summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-07-20 15:40:28 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-07-20 15:40:28 +0000
commitb595cb0c1dec83de5bdee18284abe86614bed33b (patch)
tree8c3d4540f193c5ff98019352f554e921b3a41a72 /app
parent2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff)
downloadgitlab-ce-b595cb0c1dec83de5bdee18284abe86614bed33b.tar.gz
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue5
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue6
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/index.js4
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue22
-rw-r--r--app/assets/javascripts/admin/statistics_panel/components/app.vue29
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue14
-rw-r--r--app/assets/javascripts/analytics/devops_reports/components/devops_score.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js28
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue9
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue13
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue20
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js6
-rw-r--r--app/assets/javascripts/blob/3d_viewer/index.js4
-rw-r--r--app/assets/javascripts/blob/3d_viewer/mesh_object.js4
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js8
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue3
-rw-r--r--app/assets/javascripts/boards/components/toggle_focus.vue2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue161
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue4
-rw-r--r--app/assets/javascripts/clusters_list/constants.js6
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue20
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue53
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue54
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js31
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_nodes.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js10
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js6
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js222
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js18
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js45
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js10
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue18
-rw-r--r--app/assets/javascripts/deprecated_notes.js10
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue78
-rw-r--r--app/assets/javascripts/design_management/constants.js2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue47
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js14
-rw-r--r--app/assets/javascripts/diff.js58
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue56
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue63
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue18
-rw-r--r--app/assets/javascripts/diffs/store/actions.js4
-rw-r--r--app/assets/javascripts/editor/schema/ci.json27
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js1
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js2
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue42
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue10
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/default.vue2
-rw-r--r--app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue84
-rw-r--r--app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql6
-rw-r--r--app/assets/javascripts/google_cloud/components/app.vue63
-rw-r--r--app/assets/javascripts/google_cloud/components/errors/gcp_error.vue29
-rw-r--r--app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue26
-rw-r--r--app/assets/javascripts/google_cloud/components/google_cloud_menu.vue85
-rw-r--r--app/assets/javascripts/google_cloud/components/home.vue81
-rw-r--r--app/assets/javascripts/google_cloud/components/incubation_banner.vue28
-rw-r--r--app/assets/javascripts/google_cloud/configuration/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/configuration/panel.vue88
-rw-r--r--app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue132
-rw-r--r--app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue75
-rw-r--r--app/assets/javascripts/google_cloud/databases/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/panel.vue38
-rw-r--r--app/assets/javascripts/google_cloud/databases/service_table.vue221
-rw-r--r--app/assets/javascripts/google_cloud/deployments/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/deployments/panel.vue50
-rw-r--r--app/assets/javascripts/google_cloud/deployments/service_table.vue (renamed from app/assets/javascripts/google_cloud/components/deployments_service_table.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/form.vue (renamed from app/assets/javascripts/google_cloud/components/gcp_regions_form.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/gcp_regions/list.vue (renamed from app/assets/javascripts/google_cloud/components/gcp_regions_list.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/index.js12
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/form.vue (renamed from app/assets/javascripts/google_cloud/components/service_accounts_form.vue)0
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/service_accounts/list.vue (renamed from app/assets/javascripts/google_cloud/components/service_accounts_list.vue)0
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js8
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json4
-rw-r--r--app/assets/javascripts/graphql_shared/queries/current_user.query.graphql7
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql9
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue86
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue6
-rw-r--r--app/assets/javascripts/groups/constants.js22
-rw-r--r--app/assets/javascripts/groups/index.js2
-rw-r--r--app/assets/javascripts/header.js19
-rw-r--r--app/assets/javascripts/header_search/components/app.vue111
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue44
-rw-r--r--app/assets/javascripts/header_search/constants.js22
-rw-r--r--app/assets/javascripts/header_search/init.js53
-rw-r--r--app/assets/javascripts/header_search/store/getters.js19
-rw-r--r--app/assets/javascripts/helpers/help_page_helper.js7
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue10
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue47
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue15
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js4
-rw-r--r--app/assets/javascripts/init_confirm_danger.js6
-rw-r--r--app/assets/javascripts/integrations/constants.js10
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue26
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/configuration.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue12
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue (renamed from app/assets/javascripts/invite_members/components/import_a_project_modal.vue)122
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue64
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue8
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue31
-rw-r--r--app/assets/javascripts/invite_members/constants.js4
-rw-r--r--app/assets/javascripts/invite_members/init_import_a_project_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_trigger.js20
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js4
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js31
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js13
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js5
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js43
-rw-r--r--app/assets/javascripts/issues/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue52
-rw-r--r--app/assets/javascripts/issues/list/constants.js4
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/issues/list/utils.js25
-rw-r--r--app/assets/javascripts/issues/new/components/type_popover.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue118
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/fields/title.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js26
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql8
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql6
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue266
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue44
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue37
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue36
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js14
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue5
-rw-r--r--app/assets/javascripts/issues/show/index.js1
-rw-r--r--app/assets/javascripts/jobs/bridge/app.vue118
-rw-r--r--app/assets/javascripts/jobs/bridge/components/constants.js1
-rw-r--r--app/assets/javascripts/jobs/bridge/components/empty_state.vue45
-rw-r--r--app/assets/javascripts/jobs/bridge/components/sidebar.vue105
-rw-r--r--app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql70
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue12
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue95
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue39
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue43
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue6
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue16
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue10
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue2
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue3
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/index.js44
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js27
-rw-r--r--app/assets/javascripts/jobs/store/state.js3
-rw-r--r--app/assets/javascripts/jobs/store/utils.js80
-rw-r--r--app/assets/javascripts/lib/dompurify.js1
-rw-r--r--app/assets/javascripts/lib/gfm/index.js40
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js14
-rw-r--r--app/assets/javascripts/linked_resources/index.js28
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue280
-rw-r--r--app/assets/javascripts/logs/components/log_advanced_filters.vue99
-rw-r--r--app/assets/javascripts/logs/components/log_control_buttons.vue95
-rw-r--r--app/assets/javascripts/logs/components/log_simple_filters.vue68
-rw-r--r--app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue30
-rw-r--r--app/assets/javascripts/logs/constants.js16
-rw-r--r--app/assets/javascripts/logs/index.js24
-rw-r--r--app/assets/javascripts/logs/logs_tracking_helper.js18
-rw-r--r--app/assets/javascripts/logs/stores/actions.js174
-rw-r--r--app/assets/javascripts/logs/stores/getters.js14
-rw-r--r--app/assets/javascripts/logs/stores/index.js23
-rw-r--r--app/assets/javascripts/logs/stores/mutation_types.js26
-rw-r--r--app/assets/javascripts/logs/stores/mutations.js110
-rw-r--r--app/assets/javascripts/logs/stores/state.js56
-rw-r--r--app/assets/javascripts/logs/utils.js4
-rw-r--r--app/assets/javascripts/main.js36
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue85
-rw-r--r--app/assets/javascripts/members/components/table/member_avatar.vue8
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue2
-rw-r--r--app/assets/javascripts/members/constants.js6
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js8
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue33
-rw-r--r--app/assets/javascripts/milestones/milestone.js12
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue38
-rw-r--r--app/assets/javascripts/monitoring/constants.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/javascripts/monitoring/utils.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js14
-rw-r--r--app/assets/javascripts/new_branch_form.js22
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue5
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue58
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue93
-rw-r--r--app/assets/javascripts/notes/constants.js4
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue20
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue18
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue65
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue28
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue68
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue137
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/utils.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue17
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js36
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/runners/index/index.js (renamed from app/assets/javascripts/pages/groups/runners/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/branches/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue2
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/configuration/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/deployments/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue15
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue8
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue70
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue73
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js58
-rw-r--r--app/assets/javascripts/pages/projects/logs/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue31
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js8
-rw-r--r--app/assets/javascripts/pages/projects/work_items/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue286
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue9
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue32
-rw-r--r--app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue72
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue27
-rw-r--r--app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue252
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue6
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue7
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue80
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue1
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/.gitkeep0
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/pages.yml53
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue22
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue168
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue17
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_test_details.js13
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/index.js12
-rw-r--r--app/assets/javascripts/pipelines/utils.js21
-rw-r--r--app/assets/javascripts/profile/account/index.js2
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue5
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_details_button.js8
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue2
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue12
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue2
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue30
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue17
-rw-r--r--app/assets/javascripts/projects/project_new.js1
-rw-r--r--app/assets/javascripts/projects/project_visibility.js4
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js4
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue7
-rw-r--r--app/assets/javascripts/projects/star.js34
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue6
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue44
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue18
-rw-r--r--app/assets/javascripts/releases/components/confirm_delete_modal.vue77
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue30
-rw-r--r--app/assets/javascripts/releases/components/tag_field.vue4
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue15
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql2
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql5
-rw-r--r--app/assets/javascripts/releases/mount_edit.js2
-rw-r--r--app/assets/javascripts/releases/mount_index.js2
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js45
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js16
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js9
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js9
-rw-r--r--app/assets/javascripts/releases/util.js8
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue9
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue9
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue8
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue12
-rw-r--r--app/assets/javascripts/repository/constants.js1
-rw-r--r--app/assets/javascripts/repository/graphql.js3
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/repository/log_tree.js4
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/commit.query.graphql4
-rw-r--r--app/assets/javascripts/repository/utils/commit.js1
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue45
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue154
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue36
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue1
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue111
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue46
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/paused_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js2
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_count.vue103
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue56
-rw-r--r--app/assets/javascripts/runner/constants.js1
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql)2
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql (renamed from app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql)2
-rw-r--r--app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue34
-rw-r--r--app/assets/javascripts/runner/group_runner_show/index.js8
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue122
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue4
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue26
-rw-r--r--app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql8
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue4
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue163
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/graphql.js3
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js6
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js1
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js1
-rw-r--r--app/assets/javascripts/surveys/components/satisfaction_rate.vue71
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.js52
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue169
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/index.js23
-rw-r--r--app/assets/javascripts/tabs/constants.js3
-rw-r--r--app/assets/javascripts/tabs/index.js23
-rw-r--r--app/assets/javascripts/terms/components/app.vue10
-rw-r--r--app/assets/javascripts/user_popovers.js2
-rw-r--r--app/assets/javascripts/users_select/index.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/deployment_instance.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/dom_element_listener.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql28
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue131
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue125
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/page_size_selector.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/slot_switch.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js46
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue20
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue53
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue5
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue235
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue70
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue138
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_information.vue57
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue246
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue51
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue116
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue101
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue128
-rw-r--r--app/assets/javascripts/work_items/constants.js6
-rw-r--r--app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql14
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js58
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql15
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql26
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql10
-rw-r--r--app/assets/javascripts/work_items/index.js1
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue37
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss5
-rw-r--r--app/assets/stylesheets/components/content_editor.scss9
-rw-r--r--app/assets/stylesheets/framework/header.scss3
-rw-r--r--app/assets/stylesheets/framework/icons.scss8
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/highlight/hljs.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss12
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss8
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss13
-rw-r--r--app/assets/stylesheets/highlight/themes/white.scss3
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss18
-rw-r--r--app/assets/stylesheets/mailer.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss20
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/project.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss35
-rw-r--r--app/assets/stylesheets/pages/branches.scss6
-rw-r--r--app/assets/stylesheets/pages/colors.scss24
-rw-r--r--app/assets/stylesheets/pages/commits.scss22
-rw-r--r--app/assets/stylesheets/pages/groups.scss1
-rw-r--r--app/assets/stylesheets/pages/issuable.scss33
-rw-r--r--app/assets/stylesheets/pages/issues.scss22
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss33
-rw-r--r--app/assets/stylesheets/pages/notes.scss35
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss8
-rw-r--r--app/assets/stylesheets/pages/search.scss74
-rw-r--r--app/assets/stylesheets/pages/settings.scss52
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss84
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss70
-rw-r--r--app/assets/stylesheets/themes/_dark.scss4
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss9
-rw-r--r--app/assets/stylesheets/utilities.scss48
-rw-r--r--app/channels/awareness_channel.rb84
-rw-r--r--app/components/pajamas/spinner_component.html.haml5
-rw-r--r--app/components/pajamas/spinner_component.rb27
-rw-r--r--app/controllers/admin/application_settings_controller.rb11
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb2
-rw-r--r--app/controllers/admin/hooks_controller.rb51
-rw-r--r--app/controllers/admin/system_info_controller.rb3
-rw-r--r--app/controllers/admin/topics_controller.rb10
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/google_analytics_csp.rb24
-rw-r--r--app/controllers/concerns/harbor/access.rb27
-rw-r--r--app/controllers/concerns/harbor/artifact.rb41
-rw-r--r--app/controllers/concerns/harbor/repository.rb51
-rw-r--r--app/controllers/concerns/harbor/tag.rb41
-rw-r--r--app/controllers/concerns/integrations/hooks_execution.rb60
-rw-r--r--app/controllers/concerns/integrations/params.rb1
-rw-r--r--app/controllers/concerns/issuable_actions.rb1
-rw-r--r--app/controllers/concerns/issuable_collections.rb11
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb5
-rw-r--r--app/controllers/concerns/verifies_with_email.rb194
-rw-r--r--app/controllers/confirmations_controller.rb1
-rw-r--r--app/controllers/graphql_controller.rb7
-rw-r--r--app/controllers/groups/harbor/application_controller.rb16
-rw-r--r--app/controllers/groups/harbor/artifacts_controller.rb15
-rw-r--r--app/controllers/groups/harbor/repositories_controller.rb17
-rw-r--r--app/controllers/groups/harbor/tags_controller.rb15
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb4
-rw-r--r--app/controllers/import/bitbucket_controller.rb15
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb7
-rw-r--r--app/controllers/import/bulk_imports_controller.rb8
-rw-r--r--app/controllers/import/fogbugz_controller.rb10
-rw-r--r--app/controllers/import/gitea_controller.rb2
-rw-r--r--app/controllers/import/github_controller.rb16
-rw-r--r--app/controllers/import/gitlab_controller.rb6
-rw-r--r--app/controllers/jira_connect/oauth_application_ids_controller.rb9
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb8
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb16
-rw-r--r--app/controllers/oauth/applications_controller.rb6
-rw-r--r--app/controllers/oauth/authorizations_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb29
-rw-r--r--app/controllers/projects/blame_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb1
-rw-r--r--app/controllers/projects/environments_controller.rb3
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb18
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb38
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb18
-rw-r--r--app/controllers/projects/google_cloud/deployments_controller.rb31
-rw-r--r--app/controllers/projects/google_cloud/gcp_regions_controller.rb12
-rw-r--r--app/controllers/projects/google_cloud/revoke_oauth_controller.rb7
-rw-r--r--app/controllers/projects/google_cloud/service_accounts_controller.rb25
-rw-r--r--app/controllers/projects/google_cloud_controller.rb34
-rw-r--r--app/controllers/projects/group_links_controller.rb2
-rw-r--r--app/controllers/projects/harbor/application_controller.rb12
-rw-r--r--app/controllers/projects/harbor/artifacts_controller.rb15
-rw-r--r--app/controllers/projects/harbor/repositories_controller.rb8
-rw-r--r--app/controllers/projects/harbor/tags_controller.rb15
-rw-r--r--app/controllers/projects/hooks_controller.rb51
-rw-r--r--app/controllers/projects/incidents_controller.rb3
-rw-r--r--app/controllers/projects/issues_controller.rb5
-rw-r--r--app/controllers/projects/jobs_controller.rb26
-rw-r--r--app/controllers/projects/logs_controller.rb103
-rw-r--r--app/controllers/projects/merge_requests_controller.rb5
-rw-r--r--app/controllers/projects/metrics_dashboard_controller.rb1
-rw-r--r--app/controllers/projects/pipelines/tests_controller.rb19
-rw-r--r--app/controllers/projects/pipelines_controller.rb12
-rw-r--r--app/controllers/projects/project_members_controller.rb4
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb4
-rw-r--r--app/controllers/projects/service_ping_controller.rb3
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb5
-rw-r--r--app/controllers/projects/settings/operations_controller.rb18
-rw-r--r--app/controllers/projects/tags/releases_controller.rb2
-rw-r--r--app/controllers/projects/tags_controller.rb2
-rw-r--r--app/controllers/projects/tracings_controller.rb30
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/controllers/registrations/welcome_controller.rb1
-rw-r--r--app/controllers/registrations_controller.rb3
-rw-r--r--app/controllers/search_controller.rb34
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/controllers/users/terms_controller.rb5
-rw-r--r--app/controllers/users_controller.rb8
-rw-r--r--app/events/pages/page_deleted_event.rb3
-rw-r--r--app/events/pages/page_deployed_event.rb17
-rw-r--r--app/events/projects/project_created_event.rb17
-rw-r--r--app/events/projects/project_deleted_event.rb3
-rw-r--r--app/events/projects/project_path_changed_event.rb19
-rw-r--r--app/experiments/security_actions_continuous_onboarding_experiment.rb9
-rw-r--r--app/finders/ci/auth_job_finder.rb2
-rw-r--r--app/finders/ci/runners_finder.rb8
-rw-r--r--app/finders/clusters/agents_finder.rb19
-rw-r--r--app/finders/contributed_projects_finder.rb11
-rw-r--r--app/finders/groups/user_groups_finder.rb8
-rw-r--r--app/finders/issuable_finder.rb9
-rw-r--r--app/finders/projects_finder.rb13
-rw-r--r--app/finders/snippets_finder.rb2
-rw-r--r--app/finders/user_recent_events_finder.rb50
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb9
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/widgetable.rb25
-rw-r--r--app/graphql/mutations/notes/create/diff_note.rb3
-rw-r--r--app/graphql/mutations/pages/base.rb13
-rw-r--r--app/graphql/mutations/pages/mark_onboarding_complete.rb27
-rw-r--r--app/graphql/mutations/snippets/create.rb2
-rw-r--r--app/graphql/mutations/snippets/update.rb2
-rw-r--r--app/graphql/mutations/user_callouts/create.rb2
-rw-r--r--app/graphql/mutations/work_items/create.rb16
-rw-r--r--app/graphql/mutations/work_items/update.rb9
-rw-r--r--app/graphql/mutations/work_items/update_widgets.rb1
-rw-r--r--app/graphql/queries/container_registry/get_container_repositories.query.graphql8
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql59
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb8
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb5
-rw-r--r--app/graphql/resolvers/ci/test_suite_resolver.rb2
-rw-r--r--app/graphql/resolvers/clusters/agents_resolver.rb6
-rw-r--r--app/graphql/resolvers/todo_resolver.rb66
-rw-r--r--app/graphql/resolvers/todos_resolver.rb69
-rw-r--r--app/graphql/types/alert_management/alert_type.rb2
-rw-r--r--app/graphql/types/ci/job_type.rb12
-rw-r--r--app/graphql/types/ci/runner_type.rb7
-rw-r--r--app/graphql/types/ci/runner_upgrade_status_type_enum.rb11
-rw-r--r--app/graphql/types/ci/variable_type.rb40
-rw-r--r--app/graphql/types/ci/variable_type_enum.rb13
-rw-r--r--app/graphql/types/group_type.rb7
-rw-r--r--app/graphql/types/issue_type_enum.rb4
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/graphql/types/permission_types/group_enum.rb2
-rw-r--r--app/graphql/types/project_type.rb11
-rw-r--r--app/graphql/types/query_type.rb15
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/user_interface.rb2
-rw-r--r--app/graphql/types/work_item_id_type.rb1
-rw-r--r--app/graphql/types/work_items/widget_interface.rb8
-rw-r--r--app/graphql/types/work_items/widgets/assignees_type.rb31
-rw-r--r--app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb16
-rw-r--r--app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb22
-rw-r--r--app/graphql/types/work_items/widgets/weight_input_type.rb15
-rw-r--r--app/graphql/types/work_items/widgets/weight_type.rb21
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb2
-rw-r--r--app/helpers/commits_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb2
-rw-r--r--app/helpers/emails_helper.rb102
-rw-r--r--app/helpers/environments_helper.rb1
-rw-r--r--app/helpers/groups/group_members_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/integrations_helper.rb2
-rw-r--r--app/helpers/learn_gitlab_helper.rb20
-rw-r--r--app/helpers/markup_helper.rb4
-rw-r--r--app/helpers/namespace_storage_limit_alert_helper.rb9
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb2
-rw-r--r--app/helpers/packages_helper.rb10
-rw-r--r--app/helpers/projects/pipeline_helper.rb2
-rw-r--r--app/helpers/projects/project_members_helper.rb29
-rw-r--r--app/helpers/projects_helper.rb6
-rw-r--r--app/helpers/releases_helper.rb7
-rw-r--r--app/helpers/search_helper.rb18
-rw-r--r--app/helpers/sessions_helper.rb12
-rw-r--r--app/helpers/sorting_helper.rb7
-rw-r--r--app/helpers/storage_helper.rb3
-rw-r--r--app/helpers/todos_helper.rb11
-rw-r--r--app/helpers/tree_helper.rb18
-rw-r--r--app/helpers/users/callouts_helper.rb25
-rw-r--r--app/helpers/users_helper.rb2
-rw-r--r--app/helpers/visibility_level_helper.rb2
-rw-r--r--app/helpers/web_hooks/web_hooks_helper.rb27
-rw-r--r--app/mailers/emails/admin_notification.rb7
-rw-r--r--app/mailers/emails/identity_verification.rb15
-rw-r--r--app/mailers/emails/merge_requests.rb6
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/mailers/previews/notify_preview.rb14
-rw-r--r--app/models/ability.rb7
-rw-r--r--app/models/application_setting.rb16
-rw-r--r--app/models/application_setting_implementation.rb5
-rw-r--r--app/models/authentication_event.rb5
-rw-r--r--app/models/awareness_session.rb236
-rw-r--r--app/models/ci/build.rb47
-rw-r--r--app/models/ci/build_report_result.rb4
-rw-r--r--app/models/ci/group.rb3
-rw-r--r--app/models/ci/group_variable.rb4
-rw-r--r--app/models/ci/job_artifact.rb2
-rw-r--r--app/models/ci/legacy_stage.rb73
-rw-r--r--app/models/ci/pending_build.rb14
-rw-r--r--app/models/ci/pipeline.rb85
-rw-r--r--app/models/ci/pipeline_artifact.rb17
-rw-r--r--app/models/ci/runner.rb20
-rw-r--r--app/models/ci/runner_version.rb34
-rw-r--r--app/models/ci/stage.rb2
-rw-r--r--app/models/ci/trigger.rb3
-rw-r--r--app/models/ci/variable.rb4
-rw-r--r--app/models/clusters/agent.rb2
-rw-r--r--app/models/clusters/applications/elastic_stack.rb113
-rw-r--r--app/models/clusters/cluster.rb21
-rw-r--r--app/models/clusters/concerns/elasticsearch_client.rb38
-rw-r--r--app/models/clusters/integrations/elastic_stack.rb40
-rw-r--r--app/models/clusters/integrations/prometheus.rb18
-rw-r--r--app/models/commit_status.rb9
-rw-r--r--app/models/concerns/awareness.rb41
-rw-r--r--app/models/concerns/cache_markdown_field.rb9
-rw-r--r--app/models/concerns/ci/artifactable.rb2
-rw-r--r--app/models/concerns/ci/bulk_insertable_tags.rb24
-rw-r--r--app/models/concerns/ci/has_status.rb6
-rw-r--r--app/models/concerns/each_batch.rb61
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb5
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb18
-rw-r--r--app/models/concerns/integrations/slack_mattermost_notifier.rb2
-rw-r--r--app/models/concerns/loose_index_scan.rb67
-rw-r--r--app/models/concerns/milestoneable.rb2
-rw-r--r--app/models/concerns/notification_branch_selection.rb16
-rw-r--r--app/models/concerns/packages/fips.rb11
-rw-r--r--app/models/concerns/participable.rb23
-rw-r--r--app/models/concerns/require_email_verification.rb52
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb37
-rw-r--r--app/models/container_registry/event.rb11
-rw-r--r--app/models/container_repository.rb2
-rw-r--r--app/models/customer_relations/contact.rb17
-rw-r--r--app/models/deploy_token.rb3
-rw-r--r--app/models/deployment.rb22
-rw-r--r--app/models/environment.rb8
-rw-r--r--app/models/error_tracking/client_key.rb2
-rw-r--r--app/models/group.rb20
-rw-r--r--app/models/hooks/project_hook.rb15
-rw-r--r--app/models/hooks/system_hook.rb2
-rw-r--r--app/models/hooks/web_hook.rb39
-rw-r--r--app/models/incident_management/issuable_escalation_status.rb2
-rw-r--r--app/models/integration.rb35
-rw-r--r--app/models/integrations/asana.rb37
-rw-r--r--app/models/integrations/assembla.rb29
-rw-r--r--app/models/integrations/bamboo.rb1
-rw-r--r--app/models/integrations/base_chat_notification.rb46
-rw-r--r--app/models/integrations/base_issue_tracker.rb2
-rw-r--r--app/models/integrations/campfire.rb63
-rw-r--r--app/models/integrations/confluence.rb19
-rw-r--r--app/models/integrations/datadog.rb157
-rw-r--r--app/models/integrations/discord.rb6
-rw-r--r--app/models/integrations/drone_ci.rb3
-rw-r--r--app/models/integrations/emails_on_push.rb51
-rw-r--r--app/models/integrations/external_wiki.rb22
-rw-r--r--app/models/integrations/field.rb27
-rw-r--r--app/models/integrations/flowdock.rb23
-rw-r--r--app/models/integrations/hangouts_chat.rb5
-rw-r--r--app/models/integrations/harbor.rb2
-rw-r--r--app/models/integrations/irker.rb93
-rw-r--r--app/models/integrations/jira.rb3
-rw-r--r--app/models/integrations/mattermost.rb6
-rw-r--r--app/models/integrations/microsoft_teams.rb5
-rw-r--r--app/models/integrations/mock_ci.rb2
-rw-r--r--app/models/integrations/packagist.rb51
-rw-r--r--app/models/integrations/pipelines_email.rb34
-rw-r--r--app/models/integrations/pivotaltracker.rb35
-rw-r--r--app/models/integrations/prometheus.rb66
-rw-r--r--app/models/integrations/pushover.rb141
-rw-r--r--app/models/integrations/shimo.rb18
-rw-r--r--app/models/integrations/slack.rb5
-rw-r--r--app/models/integrations/teamcity.rb5
-rw-r--r--app/models/integrations/unify_circuit.rb8
-rw-r--r--app/models/integrations/webex_teams.rb7
-rw-r--r--app/models/integrations/youtrack.rb5
-rw-r--r--app/models/integrations/zentao.rb56
-rw-r--r--app/models/issue.rb72
-rw-r--r--app/models/key.rb14
-rw-r--r--app/models/member.rb18
-rw-r--r--app/models/members/project_member.rb12
-rw-r--r--app/models/merge_request.rb5
-rw-r--r--app/models/merge_request_diff.rb51
-rw-r--r--app/models/merge_request_diff_file.rb43
-rw-r--r--app/models/namespace.rb15
-rw-r--r--app/models/namespace_setting.rb23
-rw-r--r--app/models/note.rb27
-rw-r--r--app/models/notification_recipient.rb4
-rw-r--r--app/models/oauth_access_token.rb2
-rw-r--r--app/models/operations/feature_flags_client.rb24
-rw-r--r--app/models/packages/cleanup/policy.rb15
-rw-r--r--app/models/packages/debian/file_entry.rb3
-rw-r--r--app/models/pages/virtual_domain.rb9
-rw-r--r--app/models/pages_domain.rb10
-rw-r--r--app/models/preloaders/users_max_access_level_in_projects_preloader.rb8
-rw-r--r--app/models/project.rb154
-rw-r--r--app/models/project_export_job.rb1
-rw-r--r--app/models/project_feature.rb5
-rw-r--r--app/models/project_import_state.rb9
-rw-r--r--app/models/project_setting.rb2
-rw-r--r--app/models/project_team.rb18
-rw-r--r--app/models/project_tracing_setting.rb15
-rw-r--r--app/models/projects/import_export/relation_export.rb22
-rw-r--r--app/models/projects/import_export/relation_export_upload.rb19
-rw-r--r--app/models/protected_branch.rb4
-rw-r--r--app/models/remote_mirror.rb5
-rw-r--r--app/models/repository.rb4
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/ssh_host_key.rb10
-rw-r--r--app/models/terraform/state.rb1
-rw-r--r--app/models/todo.rb4
-rw-r--r--app/models/user.rb118
-rw-r--r--app/models/users/callout.rb7
-rw-r--r--app/models/users/group_callout.rb3
-rw-r--r--app/models/users/in_product_marketing_email.rb2
-rw-r--r--app/models/users/namespace_callout.rb33
-rw-r--r--app/models/wiki_page.rb3
-rw-r--r--app/models/work_item.rb9
-rw-r--r--app/models/work_items/parent_link.rb19
-rw-r--r--app/models/work_items/type.rb6
-rw-r--r--app/models/work_items/widgets/assignees.rb10
-rw-r--r--app/models/work_items/widgets/description.rb4
-rw-r--r--app/models/work_items/widgets/hierarchy.rb4
-rw-r--r--app/models/work_items/widgets/weight.rb9
-rw-r--r--app/models/x509_certificate.rb2
-rw-r--r--app/models/x509_issuer.rb2
-rw-r--r--app/policies/global_policy.rb2
-rw-r--r--app/policies/group_policy.rb2
-rw-r--r--app/policies/incident_management/timeline_event_policy.rb10
-rw-r--r--app/policies/issue_policy.rb12
-rw-r--r--app/policies/merge_request_policy.rb2
-rw-r--r--app/policies/namespaces/user_namespace_policy.rb1
-rw-r--r--app/policies/project_policy.rb19
-rw-r--r--app/policies/work_item_policy.rb4
-rw-r--r--app/presenters/blob_presenter.rb2
-rw-r--r--app/presenters/ci/build_presenter.rb14
-rw-r--r--app/presenters/ci/legacy_stage_presenter.rb23
-rw-r--r--app/presenters/clusters/cluster_presenter.rb10
-rw-r--r--app/presenters/clusters/integration_presenter.rb2
-rw-r--r--app/presenters/commit_status_presenter.rb5
-rw-r--r--app/presenters/gitlab/blame_presenter.rb2
-rw-r--r--app/presenters/invitation_presenter.rb5
-rw-r--r--app/presenters/terraform/module_version_presenter.rb50
-rw-r--r--app/serializers/README.md4
-rw-r--r--app/serializers/ci/job_entity.rb2
-rw-r--r--app/serializers/ci/job_serializer.rb5
-rw-r--r--app/serializers/cluster_entity.rb14
-rw-r--r--app/serializers/cluster_serializer.rb2
-rw-r--r--app/serializers/environment_entity.rb21
-rw-r--r--app/serializers/error_tracking/error_entity.rb6
-rw-r--r--app/serializers/integrations/event_entity.rb10
-rw-r--r--app/serializers/integrations/harbor_serializers/artifact_entity.rb29
-rw-r--r--app/serializers/integrations/harbor_serializers/artifact_serializer.rb11
-rw-r--r--app/serializers/integrations/harbor_serializers/repository_entity.rb57
-rw-r--r--app/serializers/integrations/harbor_serializers/repository_serializer.rb11
-rw-r--r--app/serializers/integrations/harbor_serializers/tag_entity.rb41
-rw-r--r--app/serializers/integrations/harbor_serializers/tag_serializer.rb11
-rw-r--r--app/serializers/issue_entity.rb2
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/serializers/stage_entity.rb22
-rw-r--r--app/services/alert_management/alerts/update_service.rb26
-rw-r--r--app/services/audit_event_service.rb7
-rw-r--r--app/services/auto_merge/base_service.rb15
-rw-r--r--app/services/bulk_imports/create_pipeline_trackers_service.rb2
-rw-r--r--app/services/ci/build_report_result_service.rb2
-rw-r--r--app/services/ci/external_pull_requests/create_pipeline_service.rb14
-rw-r--r--app/services/ci/generate_coverage_reports_service.rb13
-rw-r--r--app/services/ci/job_artifacts/create_service.rb15
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb10
-rw-r--r--app/services/ci/pipeline_artifacts/coverage_report_service.rb30
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb2
-rw-r--r--app/services/ci/queue/build_queue_service.rb26
-rw-r--r--app/services/ci/queue/builds_table_strategy.rb75
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb27
-rw-r--r--app/services/ci/runners/reconcile_existing_runner_versions_service.rb90
-rw-r--r--app/services/ci/runners/register_runner_service.rb14
-rw-r--r--app/services/ci/test_failure_history_service.rb2
-rw-r--r--app/services/ci/update_build_queue_service.rb10
-rw-r--r--app/services/ci/update_pending_build_service.rb2
-rw-r--r--app/services/clusters/integrations/create_service.rb2
-rw-r--r--app/services/concerns/alert_management/alert_processing.rb18
-rw-r--r--app/services/concerns/integrations/project_test_data.rb2
-rw-r--r--app/services/concerns/work_items/widgetable_service.rb26
-rw-r--r--app/services/deployments/create_for_build_service.rb12
-rw-r--r--app/services/error_tracking/list_issues_service.rb1
-rw-r--r--app/services/event_create_service.rb37
-rw-r--r--app/services/feature_flags/base_service.rb4
-rw-r--r--app/services/feature_flags/create_service.rb2
-rw-r--r--app/services/feature_flags/destroy_service.rb2
-rw-r--r--app/services/feature_flags/update_service.rb2
-rw-r--r--app/services/git/branch_hooks_service.rb8
-rw-r--r--app/services/git/branch_push_service.rb4
-rw-r--r--app/services/google_cloud/base_service.rb65
-rw-r--r--app/services/google_cloud/create_service_accounts_service.rb18
-rw-r--r--app/services/google_cloud/enable_cloud_run_service.rb15
-rw-r--r--app/services/google_cloud/gcp_region_add_or_replace_service.rb4
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb2
-rw-r--r--app/services/google_cloud/service_accounts_service.rb36
-rw-r--r--app/services/google_cloud/setup_cloudsql_instance_service.rb74
-rw-r--r--app/services/gravatar_service.rb1
-rw-r--r--app/services/groups/base_service.rb4
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/groups/group_links/destroy_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb6
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/after_update_service.rb22
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/build_service.rb17
-rw-r--r--app/services/incident_management/issuable_escalation_statuses/prepare_update_service.rb2
-rw-r--r--app/services/incident_management/timeline_events/create_service.rb48
-rw-r--r--app/services/incident_management/timeline_events/update_service.rb5
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb126
-rw-r--r--app/services/issuable/clone/base_service.rb40
-rw-r--r--app/services/issuable/import_csv/base_service.rb3
-rw-r--r--app/services/issuable_base_service.rb23
-rw-r--r--app/services/issuable_links/create_service.rb2
-rw-r--r--app/services/issues/build_service.rb15
-rw-r--r--app/services/issues/clone_service.rb17
-rw-r--r--app/services/issues/close_service.rb5
-rw-r--r--app/services/issues/create_service.rb21
-rw-r--r--app/services/issues/move_service.rb1
-rw-r--r--app/services/issues/related_branches_service.rb33
-rw-r--r--app/services/issues/reopen_service.rb7
-rw-r--r--app/services/issues/update_service.rb3
-rw-r--r--app/services/members/create_service.rb2
-rw-r--r--app/services/members/creator_service.rb98
-rw-r--r--app/services/members/invite_member_builder.rb19
-rw-r--r--app/services/members/invite_service.rb4
-rw-r--r--app/services/members/projects/creator_service.rb2
-rw-r--r--app/services/members/standard_member_builder.rb23
-rw-r--r--app/services/merge_requests/approval_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb12
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb8
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb5
-rw-r--r--app/services/notification_recipients/builder/base.rb10
-rw-r--r--app/services/packages/cleanup/execute_policy_service.rb98
-rw-r--r--app/services/packages/debian/create_package_file_service.rb3
-rw-r--r--app/services/packages/debian/extract_changes_metadata_service.rb3
-rw-r--r--app/services/packages/debian/generate_distribution_service.rb3
-rw-r--r--app/services/packages/mark_package_files_for_destruction_service.rb35
-rw-r--r--app/services/packages/pypi/create_package_service.rb2
-rw-r--r--app/services/pages/delete_service.rb3
-rw-r--r--app/services/pod_logs/base_service.rb91
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb98
-rw-r--r--app/services/pod_logs/kubernetes_service.rb151
-rw-r--r--app/services/preview_markdown_service.rb2
-rw-r--r--app/services/projects/after_rename_service.rb13
-rw-r--r--app/services/projects/blame_service.rb9
-rw-r--r--app/services/projects/create_service.rb12
-rw-r--r--app/services/projects/destroy_service.rb41
-rw-r--r--app/services/projects/fork_service.rb16
-rw-r--r--app/services/projects/group_links/update_service.rb2
-rw-r--r--app/services/projects/move_deploy_keys_projects_service.rb11
-rw-r--r--app/services/projects/operations/update_service.rb10
-rw-r--r--app/services/projects/update_pages_service.rb143
-rw-r--r--app/services/projects/update_service.rb16
-rw-r--r--app/services/protected_branches/api_service.rb4
-rw-r--r--app/services/protected_refs/access_level_params.rb (renamed from app/services/protected_branches/access_level_params.rb)4
-rw-r--r--app/services/quick_actions/interpret_service.rb46
-rw-r--r--app/services/repositories/changelog_service.rb7
-rw-r--r--app/services/resource_access_tokens/create_service.rb2
-rw-r--r--app/services/search_service.rb17
-rw-r--r--app/services/service_ping/submit_service.rb13
-rw-r--r--app/services/system_notes/incidents_service.rb8
-rw-r--r--app/services/terraform/states/trigger_destroy_service.rb6
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/web_hook_service.rb17
-rw-r--r--app/services/web_hooks/destroy_service.rb3
-rw-r--r--app/services/web_hooks/log_execution_service.rb1
-rw-r--r--app/services/work_items/create_and_link_service.rb6
-rw-r--r--app/services/work_items/create_from_task_service.rb3
-rw-r--r--app/services/work_items/create_service.rb23
-rw-r--r--app/services/work_items/parent_links/create_service.rb60
-rw-r--r--app/services/work_items/parent_links/destroy_service.rb32
-rw-r--r--app/services/work_items/task_list_reference_removal_service.rb15
-rw-r--r--app/services/work_items/task_list_reference_replacement_service.rb9
-rw-r--r--app/services/work_items/update_service.rb30
-rw-r--r--app/services/work_items/widgets/base_service.rb16
-rw-r--r--app/services/work_items/widgets/description_service/update_service.rb15
-rw-r--r--app/services/work_items/widgets/hierarchy_service/base_service.rb79
-rw-r--r--app/services/work_items/widgets/hierarchy_service/create_service.rb15
-rw-r--r--app/services/work_items/widgets/hierarchy_service/update_service.rb15
-rw-r--r--app/services/work_items/widgets/weight_service/update_service.rb15
-rw-r--r--app/views/admin/application_settings/_eks.html.haml2
-rw-r--r--app/views/admin/application_settings/_error_tracking.html.haml40
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_floc.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml4
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml2
-rw-r--r--app/views/admin/application_settings/_jira_connect_application_key.html.haml4
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml4
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml2
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml4
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml2
-rw-r--r--app/views/admin/application_settings/_signup.html.haml2
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml2
-rw-r--r--app/views/admin/application_settings/_spam.html.haml2
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml2
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml4
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml2
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml2
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml6
-rw-r--r--app/views/admin/application_settings/general.html.haml23
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml10
-rw-r--r--app/views/admin/application_settings/network.html.haml28
-rw-r--r--app/views/admin/application_settings/preferences.html.haml16
-rw-r--r--app/views/admin/application_settings/reporting.html.haml6
-rw-r--r--app/views/admin/application_settings/repository.html.haml10
-rw-r--r--app/views/admin/applications/_form.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml72
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/identities/index.html.haml1
-rw-r--r--app/views/admin/system_info/show.html.haml4
-rw-r--r--app/views/admin/topics/_topic.html.haml1
-rw-r--r--app/views/admin/users/_access_levels.html.haml100
-rw-r--r--app/views/admin/users/_admin_notes.html.haml15
-rw-r--r--app/views/admin/users/_form.html.haml116
-rw-r--r--app/views/admin/users/_head.html.haml13
-rw-r--r--app/views/admin/users/_users.html.haml2
-rw-r--r--app/views/clusters/clusters/_integrations.html.haml12
-rw-r--r--app/views/clusters/clusters/_namespace.html.haml2
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml7
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml10
-rw-r--r--app/views/dashboard/_activities.html.haml2
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml2
-rw-r--r--app/views/dashboard/todos/_todo.html.haml10
-rw-r--r--app/views/devise/passwords/edit.html.haml13
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/sessions/email_verification.haml19
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/sessions/successful_verification.haml11
-rw-r--r--app/views/devise/shared/_signup_box.html.haml7
-rw-r--r--app/views/errors/not_found.html.haml3
-rw-r--r--app/views/errors/omniauth_error.html.haml16
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_group_admin_settings.html.haml59
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml2
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml34
-rw-r--r--app/views/groups/_new_group_fields.html.haml4
-rw-r--r--app/views/groups/_shared_projects.html.haml2
-rw-r--r--app/views/groups/group_members/index.html.haml23
-rw-r--r--app/views/groups/harbor/repositories/index.html.haml2
-rw-r--r--app/views/groups/merge_requests.html.haml2
-rw-r--r--app/views/groups/runners/show.html.haml8
-rw-r--r--app/views/groups/settings/_general.html.haml10
-rw-r--r--app/views/groups/settings/_permissions.html.haml16
-rw-r--r--app/views/groups/settings/_project_creation_level.html.haml2
-rw-r--r--app/views/groups/settings/_resource_access_token_creation.html.haml5
-rw-r--r--app/views/groups/settings/_subgroup_creation_level.html.haml2
-rw-r--r--app/views/groups/settings/_transfer.html.haml2
-rw-r--r--app/views/groups/settings/_two_factor_auth.html.haml8
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/_form.html.haml1
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml3
-rw-r--r--app/views/groups/settings/repository/_default_branch.html.haml2
-rw-r--r--app/views/groups/show.html.haml6
-rw-r--r--app/views/import/bitbucket/status.html.haml2
-rw-r--r--app/views/import/bitbucket_server/new.html.haml2
-rw-r--r--app/views/import/bitbucket_server/status.html.haml2
-rw-r--r--app/views/import/bulk_imports/status.html.haml1
-rw-r--r--app/views/import/fogbugz/new.html.haml2
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml2
-rw-r--r--app/views/import/fogbugz/status.html.haml2
-rw-r--r--app/views/import/gitlab/status.html.haml2
-rw-r--r--app/views/layouts/_bizible.html.haml1
-rw-r--r--app/views/layouts/_flash.html.haml3
-rw-r--r--app/views/layouts/_header_search.html.haml4
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/devise.html.haml2
-rw-r--r--app/views/layouts/devise_empty.html.haml2
-rw-r--r--app/views/layouts/group.html.haml2
-rw-r--r--app/views/layouts/mailer.html.haml4
-rw-r--r--app/views/layouts/mailer.text.erb2
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml2
-rw-r--r--app/views/layouts/notify.html.haml11
-rw-r--r--app/views/layouts/notify.text.erb2
-rw-r--r--app/views/layouts/project.html.haml5
-rw-r--r--app/views/layouts/signup_onboarding.html.haml2
-rw-r--r--app/views/notify/_failed_builds.html.haml8
-rw-r--r--app/views/notify/_reassigned_issuable_email.html.haml11
-rw-r--r--app/views/notify/_relabeled_issuable_email.html.haml3
-rw-r--r--app/views/notify/_removal_notification.html.haml14
-rw-r--r--app/views/notify/_successful_pipeline.html.haml31
-rw-r--r--app/views/notify/approved_merge_request_email.html.haml4
-rw-r--r--app/views/notify/autodevops_disabled_email.html.haml38
-rw-r--r--app/views/notify/changed_milestone_email.html.haml4
-rw-r--r--app/views/notify/closed_merge_request_email.html.haml5
-rw-r--r--app/views/notify/member_access_denied_email.html.haml11
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.html.haml4
-rw-r--r--app/views/notify/unapproved_merge_request_email.html.haml4
-rw-r--r--app/views/notify/user_auto_banned_email.html.haml2
-rw-r--r--app/views/notify/user_auto_banned_email.text.erb2
-rw-r--r--app/views/notify/verification_instructions_email.html.haml12
-rw-r--r--app/views/notify/verification_instructions_email.text.erb8
-rw-r--r--app/views/profiles/_email_settings.html.haml4
-rw-r--r--app/views/profiles/_name.html.haml4
-rw-r--r--app/views/profiles/accounts/show.html.haml10
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml5
-rw-r--r--app/views/profiles/passwords/new.html.haml5
-rw-r--r--app/views/profiles/show.html.haml36
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_activity.html.haml2
-rw-r--r--app/views/projects/_files.html.haml2
-rw-r--r--app/views/projects/_import_project_pane.html.haml8
-rw-r--r--app/views/projects/_merge_request_squash_options_settings.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml13
-rw-r--r--app/views/projects/_remove.html.haml2
-rw-r--r--app/views/projects/_remove_fork.html.haml2
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml2
-rw-r--r--app/views/projects/_transfer.html.haml2
-rw-r--r--app/views/projects/activity.html.haml1
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml4
-rw-r--r--app/views/projects/blob/_remove.html.haml16
-rw-r--r--app/views/projects/blob/show.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml8
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/commit/_change.html.haml21
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_content.html.haml4
-rw-r--r--app/views/projects/edit.html.haml4
-rw-r--r--app/views/projects/empty.html.haml4
-rw-r--r--app/views/projects/find_file/show.html.haml2
-rw-r--r--app/views/projects/forks/new.html.haml2
-rw-r--r--app/views/projects/google_cloud/configuration/index.html.haml7
-rw-r--r--app/views/projects/google_cloud/databases/index.html.haml7
-rw-r--r--app/views/projects/google_cloud/deployments/index.html.haml7
-rw-r--r--app/views/projects/google_cloud/errors/gcp_error.html.haml6
-rw-r--r--app/views/projects/google_cloud/errors/no_gcp_projects.html.haml6
-rw-r--r--app/views/projects/google_cloud/gcp_regions/index.html.haml6
-rw-r--r--app/views/projects/google_cloud/index.html.haml6
-rw-r--r--app/views/projects/google_cloud/service_accounts/index.html.haml6
-rw-r--r--app/views/projects/harbor/repositories/index.html.haml2
-rw-r--r--app/views/projects/imports/show.html.haml5
-rw-r--r--app/views/projects/incidents/show.html.haml1
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml7
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml2
-rw-r--r--app/views/projects/issues/show.html.haml1
-rw-r--r--app/views/projects/jobs/index.html.haml10
-rw-r--r--app/views/projects/jobs/show.html.haml5
-rw-r--r--app/views/projects/labels/index.html.haml1
-rw-r--r--app/views/projects/logs/empty_logs.html.haml14
-rw-r--r--app/views/projects/logs/index.html.haml1
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml4
-rw-r--r--app/views/projects/merge_requests/_commits.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml2
-rw-r--r--app/views/projects/merge_requests/show.html.haml3
-rw-r--r--app/views/projects/milestones/_form.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml2
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml2
-rw-r--r--app/views/projects/new.html.haml4
-rw-r--r--app/views/projects/pipelines/_info.html.haml12
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/project_members/index.html.haml66
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml22
-rw-r--r--app/views/projects/protected_branches/shared/_dropdown.html.haml8
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/settings/_archive.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml10
-rw-r--r--app/views/projects/settings/operations/_tracing.html.haml24
-rw-r--r--app/views/projects/settings/operations/show.html.haml3
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/tags/_edit_release_button.html.haml11
-rw-r--r--app/views/projects/tags/_tag.html.haml3
-rw-r--r--app/views/projects/tags/show.html.haml5
-rw-r--r--app/views/projects/tracings/_tracing_button.html.haml2
-rw-r--r--app/views/projects/tracings/show.html.haml50
-rw-r--r--app/views/projects/triggers/_form.html.haml2
-rw-r--r--app/views/projects/work_items/index.html.haml2
-rw-r--r--app/views/pwa/offline.html.haml2
-rw-r--r--app/views/shared/_allow_request_access.html.haml2
-rw-r--r--app/views/shared/_group_form.html.haml51
-rw-r--r--app/views/shared/_integration_settings.html.haml2
-rw-r--r--app/views/shared/_label.html.haml27
-rw-r--r--app/views/shared/_old_visibility_level.html.haml9
-rw-r--r--app/views/shared/_visibility_level.html.haml4
-rw-r--r--app/views/shared/_visibility_radios.html.haml16
-rw-r--r--app/views/shared/admin/_admin_note_form.html.haml8
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml13
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml4
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml28
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml8
-rw-r--r--app/views/shared/form_elements/_apply_template_warning.html.haml4
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/groups/_group_name_and_path_fields.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml10
-rw-r--r--app/views/shared/issuable/_form.html.haml6
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml13
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml60
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml1
-rw-r--r--app/views/shared/members/_member.html.haml3
-rw-r--r--app/views/shared/milestones/_header.html.haml18
-rw-r--r--app/views/shared/runners/_form.html.haml2
-rw-r--r--app/views/shared/snippets/_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/shared/wikis/edit.html.haml2
-rw-r--r--app/views/users/_profile_basic_info.html.haml3
-rw-r--r--app/workers/all_queues.yml105
-rw-r--r--app/workers/authorized_projects_worker.rb11
-rw-r--r--app/workers/build_hooks_worker.rb12
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb8
-rw-r--r--app/workers/ci/archive_trace_worker.rb8
-rw-r--r--app/workers/ci/build_finished_worker.rb13
-rw-r--r--app/workers/ci/pipeline_artifacts/coverage_report_worker.rb10
-rw-r--r--app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb23
-rw-r--r--app/workers/clusters/applications/activate_service_worker.rb12
-rw-r--r--app/workers/clusters/applications/deactivate_service_worker.rb12
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb6
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb6
-rw-r--r--app/workers/concerns/packages/cleanup_artifact_worker.rb15
-rw-r--r--app/workers/concerns/waitable_worker.rb22
-rw-r--r--app/workers/container_registry/migration/enqueuer_worker.rb11
-rw-r--r--app/workers/deployments/hooks_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/import_issue_event_worker.rb21
-rw-r--r--app/workers/gitlab/github_import/stage/import_issue_events_worker.rb47
-rw-r--r--app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb2
-rw-r--r--app/workers/gitlab_service_ping_worker.rb2
-rw-r--r--app/workers/google_cloud/create_cloudsql_instance_worker.rb38
-rw-r--r--app/workers/incident_management/close_incident_worker.rb43
-rw-r--r--app/workers/irker_worker.rb9
-rw-r--r--app/workers/loose_foreign_keys/cleanup_worker.rb2
-rw-r--r--app/workers/packages/cleanup/execute_policy_worker.rb72
-rw-r--r--app/workers/packages/cleanup_package_registry_worker.rb8
-rw-r--r--app/workers/packages/debian/generate_distribution_worker.rb3
-rw-r--r--app/workers/packages/debian/process_changes_worker.rb5
-rw-r--r--app/workers/pages/invalidate_domain_cache_worker.rb25
-rw-r--r--app/workers/pages_transfer_worker.rb20
-rw-r--r--app/workers/post_receive.rb13
-rw-r--r--app/workers/project_service_worker.rb16
-rw-r--r--app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb12
-rw-r--r--app/workers/web_hooks/destroy_worker.rb27
1258 files changed, 15831 insertions, 9280 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
index 944a2ef7f64..59f0e0dd17d 100644
--- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -138,10 +138,9 @@ export default {
}}</span>
</template>
- <template #cell(action)="{ item: { revokePath, expiresAt } }">
+ <template #cell(action)="{ item: { revokePath } }">
<gl-button
- variant="danger"
- :category="expiresAt ? 'primary' : 'secondary'"
+ category="tertiary"
:aria-label="$options.i18n.revokeButton"
:data-confirm="modalMessage"
data-confirm-btn-variant="danger"
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index 904052688f3..e111ae91e5c 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -117,7 +117,7 @@ export default {
<template v-if="errors">
<gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null">
- <ul class="m-0">
+ <ul class="gl-m-0">
<li v-for="error in errors" :key="error">
{{ error }}
</li>
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
index 78a575ffe96..a58b6e62254 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
@@ -37,11 +37,11 @@ export default {
<gl-button
:class="[
{
- 'ml-3': !contextCommitsEmpty,
- 'mt-3': !commitsEmpty && contextCommitsEmpty,
+ 'gl-ml-5': !contextCommitsEmpty,
+ 'gl-mt-5': !commitsEmpty && contextCommitsEmpty,
},
]"
- :variant="commitsEmpty ? 'info' : 'default'"
+ :variant="commitsEmpty ? 'confirm' : 'default'"
@click="openModal"
>
{{ buttonText }}
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 96584080d0f..8ad218ab97b 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
@@ -244,7 +244,7 @@ export default {
</template>
</gl-sprintf>
</template>
- <div class="mt-2">
+ <div class="gl-mt-3">
<gl-search-box-by-type
ref="searchInput"
:placeholder="__(`Search by commit title or SHA`)"
diff --git a/app/assets/javascripts/add_context_commits_modal/index.js b/app/assets/javascripts/add_context_commits_modal/index.js
index 697d32664e8..110677781a7 100644
--- a/app/assets/javascripts/add_context_commits_modal/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/index.js
@@ -8,7 +8,7 @@ export default function initAddContextCommitsTriggers() {
const addContextCommitsModalTriggerEl = document.querySelector('.add-review-item-modal-trigger');
const addContextCommitsModalWrapperEl = document.querySelector('.add-review-item-modal-wrapper');
- if (addContextCommitsModalTriggerEl || addContextCommitsModalWrapperEl) {
+ if (addContextCommitsModalTriggerEl) {
// eslint-disable-next-line no-new
new Vue({
el: addContextCommitsModalTriggerEl,
@@ -28,7 +28,9 @@ export default function initAddContextCommitsTriggers() {
});
},
});
+ }
+ if (addContextCommitsModalWrapperEl) {
const store = createStore();
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 29e8b9a724e..46e7ac3cf28 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -35,8 +35,12 @@ export default {
label: __('Title'),
},
{
+ key: 'fingerprint_sha256',
+ label: __('Fingerprint (SHA256)'),
+ },
+ {
key: 'fingerprint',
- label: __('Fingerprint'),
+ label: __('Fingerprint (MD5)'),
},
{
key: 'projects',
@@ -130,10 +134,18 @@ export default {
}
this.items = items.map(
- ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({
+ ({
id,
title,
fingerprint,
+ fingerprint_sha256,
+ projects_with_write_access,
+ created_at,
+ }) => ({
+ id,
+ title,
+ fingerprint,
+ fingerprint_sha256,
projects: projects_with_write_access,
created: created_at,
}),
@@ -196,8 +208,12 @@ export default {
>
</template>
+ <template #cell(fingerprint_sha256)="{ item: { fingerprint_sha256 } }">
+ <span v-if="fingerprint_sha256" class="monospace">{{ fingerprint_sha256 }}</span>
+ </template>
+
<template #cell(fingerprint)="{ item: { fingerprint } }">
- <code>{{ fingerprint }}</code>
+ <span v-if="fingerprint" class="monospace">{{ fingerprint }}</span>
</template>
<template #cell(created)="{ item: { created } }">
diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue
index f250bdae4f5..347d5f0229c 100644
--- a/app/assets/javascripts/admin/statistics_panel/components/app.vue
+++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue
@@ -1,10 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlCard, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import statisticsLabels from '../constants';
export default {
components: {
+ GlCard,
GlLoadingIcon,
},
data() {
@@ -26,20 +27,14 @@ export default {
</script>
<template>
- <div class="gl-card">
- <div class="gl-card-body">
- <h4>{{ __('Statistics') }}</h4>
- <gl-loading-icon v-if="isLoading" size="lg" class="my-3" />
- <template v-else>
- <p
- v-for="statistic in getStatistics(statisticsLabels)"
- :key="statistic.key"
- class="js-stats"
- >
- {{ statistic.label }}
- <span class="light float-right">{{ statistic.value }}</span>
- </p>
- </template>
- </div>
- </div>
+ <gl-card>
+ <h4>{{ __('Statistics') }}</h4>
+ <gl-loading-icon v-if="isLoading" size="lg" class="my-3" />
+ <template v-else>
+ <p v-for="statistic in getStatistics(statisticsLabels)" :key="statistic.key" class="js-stats">
+ {{ statistic.label }}
+ <span class="light float-right">{{ statistic.value }}</span>
+ </p>
+ </template>
+ </gl-card>
</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index 40e5f8d9d70..691a292673c 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -94,13 +94,13 @@ export default {
:data-testid="`user-actions-${user.id}`"
>
<div v-if="hasEditAction" class="gl-p-2">
- <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs">{{
+ <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{
$options.i18n.edit
}}</gl-button>
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
- icon="pencil"
+ icon="pencil-square"
v-bind="editButtonAttrs"
:aria-label="$options.i18n.edit"
/>
@@ -108,18 +108,12 @@ export default {
<div v-if="hasDropdownActions" class="gl-p-2">
<gl-dropdown
- v-gl-tooltip="$options.i18n.userAdministration"
+ :text="$options.i18n.userAdministration"
data-testid="dropdown-toggle"
- icon="ellipsis_v"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
- no-caret
- right
+ left
>
- <gl-dropdown-section-header>{{
- $options.i18n.userAdministration
- }}</gl-dropdown-section-header>
-
<template v-for="action in dropdownSafeActions">
<component
:is="getActionComponent(action)"
diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
index 5a394059931..fd966425920 100644
--- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
+++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue
@@ -40,7 +40,7 @@ export default {
return this.devopsScoreMetrics.averageScore === undefined;
},
},
- devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_report'),
+ devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_reports'),
tableHeaderFields: [
{
key: 'title',
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 38d05552783..e1bc59b36ef 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -16,60 +16,32 @@ export const dateFormats = {
// 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'),
},
- 'time-to-restore-service': {
- description: s__(
- 'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.',
- ),
- },
time_to_restore_service: {
description: s__(
'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.',
),
},
- 'change-failure-rate': {
- description: s__(
- 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
- ),
- },
change_failure_rate: {
description: s__(
'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 2b1ab911fbe..300a81caa5c 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NoteableNote from '~/notes/components/noteable_note.vue';
import PublishButton from './publish_button.vue';
@@ -14,6 +15,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
draft: {
type: Object,
@@ -92,6 +94,7 @@ export default {
:note="draft"
:line="line"
:discussion-root="true"
+ :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }"
class="draft-note"
@handleEdit="handleEditing"
@cancelForm="handleNotEditing"
@@ -113,7 +116,11 @@ export default {
class="referenced-commands draft-note-commands"
></div>
- <p class="draft-note-actions d-flex" data-qa-selector="draft_note_content">
+ <p
+ v-if="!glFeatures.mrReviewSubmitComment"
+ class="draft-note-actions d-flex"
+ data-qa-selector="draft_note_content"
+ >
<publish-button
:show-count="true"
:should-publish="false"
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index f839056daf8..ba5cc0d1a76 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -2,6 +2,7 @@
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import PreviewItem from './preview_item.vue';
import DraftsCount from './drafts_count.vue';
@@ -17,6 +18,7 @@ export default {
computed: {
...mapState('diffs', ['viewDiffsFileByFile']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
+ ...mapGetters(['getNoteableData']),
},
methods: {
...mapActions('diffs', ['setCurrentFileHash']),
@@ -24,12 +26,21 @@ export default {
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
+ isOnLatestDiff(draft) {
+ return draft.position?.head_sha === this.getNoteableData.diff_head_sha;
+ },
async onClickDraft(draft) {
if (this.viewDiffsFileByFile && draft.file_hash) {
await this.setCurrentFileHash(draft.file_hash);
}
- await this.scrollToDraft(draft);
+ if (draft.position && !this.isOnLatestDiff(draft)) {
+ const url = new URL(setUrlParams({ commit_id: draft.position.head_sha }));
+ url.hash = `note_${draft.id}`;
+ visitUrl(url.toString());
+ } else {
+ await this.scrollToDraft(draft);
+ }
},
},
};
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 5f4a1e44ea3..b070848cae9 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -22,6 +22,18 @@ export default {
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
+ mounted() {
+ // We override the Bootstrap Vue click outside behaviour
+ // to allow for clicking in the autocomplete dropdowns
+ // without this override the submit dropdown will close
+ // whenever a item in the autocomplete dropdown is clicked
+ const originalClickOutHandler = this.$refs.dropdown.$refs.dropdown.clickOutHandler;
+ this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => {
+ if (!e.target.closest('.atwho-container')) {
+ originalClickOutHandler(e);
+ }
+ };
+ },
methods: {
...mapActions('batchComments', ['publishReview']),
async submitReview() {
@@ -52,7 +64,13 @@ export default {
</script>
<template>
- <gl-dropdown right class="submit-review-dropdown" variant="info" category="secondary">
+ <gl-dropdown
+ ref="dropdown"
+ right
+ class="submit-review-dropdown"
+ variant="info"
+ category="secondary"
+ >
<template #button-content>
{{ __('Finish review') }}
<gl-icon class="dropdown-chevron" name="chevron-up" />
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 908cbfd6dc8..a44b9827fe9 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -138,6 +138,12 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
window.mrTabs.tabShown(tab);
}
+ const { file_path: filePath } = draft;
+
+ if (filePath) {
+ dispatch('diffs/setFileCollapsedAutomatically', { filePath, collapsed: false }, { root: true });
+ }
+
if (discussion) {
dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true });
}
diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js
index fd064e7ca8f..d4efe409fef 100644
--- a/app/assets/javascripts/blob/3d_viewer/index.js
+++ b/app/assets/javascripts/blob/3d_viewer/index.js
@@ -98,9 +98,9 @@ export default class Renderer {
requestAnimationFrame(this.renderWrapper);
}
- changeObjectMaterials(type) {
+ changeObjectMaterials(material) {
this.objects.forEach((obj) => {
- obj.changeMaterial(type);
+ obj.changeMaterial(material);
});
}
diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
index cb7fcff8674..c55a9ca8926 100644
--- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js
+++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
@@ -30,7 +30,7 @@ export default class MeshObject extends Mesh {
}
}
- changeMaterial(type) {
- this.material = materials[type];
+ changeMaterial(materialKey) {
+ this.material = materials[materialKey];
}
}
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index 0ea623a705a..768bbce9c57 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -5,15 +5,15 @@ export default () => {
[].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
el.addEventListener('click', (e) => {
- const { target } = e;
+ const { currentTarget } = e;
e.preventDefault();
document.querySelector('.js-material-changer.selected').classList.remove('selected');
- target.classList.add('selected');
- target.blur();
+ currentTarget.classList.add('selected');
+ currentTarget.blur();
- viewer.changeObjectMaterials(target.dataset.type);
+ viewer.changeObjectMaterials(currentTarget.dataset.material);
});
});
};
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index dc821cb9f58..3638fdd2ca5 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -48,6 +48,15 @@ export default {
isDraggable() {
return !this.disabled && this.item.id && !this.item.isLoading;
},
+ cardStyle() {
+ return this.isColorful && this.item.color ? { borderColor: this.item.color } : '';
+ },
+ isColorful() {
+ return gon?.features?.epicColorHighlight;
+ },
+ colorClass() {
+ return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : '';
+ },
},
methods: {
...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
@@ -70,17 +79,21 @@ export default {
<template>
<li
data-qa-selector="board_card"
- :class="{
- 'multi-select': multiSelectVisible,
- 'gl-cursor-grab': isDraggable,
- 'is-disabled': isDisabled,
- 'is-active': isActive,
- 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
- }"
+ :class="[
+ {
+ 'multi-select': multiSelectVisible,
+ 'gl-cursor-grab': isDraggable,
+ 'is-disabled': isDisabled,
+ 'is-active': isActive,
+ 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
+ },
+ colorClass,
+ ]"
:index="index"
:data-item-id="item.id"
:data-item-iid="item.iid"
:data-item-path="item.referencePath"
+ :style="cardStyle"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@click="toggleIssue($event)"
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 98ce1ac7f97..a632f5ae0ed 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -243,6 +243,7 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
+ target="#"
@click="filterByLabel(label)"
/>
</template>
@@ -253,7 +254,7 @@ export default {
<div
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
- <gl-loading-icon v-if="item.isLoading" size="lg" class="mt-3" />
+ <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue
index 71612e0742f..990a6fa63d4 100644
--- a/app/assets/javascripts/boards/components/toggle_focus.vue
+++ b/app/assets/javascripts/boards/components/toggle_focus.vue
@@ -20,7 +20,7 @@ export default {
hide(this.$refs.toggleFocusModeButton);
const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
- issueBoardsContent.classList.toggle('is-focused');
+ issueBoardsContent?.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index ebcc4b85ac4..9d8cb40b60a 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -3,7 +3,6 @@ import {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlModalDirective,
@@ -14,9 +13,9 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Api, { DEFAULT_PER_PAGE } from '~/api';
-import { helpPagePath } from '~/helpers/help_page_helper';
import httpStatusCodes from '~/lib/utils/http_status';
import { __, s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -24,7 +23,6 @@ export default {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlPagination,
@@ -36,22 +34,18 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
+ mixins: [Tracking.mixin()],
inject: ['projectId', 'admin', 'fileSizeLimit'],
- docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
deleteLabel: __('Delete File'),
uploadLabel: __('Upload File'),
uploadingLabel: __('Uploading...'),
+ noFilesMessage: __('There are no secure files yet.'),
pagination: {
next: __('Next'),
prev: __('Prev'),
},
- title: __('Secure Files'),
- overviewMessage: __(
- 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
- ),
- moreInformation: __('More information'),
uploadErrorMessages: {
duplicate: __('A file with this name already exists.'),
tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'),
@@ -79,12 +73,12 @@ export default {
fields: [
{
key: 'name',
- label: __('Filename'),
+ label: __('File name'),
tdClass: 'gl-vertical-align-middle!',
},
{
key: 'created_at',
- label: __('Uploaded'),
+ label: __('Uploaded date'),
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -113,6 +107,8 @@ export default {
try {
await Api.deleteProjectSecureFile(this.projectId, secureFileId);
this.getProjectSecureFiles();
+
+ this.track('delete_secure_file');
} catch (error) {
Sentry.captureException(error);
this.error = true;
@@ -129,6 +125,7 @@ export default {
this.loading = false;
this.uploading = false;
+ this.track('render_secure_files_list');
},
async uploadSecureFile() {
this.error = null;
@@ -137,6 +134,7 @@ export default {
try {
await Api.uploadProjectSecureFile(this.projectId, this.uploadFormData(file));
this.getProjectSecureFiles();
+ this.track('upload_secure_file');
} catch (error) {
this.error = true;
this.errorMessage = this.formattedErrorMessage(error);
@@ -157,7 +155,7 @@ export default {
}
return message;
},
- loadFileSelctor() {
+ loadFileSelector() {
this.$refs.fileUpload.click();
},
setDeleteModalData(secureFile) {
@@ -177,91 +175,74 @@ export default {
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
- {{ errorMessage }}
- </gl-alert>
- <div class="row">
- <div class="col-md-12 col-lg-6 gl-display-flex">
- <div class="gl-flex-direction-column gl-flex-wrap">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0">
- {{ $options.i18n.title }}
- </h1>
- </div>
- </div>
+ <div class="ci-secure-files-table">
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
+ {{ errorMessage }}
+ </gl-alert>
+
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ data-qa-selector="ci_secure_files_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ :empty-text="$options.i18n.noFilesMessage"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
- <div class="col-md-12 col-lg-6">
- <div class="gl-display-flex gl-flex-wrap gl-justify-content-end">
- <gl-button v-if="admin" class="gl-mt-3" variant="confirm" @click="loadFileSelctor">
- <span v-if="uploading">
- <gl-loading-icon size="sm" class="gl-my-5" inline />
- {{ $options.i18n.uploadingLabel }}
- </span>
- <span v-else>
- <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
- </span>
- </gl-button>
- <input
- id="file-upload"
- ref="fileUpload"
- type="file"
- class="hidden"
- data-qa-selector="file_upload_field"
- @change="uploadSecureFile"
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <gl-button
+ v-if="admin"
+ v-gl-modal="$options.deleteModalId"
+ v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.i18n.deleteLabel"
+ data-testid="delete-button"
+ @click="setDeleteModalData(item)"
/>
- </div>
- </div>
+ </template>
+ </gl-table>
</div>
- <div class="row">
- <div class="col-md-12 col-lg-12 gl-my-4">
- <span data-testid="info-message">
- {{ $options.i18n.overviewMessage }}
- <gl-link :href="$options.docsLink" target="_blank">{{
- $options.i18n.moreInformation
- }}</gl-link>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-button v-if="admin" variant="confirm" @click="loadFileSelector">
+ <span v-if="uploading">
+ <gl-loading-icon class="gl-my-5" inline />
+ {{ $options.i18n.uploadingLabel }}
+ </span>
+ <span v-else>
+ <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
</span>
- </div>
+ </gl-button>
+ <input
+ id="file-upload"
+ ref="fileUpload"
+ type="file"
+ class="hidden"
+ data-qa-selector="file_upload_field"
+ @change="uploadSecureFile"
+ />
</div>
- <gl-table
- :busy="loading"
- :fields="fields"
- :items="projectSecureFiles"
- tbody-tr-class="js-ci-secure-files-row"
- data-qa-selector="ci_secure_files_table_content"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- table-class="text-secondary"
- show-empty
- sort-icon-left
- no-sort-reset
- >
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
- </template>
-
- <template #cell(name)="{ item }">
- {{ item.name }}
- </template>
-
- <template #cell(created_at)="{ item }">
- <timeago-tooltip :time="item.created_at" />
- </template>
-
- <template #cell(actions)="{ item }">
- <gl-button
- v-if="admin"
- v-gl-modal="$options.deleteModalId"
- v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
- variant="danger"
- icon="remove"
- :aria-label="$options.i18n.deleteLabel"
- @click="setDeleteModalData(item)"
- />
- </template>
- </gl-table>
-
<gl-pagination
v-if="!loading"
v-model="page"
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index 496baf8cb08..e0e3b961c51 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -58,7 +58,7 @@ export default {
},
computed: {
fields() {
- const tdClass = 'gl-py-5!';
+ const tdClass = 'gl-pt-3! gl-pb-4! gl-vertical-align-middle!';
return [
{
key: 'name',
@@ -184,7 +184,7 @@ export default {
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
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span
>{{ $options.AGENT_STATUSES[item.status].name }}
</span>
<gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right">
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 10e71513065..7bc8a1a7304 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -145,8 +145,8 @@ export const AGENT_STATUSES = {
},
inactive: {
name: s__('ClusterAgents|Not connected'),
- icon: 'severity-critical',
- class: 'text-danger-800',
+ icon: 'status-alert',
+ class: 'text-danger-500',
tooltip: {
title: s__('ClusterAgents|Agent might not be connected to GitLab'),
body: sprintf(
@@ -159,7 +159,7 @@ export const AGENT_STATUSES = {
unused: {
name: s__('ClusterAgents|Never connected'),
icon: 'status-neutral',
- class: 'text-secondary-400',
+ class: 'text-secondary-500',
tooltip: {
title: s__('ClusterAgents|Agent never connected to GitLab'),
body: s__('ClusterAgents|Make sure you are using a valid token.'),
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
index e35fbf14de5..f0726ff3e63 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
@@ -91,6 +91,26 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="superscript"
+ content-type="superscript"
+ icon-name="superscript"
+ editor-command="toggleSuperscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Superscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="subscript"
+ content-type="subscript"
+ icon-name="subscript"
+ editor-command="toggleSubscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Subscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="link"
content-type="link"
icon-name="link"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index cba3b627390..5dcff1f6295 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -19,7 +19,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 02de6470cf2..252f69f7a5d 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -58,7 +58,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index ecde593147c..6e4cde5dad6 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -10,9 +10,31 @@ export default {
GlTooltip,
},
inject: ['tiptapEditor'],
+ data() {
+ return {
+ isActive: {},
+ };
+ },
methods: {
- execute(contentType, attrs) {
- this.tiptapEditor.chain().focus().setNode(contentType, attrs).run();
+ insert(contentType, ...args) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .setNode(contentType, ...args)
+ .run();
+
+ this.$emit('execute', { contentType });
+ },
+
+ insertList(listType, listItemType) {
+ if (!this.tiptapEditor.isActive(listType))
+ this.tiptapEditor.chain().focus().toggleList(listType, listItemType).run();
+
+ this.$emit('execute', { contentType: listType });
+ },
+
+ execute(command, contentType) {
+ this.tiptapEditor.chain().focus()[command]().run();
this.$emit('execute', { contentType });
},
@@ -20,15 +42,30 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="plus">
- <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })">
- {{ __('Mermaid diagram') }}
+ <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right>
+ <gl-dropdown-item @click="insert('codeBlock')">
+ {{ __('Code block') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })">
- {{ __('PlantUML diagram') }}
+ <gl-dropdown-item @click="insertList('details', 'detailsContent')">
+ {{ __('Details block') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')">
+ {{ __('Bullet list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')">
+ {{ __('Ordered list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')">
+ {{ __('Task list') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('horizontalRule')">
+ <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')">
{{ __('Horizontal rule') }}
</gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })">
+ {{ __('Mermaid diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
+ {{ __('PlantUML diagram') }}
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 46db806da94..18928acef3c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -62,7 +62,7 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown">
+ <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right>
<gl-dropdown-form class="gl-px-3!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index b652e634b0c..65d71814268 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -51,12 +51,12 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
class="gl-mx-2"
- editor-command="toggleStrike"
- :label="__('Strikethrough')"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
@@ -69,34 +69,11 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- class="gl-mx-2"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code-block"
- content-type="codeBlock"
- icon-name="doc-code"
- class="gl-mx-2"
- editor-command="toggleCodeBlock"
- :label="__('Insert a code block')"
- @execute="trackToolbarControlExecution"
- />
<toolbar-button
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
@execute="trackToolbarControlExecution"
@@ -105,18 +82,23 @@ export default {
data-testid="ordered-list"
content-type="orderedList"
icon-name="list-numbered"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="details"
- content-type="details"
- icon-name="details-block"
- class="gl-mx-2"
- editor-command="toggleDetails"
- :label="__('Add a collapsible section')"
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a task list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-image-button
+ ref="imageButton"
+ data-testid="image"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 61f6a233694..edf8b3d3a0b 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -42,11 +42,14 @@ export default CodeBlockLowlight.extend({
},
parseHTML() {
return [
- ...(this.parent?.() || []),
{
tag: 'div.markdown-code-block',
skip: true,
},
+ {
+ tag: 'pre.js-syntax-highlight',
+ preserveWhitespace: 'full',
+ },
];
},
renderHTML({ HTMLAttributes }) {
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
deleted file mode 100644
index 566ed85acf3..00000000000
--- a/app/assets/javascripts/content_editor/extensions/division.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Node } from '@tiptap/core';
-import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
-
-const getDiv = (element) => {
- if (element.nodeName === 'DIV') return element;
- return element.querySelector('div');
-};
-
-export default Node.create({
- name: 'division',
- content: 'block*',
- group: 'block',
- defining: true,
-
- addAttributes() {
- return {
- className: {
- default: null,
- parseHTML: (element) => getDiv(element).className || null,
- },
- };
- },
-
- parseHTML() {
- return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
- },
-
- renderHTML({ HTMLAttributes }) {
- return ['div', HTMLAttributes, 0];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/html_nodes.js b/app/assets/javascripts/content_editor/extensions/html_nodes.js
new file mode 100644
index 00000000000..23409354814
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/html_nodes.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+
+const tags = ['div', 'pre'];
+
+const createHtmlNodeExtension = (tagName) =>
+ Node.create({
+ name: tagName,
+ content: 'block*',
+ group: 'block',
+ defining: true,
+ addOptions() {
+ return {
+ tagName,
+ };
+ },
+ parseHTML() {
+ return [{ tag: tagName, priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return [tagName, HTMLAttributes, 0];
+ },
+ });
+
+export default tags.map(createHtmlNodeExtension);
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 87118074462..618f17b1c5e 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -9,6 +9,7 @@ import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
+import HTMLNodes from './html_nodes';
import Image from './image';
import Italic from './italic';
import Link from './link';
@@ -51,13 +52,22 @@ export default Extension.create({
TableCell.name,
TableHeader.name,
TableRow.name,
+ ...HTMLNodes.map((htmlNode) => htmlNode.name),
],
attributes: {
+ /**
+ * The reason to add a function that returns an empty
+ * string in these attributes is indicate that these
+ * attributes shouldn’t be rendered in the ProseMirror
+ * view.
+ */
sourceMarkdown: {
default: null,
+ renderHTML: () => '',
},
sourceMapKey: {
default: null,
+ renderHTML: () => '',
},
},
},
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 06757e7a280..867bf0b4d55 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -39,12 +39,12 @@ export class ContentEditor {
this._eventHub.dispose();
}
- deserialize(serializedContent) {
+ deserialize(markdown) {
const { _tiptapEditor: editor, _deserializer: deserializer } = this;
return deserializer.deserialize({
schema: editor.schema,
- content: serializedContent,
+ markdown,
});
}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 15aac3d86e5..c5cfa9a4285 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -16,7 +16,6 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
-import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
@@ -32,6 +31,7 @@ import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -103,7 +103,6 @@ export const createContentEditor = ({
DetailsContent,
Document,
Diagram,
- Division,
Dropcursor,
Emoji,
Figure,
@@ -118,6 +117,7 @@ export const createContentEditor = ({
History,
HorizontalRule,
...HTMLMarks,
+ ...HTMLNodes,
Image,
InlineDiff,
Italic,
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index dcd56e55268..fa46bd9ff81 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -16,8 +16,8 @@ export default ({ render }) => {
* document. The dom property contains the HTML generated from the Markdown Source.
*/
return {
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
+ deserialize: async ({ schema, markdown }) => {
+ const html = await render(markdown);
if (!html) return {};
@@ -25,7 +25,7 @@ export default ({ render }) => {
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
- body.append(document.createComment(content));
+ body.append(document.createComment(markdown));
return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
},
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 2c462cdde91..312ab88de4a 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -21,9 +21,10 @@
import { Mark } from 'prosemirror-model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
-import { toString } from 'hast-util-to-string';
import { isFunction, isString, noop } from 'lodash';
+const NO_ATTRIBUTES = {};
+
/**
* Merges two ProseMirror text nodes if both text nodes
* have the same set of marks.
@@ -51,7 +52,7 @@ function maybeMerge(a, b) {
* Hast node documentation: https://github.com/syntax-tree/hast
*
* @param {HastNode} hastNode A Hast node
- * @param {String} source Markdown source file
+ * @param {String} markdown Markdown source file
*
* @returns It returns an object with the following attributes:
*
@@ -60,13 +61,13 @@ function maybeMerge(a, b) {
* - sourceMarkdown: A node’s original Markdown source extrated
* from the Markdown source file.
*/
-function createSourceMapAttributes(hastNode, source) {
+function createSourceMapAttributes(hastNode, markdown) {
const { position } = hastNode;
return position && position.end
? {
sourceMapKey: `${position.start.offset}:${position.end.offset}`,
- sourceMarkdown: source.substring(position.start.offset, position.end.offset),
+ sourceMarkdown: markdown.substring(position.start.offset, position.end.offset),
}
: {};
}
@@ -82,16 +83,16 @@ function createSourceMapAttributes(hastNode, source) {
* @param {*} proseMirrorNodeSpec ProseMirror node spec object
* @param {HastNode} hastNode A hast node
* @param {Array<HastNode>} hastParents All the ancestors of the hastNode
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains a ProseMirror node’s attributes
*/
-function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) {
+function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) {
const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
return {
- ...createSourceMapAttributes(hastNode, source),
- ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}),
+ ...createSourceMapAttributes(hastNode, markdown),
+ ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
};
}
@@ -136,6 +137,10 @@ class HastToProseMirrorConverterState {
return this.stack[this.stack.length - 1];
}
+ get topNode() {
+ return this.findInStack((item) => item.type === 'node');
+ }
+
/**
* Detects if the node stack is empty
*/
@@ -177,7 +182,7 @@ class HastToProseMirrorConverterState {
*/
addText(schema, text) {
if (!text) return;
- const nodes = this.top.content;
+ const nodes = this.topNode?.content;
const last = nodes[nodes.length - 1];
const node = schema.text(text, this.marks);
const merged = maybeMerge(last, node);
@@ -187,57 +192,92 @@ class HastToProseMirrorConverterState {
} else {
nodes.push(node);
}
-
- this.closeMarks();
}
/**
* Adds a mark to the set of marks stored temporarily
- * until addText is called.
- * @param {*} markType
- * @param {*} attrs
+ * until an inline node is created.
+ * @param {https://prosemirror.net/docs/ref/#model.MarkType} schemaType Mark schema type
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode AST node that the mark is based on
+ * @param {Object} attrs Mark attributes
+ * @param {Object} factorySpec Specifications on how th mark should be created
*/
- openMark(markType, attrs) {
- this.marks = markType.create(attrs).addToSet(this.marks);
+ openMark(schemaType, hastNode, attrs, factorySpec) {
+ const mark = schemaType.create(attrs);
+ this.stack.push({
+ type: 'mark',
+ mark,
+ attrs,
+ hastNode,
+ factorySpec,
+ });
+
+ this.marks = mark.addToSet(this.marks);
}
/**
- * Empties the temporary Mark set.
+ * Removes a mark from the list of active marks that
+ * are applied to inline nodes.
*/
- closeMarks() {
- this.marks = Mark.none;
+ closeMark() {
+ const { mark } = this.stack.pop();
+
+ this.marks = mark.removeFromSet(this.marks);
}
/**
* Adds a node to the stack data structure.
*
- * @param {Schema.NodeType} type ProseMirror Schema for the node
- * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created
+ * @param {https://prosemirror.net/docs/ref/#model.NodeType} schemaType ProseMirror Schema for the node
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode Hast node from which the ProseMirror node will be created
* @param {*} attrs Node’s attributes
* @param {*} factorySpec The factory spec used to create the node factory
*/
- openNode(type, hastNode, attrs, factorySpec) {
- this.stack.push({ type, attrs, content: [], hastNode, factorySpec });
+ openNode(schemaType, hastNode, attrs, factorySpec) {
+ this.stack.push({
+ type: 'node',
+ schemaType,
+ attrs,
+ content: [],
+ hastNode,
+ factorySpec,
+ });
}
/**
* Removes the top ProseMirror node from the
* conversion stack and adds the node to the
* previous element.
- * @returns
*/
closeNode() {
- const { type, attrs, content } = this.stack.pop();
- const node = type.createAndFill(attrs, content);
-
- if (!node) return null;
-
- if (this.marks.length) {
- this.marks = Mark.none;
+ const { schemaType, attrs, content, factorySpec } = this.stack.pop();
+ const node =
+ factorySpec.type === 'inline' && this.marks.length
+ ? schemaType.createAndFill(attrs, content, this.marks)
+ : schemaType.createAndFill(attrs, content);
+
+ if (!node) {
+ /*
+ When the node returned by `createAndFill` is null is because the `content` passed as a parameter
+ doesn’t conform with the document schema. We are handling the most likely scenario here that happens
+ when a paragraph is inside another paragraph.
+
+ This scenario happens when the converter encounters a mark wrapping one or more paragraphs.
+ In this case, the converter will wrap the mark in a paragraph as well because ProseMirror does
+ not allow marks wrapping block nodes or being direct children of certain nodes like the root nodes
+ or list items.
+ */
+ if (
+ schemaType.name === 'paragraph' &&
+ content.some((child) => child.type.name === 'paragraph')
+ ) {
+ this.topNode.content.push(...content);
+ }
+ return null;
}
if (!this.empty) {
- this.top.content.push(node);
+ this.topNode.content.push(node);
}
return node;
@@ -245,9 +285,27 @@ class HastToProseMirrorConverterState {
closeUntil(hastNode) {
while (hastNode !== this.top?.hastNode) {
- this.closeNode();
+ if (this.top.type === 'node') {
+ this.closeNode();
+ } else {
+ this.closeMark();
+ }
}
}
+
+ buildDoc() {
+ let doc;
+
+ do {
+ if (this.top.type === 'node') {
+ doc = this.closeNode();
+ } else {
+ this.closeMark();
+ }
+ } while (!this.empty);
+
+ return doc;
+ }
}
/**
@@ -260,20 +318,21 @@ class HastToProseMirrorConverterState {
* @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the
* ProseMirror nodes and marks.
* @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications.
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains ProseMirror node factories
*/
-const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => {
+const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => {
const factories = {
root: {
selector: 'root',
wrapInParagraph: true,
- handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}),
+ handle: (state, hastNode) =>
+ state.openNode(schema.topNodeType, hastNode, NO_ATTRIBUTES, factories.root),
},
text: {
selector: 'text',
- handle: (state, hastNode) => {
+ handle: (state, hastNode, parent) => {
const found = state.findInStack((node) => isFunction(node.factorySpec.processText));
const { value: text } = hastNode;
@@ -281,17 +340,14 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
return;
}
+ state.closeUntil(parent);
state.addText(schema, found ? found.factorySpec.processText(text) : text);
},
},
};
for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) {
const factory = {
- selector: factorySpec.selector,
- skipChildren: factorySpec.skipChildren,
- processText: factorySpec.processText,
- parent: factorySpec.parent,
- wrapInParagraph: factorySpec.wrapInParagraph,
+ ...factorySpec,
};
if (factorySpec.type === 'block') {
@@ -299,48 +355,22 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
-
- /**
- * If a getContent function is provided, we immediately close
- * the node to delegate content processing to this function.
- * */
- if (isFunction(factorySpec.getContent)) {
- state.addText(
- schema,
- factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }),
- );
- state.closeNode();
- }
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'inline') {
+ } else if (factory.type === 'inline') {
const nodeType = schema.nodeType(proseMirrorName);
factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
- } else if (factorySpec.type === 'mark') {
+ } else if (factory.type === 'mark') {
const markType = schema.marks[proseMirrorName];
factory.handle = (state, hastNode, parent) => {
- state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source));
-
- if (factorySpec.inlineContent) {
- state.addText(schema, hastNode.value);
- }
+ state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'ignore') {
+ } else if (factory.type === 'ignore') {
factory.handle = noop;
} else {
throw new RangeError(
@@ -371,7 +401,7 @@ const findParent = (ancestors, parent) => {
return ancestors[ancestors.length - 1];
};
-const calcTextNodePosition = (textNode) => {
+const resolveNodePosition = (textNode) => {
const { position, value, type } = textNode;
if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) {
@@ -414,11 +444,14 @@ const wrapInlineElements = (nodes, wrappableTags) =>
nodes.reduce((children, child) => {
const previous = children[children.length - 1];
- if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) {
+ if (
+ child.type === 'comment' ||
+ (child.type !== 'text' && !wrappableTags.includes(child.tagName))
+ ) {
return [...children, child];
}
- const wrapperExists = previous?.properties.wrapper;
+ const wrapperExists = previous?.properties?.wrapper;
if (wrapperExists) {
const wrapper = previous;
@@ -432,7 +465,7 @@ const wrapInlineElements = (nodes, wrappableTags) =>
const wrapper = {
type: 'element',
tagName: 'p',
- position: calcTextNodePosition(child),
+ position: resolveNodePosition(child),
children: [child],
properties: { wrapper: true },
};
@@ -528,19 +561,6 @@ const wrapInlineElements = (nodes, wrappableTags) =>
* it allows applying a processing function to that text. This is useful when
* you can transform the text node, i.e trim(), substring(), etc.
*
- * **skipChildren**
- *
- * Skips a hast node’s children while traversing the tree.
- *
- * **getContent**
- *
- * Allows to pass a custom function that returns the content of a block node. The
- * Content is limited to a single text node therefore the function should return
- * a String value.
- *
- * Use this property along skipChildren to provide custom processing of child nodes
- * for a block node.
- *
* **parent**
*
* Specifies what is the node’s parent. This is useful when the node’s parent is not
@@ -561,20 +581,16 @@ export const createProseMirrorDocFromMdastTree = ({
factorySpecs,
wrappableTags,
tree,
- source,
+ markdown,
}) => {
- const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source);
+ const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown);
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories);
if (!factory) {
- throw new Error(
- `Hast node of type "${
- hastNode.tagName || hastNode.type
- }" not supported by this converter. Please, provide an specification.`,
- );
+ return SKIP;
}
const parent = findParent(ancestors, factory.parent);
@@ -595,14 +611,8 @@ export const createProseMirrorDocFromMdastTree = ({
factory.handle(state, hastNode, parent);
- return factory.skipChildren === true ? SKIP : true;
+ return true;
});
- let doc;
-
- do {
- doc = state.closeNode();
- } while (!state.empty);
-
- return doc;
+ return state.buildDoc();
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 2d33a16f1a5..c1c7af6b1af 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -12,7 +12,6 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
-import Division from '../extensions/division';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
@@ -24,6 +23,7 @@ import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -123,16 +123,6 @@ const defaultSerializerConfig = {
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Diagram.name]: renderCodeBlock,
- [Division.name]: (state, node) => {
- if (node.attrs.className?.includes('js-markdown-code')) {
- state.renderInline(node);
- } else {
- const newNode = node;
- delete newNode.attrs.className;
-
- renderHTMLNode('div')(state, newNode);
- }
- },
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -206,6 +196,12 @@ const defaultSerializerConfig = {
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
+ ...HTMLNodes.reduce((serializers, htmlNode) => {
+ return {
+ ...serializers,
+ [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node),
+ };
+ }, {}),
},
};
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index da10c684b0b..8e2c066e011 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,11 +1,10 @@
-import { isString } from 'lodash';
import { render } from '~/lib/gfm';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
const isTaskItem = (hastNode) => {
- const { className } = hastNode.properties;
+ const className = hastNode.properties?.className;
return (
hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item')
@@ -23,16 +22,16 @@ const factorySpecs = {
listItem: {
type: 'block',
wrapInParagraph: true,
- selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties?.className,
processText: (text) => text.trimRight(),
},
orderedList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties?.className,
},
bulletList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties?.className,
},
heading: {
type: 'block',
@@ -45,15 +44,8 @@ const factorySpecs = {
},
codeBlock: {
type: 'block',
- skipChildren: true,
- selector: 'pre',
- getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''),
- getAttrs: (hastNode) => {
- const languageClass = hastNode.children[0]?.properties.className?.[0];
- const language = isString(languageClass) ? languageClass.replace('language-', '') : null;
-
- return { language };
- },
+ selector: 'codeblock',
+ getAttrs: (hastNode) => ({ ...hastNode.properties }),
},
horizontalRule: {
type: 'block',
@@ -62,7 +54,7 @@ const factorySpecs = {
taskList: {
type: 'block',
selector: (hastNode) => {
- const { className } = hastNode.properties;
+ const className = hastNode.properties?.className;
return (
['ul', 'ol'].includes(hastNode.tagName) &&
@@ -88,6 +80,11 @@ const factorySpecs = {
selector: (hastNode, ancestors) =>
hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]),
},
+ div: {
+ type: 'block',
+ selector: 'div',
+ wrapInParagraph: true,
+ },
table: {
type: 'block',
selector: 'table',
@@ -118,6 +115,11 @@ const factorySpecs = {
selector: 'footnotedefinition',
getAttrs: (hastNode) => hastNode.properties,
},
+ pre: {
+ type: 'block',
+ selector: 'pre',
+ wrapInParagraph: true,
+ },
image: {
type: 'inline',
selector: 'img',
@@ -160,11 +162,19 @@ const factorySpecs = {
type: 'mark',
selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName),
},
+ /* TODO
+ * Implement proper editing support for HTML comments in the Content Editor
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/342173
+ */
+ comment: {
+ type: 'ignore',
+ selector: (hastNode) => hastNode.type === 'comment',
+ },
};
export default () => {
return {
- deserialize: async ({ schema, content: markdown }) => {
+ deserialize: async ({ schema, markdown }) => {
const document = await render({
markdown,
renderer: (tree) =>
@@ -173,8 +183,9 @@ export default () => {
factorySpecs,
tree,
wrappableTags,
- source: markdown,
+ markdown,
}),
+ skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'],
});
return { document };
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 88f5192af77..7d5e718b41c 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -5,6 +5,8 @@ const defaultAttrs = {
th: { colspan: 1, rowspan: 1, colwidth: null },
};
+const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey'];
+
const ignoreAttrs = {
dd: ['isTerm'],
dt: ['isTerm'],
@@ -101,13 +103,17 @@ function htmlEncode(str = '') {
.replace(/"/g, '&#34;');
}
+const shouldIgnoreAttr = (tagName, attrKey, attrValue) =>
+ ignoreAttrs[tagName]?.includes(attrKey) ||
+ defaultIgnoreAttrs.includes(attrKey) ||
+ defaultAttrs[tagName]?.[attrKey] === attrValue;
+
export function openTag(tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
- if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value)
- return '';
+ if (shouldIgnoreAttr(tagName, key, value)) return '';
return ` ${key}="${htmlEncode(value?.toString())}"`;
})
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 8a7d3430063..d811bb3b0bf 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -115,10 +115,20 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
<div class="table-mobile-content" data-qa-selector="key_container">
<strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong>
- <div class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
- {{ __('MD5') }}:{{ deployKey.fingerprint }}
- </div>
- <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div>
+ <dl>
+ <dt>{{ __('SHA256') }}</dt>
+ <dd class="fingerprint" data-qa-selector="key_sha256_fingerprint_content">
+ {{ deployKey.fingerprint_sha256 }}
+ </dd>
+ <template v-if="deployKey.fingerprint">
+ <dt>
+ {{ __('MD5') }}
+ </dt>
+ <dd class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
+ {{ deployKey.fingerprint }}
+ </dd>
+ </template>
+ </dl>
</div>
</div>
<div class="table-section section-30 section-wrap">
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 73d872cf962..2ac62b9b927 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-restricted-properties, camelcase,
+/* eslint-disable camelcase,
no-unused-expressions, default-case,
consistent-return, no-param-reassign,
no-shadow, no-useless-escape,
@@ -10,7 +10,7 @@ class-methods-use-this */
deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
*/
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { escape, uniqueId } from 'lodash';
@@ -357,7 +357,7 @@ export default class Notes {
if (shouldReset == null) {
shouldReset = true;
}
- const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ const nthInterval = this.basePollingInterval * 2 ** (this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
@@ -1233,10 +1233,10 @@ export default class Notes {
new Vue({
el,
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
},
render(createElement) {
- return createElement('gl-skeleton-loading');
+ return createElement('gl-skeleton-loader');
},
});
}
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 8a6dd17a25b..24cc93f5eaf 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -1,6 +1,6 @@
<script>
-import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
-import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
+import { GlAccordion, GlAccordionItem, GlSkeletonLoader } from '@gitlab/ui';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import Participants from '~/sidebar/components/participants/participants.vue';
@@ -17,9 +17,8 @@ export default {
DesignDiscussion,
DesignNoteSignedOut,
Participants,
- GlCollapse,
- GlButton,
- GlPopover,
+ GlAccordion,
+ GlAccordionItem,
GlSkeletonLoader,
DesignTodoButton,
},
@@ -58,7 +57,7 @@ export default {
},
data() {
return {
- isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
+ isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -79,18 +78,22 @@ export default {
resolvedDiscussions() {
return this.discussions.filter((discussion) => discussion.resolved);
},
+ hasResolvedDiscussions() {
+ return this.resolvedDiscussions.length > 0;
+ },
+ resolvedDiscussionsTitle() {
+ return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`;
+ },
unresolvedDiscussions() {
return this.discussions.filter((discussion) => !discussion.resolved);
},
- resolvedCommentsToggleIcon() {
- return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
- },
},
watch: {
- isResolvedCommentsPopoverHidden(newVal) {
- if (!newVal) {
- this.$refs.resolvedComments.scrollIntoView();
- }
+ resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
+ this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
+ },
+ isResolvedDiscussionsExpanded() {
+ this.$emit('toggleResolvedComments');
},
},
mounted() {
@@ -100,8 +103,6 @@ export default {
},
methods: {
handleSidebarClick() {
- this.isResolvedCommentsPopoverHidden = true;
- setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
@@ -121,8 +122,9 @@ export default {
this.discussionWithOpenForm = id;
},
},
- resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
- cookieKey: 'hide_design_resolved_comments_popover',
+ i18n: {
+ resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
+ },
};
</script>
@@ -181,40 +183,12 @@ export default {
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@open-form="updateDiscussionWithOpenForm"
/>
- <template v-if="resolvedDiscussions.length > 0">
- <gl-button
- id="resolved-comments"
- ref="resolvedComments"
- data-testid="resolved-comments"
- :icon="resolvedCommentsToggleIcon"
- variant="link"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
- @click="$emit('toggleResolvedComments')"
- >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
- </gl-button>
- <gl-popover
- v-if="!isResolvedCommentsPopoverHidden"
- :show="!isResolvedCommentsPopoverHidden"
- target="resolved-comments"
- container="popovercontainer"
- placement="top"
- :title="s__('DesignManagement|Resolved Comments')"
+ <gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
+ <gl-accordion-item
+ v-model="isResolvedDiscussionsExpanded"
+ :title="resolvedDiscussionsTitle"
+ header-class="gl-mb-5!"
>
- <p>
- {{
- s__(
- 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
- )
- }}
- </p>
- <a
- href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
- rel="noopener noreferrer"
- target="_blank"
- >{{ s__('DesignManagement|Learn more about resolving comments') }}</a
- >
- </gl-popover>
- <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
@@ -232,8 +206,8 @@ export default {
@open-form="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
- </gl-collapse>
- </template>
+ </gl-accordion-item>
+ </gl-accordion>
<slot name="reply-form"></slot>
</template>
</div>
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index 92928ca429f..afe621ac3c5 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index f81d4f6662f..51983b19677 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -4,16 +4,15 @@ import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
-import createFlash, { FLASH_TYPES } from '~/flash';
import { getFilename, validateImageName } from '~/lib/utils/file_upload';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '../components/delete_button.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import Design from '../components/list/item.vue';
import UploadButton from '../components/upload/button.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
-import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+import { MAXIMUM_FILE_UPLOAD_LIMIT, VALID_DESIGN_FILE_MIMETYPE } from '../constants';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import allDesignsMixin from '../mixins/all_designs';
@@ -35,11 +34,10 @@ import {
UPLOAD_DESIGN_INVALID_FILETYPE_ERROR,
designUploadSkippedWarning,
designDeletionError,
+ MAXIMUM_FILE_UPLOAD_LIMIT_REACHED,
} from '../utils/error_messages';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
-const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
-
export default {
components: {
GlLoadingIcon,
@@ -87,6 +85,7 @@ export default {
isDraggingDesign: false,
reorderedDesigns: null,
isReorderingInProgress: false,
+ uploadError: null,
};
},
computed: {
@@ -159,16 +158,7 @@ export default {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
- createFlash({
- message: sprintf(
- s__(
- 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
- ),
- {
- upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
- },
- ),
- });
+ this.uploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
return false;
}
@@ -206,7 +196,7 @@ export default {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
- createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING });
+ this.uploadError = skippedWarningMessage;
}
// if this upload resulted in a new version being created, redirect user to the latest version
@@ -229,7 +219,7 @@ export default {
},
onUploadDesignError() {
this.resetFilesToBeSaved();
- createFlash({ message: UPLOAD_DESIGN_ERROR });
+ this.uploadError = UPLOAD_DESIGN_ERROR;
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
@@ -260,21 +250,21 @@ export default {
},
onDesignDeleteError() {
const errorMessage = designDeletionError(this.selectedDesigns.length);
- createFlash({ message: errorMessage });
+ this.uploadError = errorMessage;
},
onDesignDropzoneError() {
- createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR });
+ this.uploadError = UPLOAD_DESIGN_INVALID_FILETYPE_ERROR;
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
- createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE;
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
- createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE;
return;
}
@@ -329,7 +319,7 @@ export default {
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
- createFlash({ message: MOVE_DESIGN_ERROR });
+ this.uploadError = MOVE_DESIGN_ERROR;
})
.finally(() => {
this.isReorderingInProgress = false;
@@ -338,6 +328,9 @@ export default {
onDesignMove(designs) {
this.reorderedDesigns = designs;
},
+ unsetUpdateError() {
+ this.uploadError = null;
+ },
},
dragOptions: {
animation: 200,
@@ -356,6 +349,15 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
+ <gl-alert
+ v-if="uploadError"
+ variant="danger"
+ class="gl-mb-3"
+ data-testid="design-update-alert"
+ @dismiss="unsetUpdateError"
+ >
+ {{ uploadError }}
+ </gl-alert>
<header
v-if="showToolbar"
class="gl-display-flex gl-my-0 gl-text-gray-900"
@@ -371,6 +373,7 @@ export default {
<div
v-show="hasDesigns"
class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
+ data-testid="design-selector-toolbar"
>
<gl-button
v-if="isLatestVersion"
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 981b50329b2..42f752efc9e 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -1,4 +1,5 @@
import { __, s__, n__, sprintf } from '~/locale';
+import { MAXIMUM_FILE_UPLOAD_LIMIT } from '../constants';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
@@ -27,11 +28,11 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
- 'You can only upload one design when dropping onto an existing design.',
+ 'Your update failed. You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
- 'You must upload a file with the same file name when dropping onto an existing design.',
+ 'Your update failed. You must upload a file with the same file name when dropping onto an existing design.',
);
export const MOVE_DESIGN_ERROR = __(
@@ -122,3 +123,12 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
return someDesignsSkippedMessage(skippedFiles);
};
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+);
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 9f3fb715150..8388458b11c 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -101,6 +101,7 @@ export default class Diff {
const clickTarget = $('.js-file-title, .click-to-expand', diffFile);
diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => {
this.highlightSelectedLine();
+ this.prepareRenderedDiff();
if (cb) cb();
});
} else if (cb) {
@@ -156,20 +157,22 @@ export default class Diff {
}
prepareRenderedDiff() {
- const $elements = $('[data-diff-toggle-entity]');
-
- if ($elements.length === 0) return;
-
+ const allElements = this.elementsForRenderedDiff();
const diff = this;
- const elements = $elements.toArray().map(this.formatElementToObject).reduce(merge);
+ for (const [fileHash, fileElements] of Object.entries(allElements)) {
+ // eslint-disable no-param-reassign
+ fileElements.rawButton.onclick = () => {
+ diff.showRawViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]);
+ };
- Object.values(elements).forEach((e) => {
- e.toShowBtn.onclick = () => diff.showOneHideAnother('rendered', e); // eslint-disable-line no-param-reassign
- e.toHideBtn.onclick = () => diff.showOneHideAnother('raw', e); // eslint-disable-line no-param-reassign
+ fileElements.renderedButton.onclick = () => {
+ diff.showRenderedViewer(fileHash, diff.elementsForRenderedDiff()[fileHash]);
+ };
+ // eslint-enable no-param-reassign
- diff.showOneHideAnother('rendered', e);
- });
+ diff.showRenderedViewer(fileHash, fileElements);
+ }
}
formatElementToObject = (element) => {
@@ -179,18 +182,33 @@ export default class Diff {
return { [key]: { [name]: element } };
};
- showOneHideAnother = (mode, elements) => {
- let { toShowBtn, toHideBtn, toShow, toHide } = elements;
+ elementsForRenderedDiff = () => {
+ const $elements = $('[data-diff-toggle-entity]');
+
+ if ($elements.length === 0) return {};
- if (mode === 'raw') {
- [toShowBtn, toHideBtn] = [toHideBtn, toShowBtn];
- [toShow, toHide] = [toHide, toShow];
- }
+ const diff = this;
+
+ return $elements.toArray().map(diff.formatElementToObject).reduce(merge);
+ };
+
+ showRawViewer = (fileHash, elements) => {
+ if (elements === undefined) return;
+
+ elements.rawButton.classList.add('selected');
+ elements.renderedButton.classList.remove('selected');
+
+ elements.renderedViewer.classList.add('hidden');
+ elements.rawViewer.classList.remove('hidden');
+ };
+
+ showRenderedViewer = (fileHash, elements) => {
+ if (elements === undefined) return;
- toShowBtn.classList.add('selected');
- toHideBtn.classList.remove('selected');
+ elements.rawButton.classList.remove('selected');
+ elements.rawViewer.classList.add('hidden');
- toHide.classList.add('hidden');
- toShow.classList.remove('hidden');
+ elements.renderedButton.classList.add('selected');
+ elements.renderedViewer.classList.remove('hidden');
};
}
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 54b648e8d03..ad163a2a615 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -134,7 +134,9 @@ export default {
class="avatar-cell d-none d-sm-block"
/>
</div>
- <div class="commit-detail flex-list">
+ <div
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ >
<div class="commit-content" data-qa-selector="commit_content">
<a
v-safe-html:[$options.safeHtmlConfig]="commit.title_html"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
new file mode 100644
index 00000000000..f339b108a11
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
+
+export default {
+ components: { GlButton, GlIcon },
+ props: {
+ line: {
+ type: Number,
+ required: true,
+ },
+ codeQuality: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="diff-codequality" class="gl-relative">
+ <ul
+ class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10"
+ >
+ <li
+ v-for="finding in codeQuality"
+ :key="finding.description"
+ class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
+ >
+ <gl-icon
+ :size="12"
+ :name="severityIcon(finding.severity)"
+ :class="severityClass(finding.severity)"
+ class="codequality-severity-icon"
+ />
+ {{ finding.description }}
+ </li>
+ </ul>
+ <gl-button
+ data-testid="diff-codequality-close"
+ category="tertiary"
+ size="small"
+ icon="close"
+ class="gl-absolute gl-right-2 gl-top-2"
+ @click="$emit('hideCodeQualityFindings', line)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index bfe35e9346d..70071a3ff53 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -80,7 +80,7 @@ export default {
return this.getUserData;
},
mappedLines() {
- // TODO: Do this data generation when we recieve a response to save a computed property being created
+ // TODO: Do this data generation when we receive a response to save a computed property being created
return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
},
},
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index ebc68bafb9a..467a0f8d2db 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -206,6 +206,7 @@ export default {
);
},
updateStartLine(line) {
+ this.commentLineStart = line;
this.lines.start = line;
},
},
@@ -216,7 +217,6 @@ export default {
<div class="content discussion-form discussion-form-container discussion-notes">
<div class="gl-mb-3 gl-text-gray-500 gl-pb-3">
<multiline-comment-form
- v-model="commentLineStart"
:line="line"
:line-range="lines"
:comment-line-options="commentLineOptions"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 1b07b00d725..63c5aedd7ce 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -274,6 +274,9 @@ export default {
v-if="$options.showCodequalityLeft(props)"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
+ @showCodeQualityFindings="
+ listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line)
+ "
/>
</div>
<div
@@ -395,6 +398,9 @@ export default {
:codequality="props.line.right.codequality"
:file-path="props.filePath"
data-testid="codeQualityIcon"
+ @showCodeQualityFindings="
+ listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line)
+ "
/>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index d740d5adcb6..ad406947561 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -2,12 +2,14 @@
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
+import DiffCodeQuality from './diff_code_quality.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
import { isHighlighted } from './diff_row_utils';
@@ -17,12 +19,17 @@ export default {
DiffExpansionCell,
DiffRow,
DiffCommentCell,
+ DiffCodeQuality,
DraftNote,
},
directives: {
SafeHtml,
},
- mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [
+ draftCommentsMixin,
+ IdState({ idProp: (vm) => vm.diffFile.file_hash }),
+ glFeatureFlagsMixin(),
+ ],
props: {
diffFile: {
type: Object,
@@ -43,6 +50,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ codeQualityExpandedLines: [],
+ };
+ },
idState() {
return {
dragStart: null,
@@ -84,6 +96,23 @@ export default {
}
this.idState.dragStart = line;
},
+ parseCodeQuality(line) {
+ return (line.left ?? line.right)?.codequality;
+ },
+
+ hideCodeQualityFindings(line) {
+ const index = this.codeQualityExpandedLines.indexOf(line);
+ if (index > -1) {
+ this.codeQualityExpandedLines.splice(index, 1);
+ }
+ },
+ toggleCodeQualityFindings(line) {
+ if (!this.codeQualityExpandedLines.includes(line)) {
+ this.codeQualityExpandedLines.push(line);
+ } else {
+ this.hideCodeQualityFindings(line);
+ }
+ },
onDragOver(line) {
if (line.chunk !== this.idState.dragStart.chunk) return;
@@ -125,15 +154,16 @@ export default {
},
handleParallelLineMouseDown(e) {
const line = e.target.closest('.diff-td');
- const table = line.closest('.diff-table');
-
- table.classList.remove('left-side-selected', 'right-side-selected');
- const [lineClass] = ['left-side', 'right-side'].filter((name) =>
- line.classList.contains(name),
- );
+ if (line) {
+ const table = line.closest('.diff-table');
+ table.classList.remove('left-side-selected', 'right-side-selected');
+ const [lineClass] = ['left-side', 'right-side'].filter((name) =>
+ line.classList.contains(name),
+ );
- if (lineClass) {
- table.classList.add(`${lineClass}-selected`);
+ if (lineClass) {
+ table.classList.add(`${lineClass}-selected`);
+ }
}
},
getCountBetweenIndex(index) {
@@ -148,6 +178,9 @@ export default {
Number(this.diffLines[index - 1].left.new_line)
);
},
+ getCodeQualityLine(line) {
+ return this.parseCodeQuality(line)?.[0]?.line;
+ },
},
userColorScheme: window.gon.user_color_scheme,
};
@@ -190,6 +223,7 @@ export default {
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow"
+ @toggleCodeQualityFindings="toggleCodeQualityFindings"
@toggleLineDiscussions="
({ lineCode, expanded }) =>
toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
@@ -198,6 +232,17 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
+
+ <diff-code-quality
+ v-if="
+ glFeatures.refactorCodeQualityInlineFindings &&
+ codeQualityExpandedLines.includes(getCodeQualityLine(line))
+ "
+ :key="line.line_code"
+ :line="getCodeQualityLine(line)"
+ :code-quality="parseCodeQuality(line)"
+ @hideCodeQualityFindings="hideCodeQualityFindings"
+ />
<div
v-if="line.renderCommentRow"
:key="`dcr-${line.line_code || index}`"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 85e4199d1c1..ffbea854001 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import micromatch from 'micromatch';
import { s__, sprintf } from '~/locale';
import FileTree from '~/vue_shared/components/file_tree.vue';
import DiffFileRow from './diff_file_row.vue';
@@ -28,14 +29,24 @@ export default {
...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']),
...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
- const search = this.search.toLowerCase().trim();
+ let search = this.search.toLowerCase().trim();
if (search === '') {
return this.renderTreeList ? this.tree : this.allBlobs;
}
+ const searchSplit = search.split(',').filter((t) => t);
+
+ if (searchSplit.length > 1) {
+ search = `(${searchSplit.map((s) => s.replace(/(^ +| +$)/g, '')).join('|')})`;
+ } else {
+ [search] = searchSplit;
+ }
+
return this.allBlobs.reduce((acc, folder) => {
- const tree = folder.tree.filter((f) => f.path.toLowerCase().indexOf(search) >= 0);
+ const tree = folder.tree.filter((f) =>
+ micromatch.contains(f.path, search, { nocase: true }),
+ );
if (tree.length) {
return acc.concat({
@@ -54,7 +65,7 @@ export default {
this.search = '';
},
},
- searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), {
+ searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)'), {
modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
}),
DiffFileRow,
@@ -74,6 +85,7 @@ export default {
type="search"
name="diff-tree-search"
class="form-control"
+ data-testid="diff-tree-search"
/>
<button
v-show="search"
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index d5cd4af4d06..ace507f601a 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -744,6 +744,10 @@ export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => {
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE });
};
+export const setFileCollapsedAutomatically = ({ commit }, { filePath, collapsed }) => {
+ commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_AUTOMATIC_COLLAPSE });
+};
+
export const setSuggestPopoverDismissed = ({ commit, state }) =>
axios
.post(state.dismissEndpoint, {
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index c8015f884b7..e8b96c25965 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -436,6 +436,33 @@
"type": "string"
}
},
+ "pull_policy": {
+ "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#servicepull_policy).",
+ "default": "always",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ }
+ ]
+ },
"command": {
"type": "array",
"description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js
index 95a43c2b2d0..fb5d5414ca4 100644
--- a/app/assets/javascripts/editor/source_editor_instance.js
+++ b/app/assets/javascripts/editor/source_editor_instance.js
@@ -90,6 +90,7 @@ export default class EditorInstance {
this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);
+ // eslint-disable-next-line no-constructor-return
return instProxy;
}
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index f83bfe614dd..427a504e038 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -14,8 +14,6 @@ import {
export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data);
export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
- if (!window.gon?.current_user_id) return;
-
try {
const { data, headers } = await axios.get(joinPaths(gon.relative_url_root || '', state.path), {
params: { per_page: 100, page },
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index fd4885a9dbd..cacd868bed0 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -42,7 +42,7 @@ export default {
modalId: CANARY_UPDATE_MODAL,
actionPrimary: {
text: s__('CanaryIngress|Change ratio'),
- attributes: [{ variant: 'info' }],
+ attributes: [{ variant: 'confirm' }],
},
actionCancel: { text: __('Cancel') },
static: true,
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index ce919f73858..8259574f8e3 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -6,6 +6,7 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql';
import eventHub from '../event_hub';
@@ -84,7 +85,9 @@ export default {
return this.environment.commitUrl;
},
modalActionText() {
- return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback');
+ return this.isLastDeployment
+ ? s__('Environments|Re-deploy environment')
+ : s__('Environments|Rollback environment');
},
primaryProps() {
let attributes = [{ variant: 'danger' }];
@@ -101,6 +104,15 @@ export default {
isLastDeployment() {
return this.environment?.isLastDeployment || this.environment?.lastDeployment?.isLast;
},
+ modalBodyText() {
+ return this.isLastDeployment
+ ? s__(
+ 'Environments|This action will %{docsStart}retry the latest deployment%{docsEnd} with the commit %{commitId}, for this environment. Are you sure you want to continue?',
+ )
+ : s__(
+ 'Environments|This action will %{docsStart}roll back this environment%{docsEnd} to a previously successful deployment for commit %{commitId}. Are you sure you want to continue?',
+ );
+ },
},
methods: {
handleChange(event) {
@@ -125,6 +137,7 @@ export default {
text: __('Cancel'),
attributes: [{ variant: 'danger' }],
},
+ docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
</script>
<template>
@@ -137,33 +150,14 @@ export default {
@ok="onOk"
@change="handleChange"
>
- <gl-sprintf
- v-if="environment.isLastDeployment"
- :message="
- s__(
- 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
- )
- "
- >
- <template #link>
+ <gl-sprintf :message="modalBodyText">
+ <template #commitId>
<gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{
commitShortSha
}}</gl-link>
</template>
- </gl-sprintf>
- <gl-sprintf
- v-else
- :message="
- s__(
- 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?',
- )
- "
- >
- <template #name>{{ environment.name }}</template>
- <template #link>
- <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{
- commitShortSha
- }}</gl-link>
+ <template #docs="{ content }">
+ <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-modal>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 8a379ebdf66..7a2c9a8600e 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -51,11 +51,6 @@ export default {
type: Boolean,
required: true,
},
- logsPath: {
- type: String,
- required: false,
- default: '',
- },
graphql: {
type: Boolean,
required: false,
@@ -186,7 +181,6 @@ export default {
:status="instance.status"
:tooltip-text="instance.tooltip"
:pod-name="podName(instance)"
- :logs-path="logsPath"
:stable="instance.stable"
/>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 895a6cf2ccb..b47086a19da 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -164,7 +164,6 @@ export default {
:deploy-board-data="model.deployBoardData"
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
- :logs-path="model.logs_path"
@changeCanaryWeight="changeCanaryWeight(model, $event)"
/>
</div>
@@ -199,7 +198,6 @@ export default {
:deploy-board-data="child.deployBoardData"
:is-loading="child.isLoadingDeployBoard"
:is-empty="child.isEmptyDeployBoard"
- :logs-path="child.logs_path"
@changeCanaryWeight="changeCanaryWeight(child, $event)"
/>
</div>
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index a67e44b3348..4d70e29a684 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -15,8 +15,6 @@ export default class EnvironmentsStore {
this.state.availableCounter = 0;
this.state.paginationInformation = {};
this.state.reviewAppDetails = {};
-
- return this;
}
/**
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index d29d5aa0671..a07428dafea 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -150,6 +150,12 @@ export default {
paginationRequired() {
return !isEmpty(this.pagination);
},
+ previousPage() {
+ return this.pagination.previous ? this.$options.PREV_PAGE : null;
+ },
+ nextPage() {
+ return this.pagination.next ? this.$options.NEXT_PAGE : null;
+ },
errorTrackingHelpUrl() {
return helpPagePath('operations/error_tracking');
},
@@ -430,8 +436,8 @@ export default {
<gl-pagination
v-show="!loading"
v-if="paginationRequired"
- :prev-page="$options.PREV_PAGE"
- :next-page="$options.NEXT_PAGE"
+ :prev-page="previousPage"
+ :next-page="nextPage"
:value="pageValue"
align="center"
@input="goToPage"
diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue
index cb8ffbddfbd..04190d7bfda 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/default.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue
@@ -4,7 +4,7 @@ export default {
this.$emit('change', { parameters: {} });
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
new file mode 100644
index 00000000000..f17a05999b0
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { captureException } from '@sentry/browser';
+import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
+import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml';
+import { logError } from '~/lib/logger';
+import { s__ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
+import pagesMarkOnboardingComplete from '../queries/mark_onboarding_complete.graphql';
+
+export const i18n = {
+ loadingMessage: s__('GitLabPages|Updating your Pages configuration...'),
+};
+
+export default {
+ name: 'PagesPipelineWizard',
+ i18n,
+ PagesWizardTemplate,
+ components: {
+ PipelineWizard,
+ GlLoadingIcon,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ redirectToWhenDone: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ methods: {
+ async updateOnboardingState() {
+ try {
+ await this.$apollo.mutate({
+ mutation: pagesMarkOnboardingComplete,
+ variables: {
+ input: { projectPath: this.projectPath },
+ },
+ });
+ } catch (e) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Updating the pages onboarding state failed', e);
+ captureException(e);
+ }
+ },
+ async onDone() {
+ this.loading = true;
+ await this.updateOnboardingState();
+ redirectTo(this.redirectToWhenDone);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-if="loading"
+ class="gl-p-3 gl-rounded-base gl-text-center"
+ data-testid="onboarding-mutation-loading"
+ >
+ <gl-loading-icon />
+ {{ $options.i18n.loadingMessage }}
+ </div>
+ <pipeline-wizard
+ v-else
+ :template="$options.PagesWizardTemplate"
+ :project-path="projectPath"
+ :default-branch="defaultBranch"
+ @done="onDone"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql
new file mode 100644
index 00000000000..abedd54b079
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/queries/mark_onboarding_complete.graphql
@@ -0,0 +1,6 @@
+mutation pagesMarkOnboardingComplete($input: PagesMarkOnboardingCompleteInput!) {
+ pagesMarkOnboardingComplete(input: $input) {
+ onboardingComplete
+ errors
+ }
+}
diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue
deleted file mode 100644
index b3d773e6bee..00000000000
--- a/app/assets/javascripts/google_cloud/components/app.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
-import { __ } from '~/locale';
-
-import Home from './home.vue';
-import IncubationBanner from './incubation_banner.vue';
-import ServiceAccountsForm from './service_accounts_form.vue';
-import GcpRegionsForm from './gcp_regions_form.vue';
-import NoGcpProjects from './errors/no_gcp_projects.vue';
-import GcpError from './errors/gcp_error.vue';
-
-const SCREEN_GCP_ERROR = 'gcp_error';
-const SCREEN_HOME = 'home';
-const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
-const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
-const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form';
-
-export default {
- components: {
- IncubationBanner,
- },
- inheritAttrs: false,
- props: {
- screen: {
- required: true,
- type: String,
- },
- },
- computed: {
- mainComponent() {
- switch (this.screen) {
- case SCREEN_HOME:
- return Home;
- case SCREEN_GCP_ERROR:
- return GcpError;
- case SCREEN_NO_GCP_PROJECTS:
- return NoGcpProjects;
- case SCREEN_SERVICE_ACCOUNTS_FORM:
- return ServiceAccountsForm;
- case SCREEN_GCP_REGIONS_FORM:
- return GcpRegionsForm;
- default:
- throw new Error(__('Unknown screen'));
- }
- },
- },
- methods: {
- feedbackUrl(template) {
- return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <incubation-banner
- :share-feedback-url="feedbackUrl('general_feedback')"
- :report-bug-url="feedbackUrl('report_bug')"
- :feature-request-url="feedbackUrl('feature_request')"
- />
- <component :is="mainComponent" v-bind="$attrs" />
- </div>
-</template>
diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue
deleted file mode 100644
index 90aa0e1ae68..00000000000
--- a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<script>
-import { GlAlert } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: { GlAlert },
- props: {
- error: {
- type: String,
- required: true,
- },
- },
- i18n: {
- title: __('Google Cloud project misconfigured'),
- description: __(
- 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:',
- ),
- },
-};
-</script>
-
-<template>
- <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title">
- {{ $options.i18n.description }}
- <blockquote>
- <code>{{ error }}</code>
- </blockquote>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue
deleted file mode 100644
index da229ac3f0e..00000000000
--- a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: { GlAlert, GlButton },
- i18n: {
- title: __('Google Cloud project required'),
- description: __(
- 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.',
- ),
- createLabel: __('Create Google Cloud project'),
- },
-};
-</script>
-
-<template>
- <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title">
- {{ $options.i18n.description }}
- <template #actions>
- <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank">
- {{ $options.i18n.createLabel }}
- </gl-button>
- </template>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
new file mode 100644
index 00000000000..d6b7c702b54
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
@@ -0,0 +1,85 @@
+<script>
+import { s__ } from '~/locale';
+
+const CONFIGURATION_KEY = 'configuration';
+const DEPLOYMENTS_KEY = 'deployments';
+const DATABASES_KEY = 'databases';
+
+const i18n = {
+ configuration: { title: s__('CloudSeed|Configuration') },
+ deployments: { title: s__('CloudSeed|Deployments') },
+ databases: { title: s__('CloudSeed|Databases') },
+};
+
+export default {
+ props: {
+ active: {
+ type: String,
+ required: true,
+ },
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isConfigurationActive() {
+ return this.active === CONFIGURATION_KEY;
+ },
+ isDeploymentsActive() {
+ return this.active === DEPLOYMENTS_KEY;
+ },
+ isDatabasesActive() {
+ return this.active === DATABASES_KEY;
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div class="tabs gl-tabs">
+ <ul role="tablist" class="nav gl-tabs-nav">
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="configurationLink"
+ role="tab"
+ :href="configurationUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isConfigurationActive }"
+ >
+ {{ $options.i18n.configuration.title }}</a
+ >
+ </li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="deploymentsLink"
+ role="tab"
+ :href="deploymentsUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isDeploymentsActive }"
+ >
+ {{ $options.i18n.deployments.title }}
+ </a>
+ </li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="databasesLink"
+ role="tab"
+ :href="databasesUrl"
+ class="nav-link gl-tab-nav-item"
+ :class="{ 'gl-tab-nav-item-active': isDatabasesActive }"
+ >
+ {{ $options.i18n.databases.title }}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue
deleted file mode 100644
index e41337e2679..00000000000
--- a/app/assets/javascripts/google_cloud/components/home.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
-import DeploymentsServiceTable from './deployments_service_table.vue';
-import RevokeOauth from './revoke_oauth.vue';
-import ServiceAccountsList from './service_accounts_list.vue';
-import GcpRegionsList from './gcp_regions_list.vue';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- DeploymentsServiceTable,
- RevokeOauth,
- ServiceAccountsList,
- GcpRegionsList,
- },
- props: {
- serviceAccounts: {
- type: Array,
- required: true,
- },
- createServiceAccountUrl: {
- type: String,
- required: true,
- },
- configureGcpRegionsUrl: {
- type: String,
- required: true,
- },
- emptyIllustrationUrl: {
- type: String,
- required: true,
- },
- enableCloudRunUrl: {
- type: String,
- required: true,
- },
- enableCloudStorageUrl: {
- type: String,
- required: true,
- },
- gcpRegions: {
- type: Array,
- required: true,
- },
- revokeOauthUrl: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <gl-tabs>
- <gl-tab :title="__('Configuration')">
- <service-accounts-list
- class="gl-mx-4"
- :list="serviceAccounts"
- :create-url="createServiceAccountUrl"
- :empty-illustration-url="emptyIllustrationUrl"
- />
- <hr />
- <gcp-regions-list
- class="gl-mx-4"
- :empty-illustration-url="emptyIllustrationUrl"
- :create-url="configureGcpRegionsUrl"
- :list="gcpRegions"
- />
- <hr v-if="revokeOauthUrl" />
- <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
- </gl-tab>
- <gl-tab :title="__('Deployments')">
- <deployments-service-table
- :cloud-run-url="enableCloudRunUrl"
- :cloud-storage-url="enableCloudStorageUrl"
- />
- </gl-tab>
- <gl-tab :title="__('Services')" disabled />
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
index 652b8c1aecb..128b3dcb1d9 100644
--- a/app/assets/javascripts/google_cloud/components/incubation_banner.vue
+++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue
@@ -1,22 +1,20 @@
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+const FEATURE_REQUEST_KEY = 'feature_request';
+const REPORT_BUG_KEY = 'report_bug';
+const GENERAL_FEEDBACK_KEY = 'general_feedback';
+
export default {
components: { GlAlert, GlLink, GlSprintf },
- props: {
- shareFeedbackUrl: {
- required: true,
- type: String,
- },
- reportBugUrl: {
- required: true,
- type: String,
- },
- featureRequestUrl: {
- required: true,
- type: String,
+ methods: {
+ feedbackUrl(template) {
+ return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`;
},
},
+ FEATURE_REQUEST_KEY,
+ REPORT_BUG_KEY,
+ GENERAL_FEEDBACK_KEY,
};
</script>
@@ -31,13 +29,13 @@ export default {
"
>
<template #featureLink="{ content }">
- <gl-link :href="featureRequestUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.FEATURE_REQUEST_KEY)">{{ content }}</gl-link>
</template>
<template #bugLink="{ content }">
- <gl-link :href="reportBugUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.REPORT_BUG_KEY)">{{ content }}</gl-link>
</template>
<template #feedbackLink="{ content }">
- <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link>
+ <gl-link :href="feedbackUrl($options.GENERAL_FEEDBACK_KEY)">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
diff --git a/app/assets/javascripts/google_cloud/configuration/index.js b/app/assets/javascripts/google_cloud/configuration/index.js
new file mode 100644
index 00000000000..580315588d0
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/configuration/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-configuration') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue
new file mode 100644
index 00000000000..ee046eb1988
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/configuration/panel.vue
@@ -0,0 +1,88 @@
+<script>
+import GcpRegionsList from '../gcp_regions/list.vue';
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import RevokeOauth from '../components/revoke_oauth.vue';
+import ServiceAccountsList from '../service_accounts/list.vue';
+
+export default {
+ components: {
+ GcpRegionsList,
+ GoogleCloudMenu,
+ IncubationBanner,
+ RevokeOauth,
+ ServiceAccountsList,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ serviceAccounts: {
+ type: Array,
+ required: true,
+ },
+ createServiceAccountUrl: {
+ type: String,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ configureGcpRegionsUrl: {
+ type: String,
+ required: true,
+ },
+ gcpRegions: {
+ type: Array,
+ required: true,
+ },
+ revokeOauthUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="configuration"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+
+ <service-accounts-list
+ class="gl-mx-4"
+ :list="serviceAccounts"
+ :create-url="createServiceAccountUrl"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
+
+ <hr />
+
+ <gcp-regions-list
+ class="gl-mx-4"
+ :empty-illustration-url="emptyIllustrationUrl"
+ :create-url="configureGcpRegionsUrl"
+ :list="gcpRegions"
+ />
+
+ <hr v-if="revokeOauthUrl" />
+
+ <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue
new file mode 100644
index 00000000000..0ac561b6132
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlButton, GlFormCheckbox, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const i18n = {
+ gcpProjectLabel: s__('CloudSeed|Google Cloud project'),
+ gcpProjectDescription: s__(
+ 'CloudSeed|Database instance is generated within the selected Google Cloud project',
+ ),
+ refsLabel: s__('CloudSeed|Refs'),
+ refsDescription: s__(
+ 'CloudSeed|Generated database instance is linked to the selected branch or tag',
+ ),
+ databaseVersionLabel: s__('CloudSeed|Database version'),
+ tierLabel: s__('CloudSeed|Machine type'),
+ tierDescription: s__('CloudSeed|Determines memory and virtual cores available to your instance'),
+ checkboxLabel: s__(
+ 'CloudSeed|I accept Google Cloud pricing and responsibilities involved with managing database instances',
+ ),
+ cancelLabel: s__('CloudSeed|Cancel'),
+ submitLabel: s__('CloudSeed|Create instance'),
+ all: s__('CloudSeed|All'),
+};
+
+export default {
+ ALL_REFS: '*',
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormSelect,
+ },
+ props: {
+ cancelPath: { required: true, type: String },
+ gcpProjects: { required: true, type: Array },
+ refs: { required: true, type: Array },
+ formTitle: { required: true, type: String },
+ formDescription: { required: true, type: String },
+ databaseVersions: { required: true, type: Array },
+ tiers: { required: true, type: Array },
+ },
+ i18n,
+};
+</script>
+<template>
+ <div>
+ <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
+ <h2 class="gl-font-size-h1">{{ formTitle }}</h2>
+ <p>{{ formDescription }}</p>
+ </header>
+
+ <gl-form-group
+ data-testid="form_group_gcp_project"
+ label-for="gcp_project"
+ :label="$options.i18n.gcpProjectLabel"
+ :description="$options.i18n.gcpProjectDescription"
+ >
+ <gl-form-select id="gcp_project" data-testid="select_gcp_project" name="gcp_project" required>
+ <option
+ v-for="gcpProject in gcpProjects"
+ :key="gcpProject.project_id"
+ :value="gcpProject.project_id"
+ >
+ {{ gcpProject.name }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="form_group_environments"
+ label-for="ref"
+ :label="$options.i18n.refsLabel"
+ :description="$options.i18n.refsDescription"
+ >
+ <gl-form-select id="ref" data-testid="select_environments" name="ref" required>
+ <option :value="$options.ALL_REFS">{{ $options.i18n.all }}</option>
+ <option v-for="ref in refs" :key="ref" :value="ref">
+ {{ ref }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="form_group_tier"
+ label-for="tier"
+ :label="$options.i18n.tierLabel"
+ :description="$options.i18n.tierDescription"
+ >
+ <gl-form-select id="tier" data-testid="select_tier" name="tier" required>
+ <option v-for="tier in tiers" :key="tier.value" :value="tier.value">
+ {{ tier.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="form_group_database_version"
+ label-for="database-version"
+ :label="$options.i18n.databaseVersionLabel"
+ >
+ <gl-form-select
+ id="database-version"
+ data-testid="select_database_version"
+ name="database_version"
+ required
+ >
+ <option
+ v-for="databaseVersion in databaseVersions"
+ :key="databaseVersion.value"
+ :value="databaseVersion.value"
+ >
+ {{ databaseVersion.label }}
+ </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" data-testid="submit-button">
+ {{ $options.i18n.submitLabel }}
+ </gl-button>
+ <gl-button class="gl-ml-1" :href="cancelPath" data-testid="cancel-button">{{
+ $options.i18n.cancelLabel
+ }}</gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue
new file mode 100644
index 00000000000..823895214df
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlEmptyState, GlLink, GlTable } from '@gitlab/ui';
+import { encodeSaferUrl, setUrlParams } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const i18n = {
+ noInstancesTitle: s__('CloudSeed|No instances'),
+ noInstancesDescription: s__('CloudSeed|There are no instances to display.'),
+ title: s__('CloudSeed|Instances'),
+ description: s__('CloudSeed|Database instances associated with this project'),
+};
+
+export default {
+ components: { GlEmptyState, GlLink, GlTable },
+ props: {
+ cloudsqlInstances: {
+ type: Array,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tableData() {
+ return this.cloudsqlInstances.filter((instance) => instance.instance_name);
+ },
+ },
+ methods: {
+ gcpProjectUrl(id) {
+ return setUrlParams({ project: id }, 'https://console.cloud.google.com/sql/instances');
+ },
+ instanceUrl(name, id) {
+ const saferName = encodeSaferUrl(name);
+
+ return setUrlParams(
+ { project: id },
+ `https://console.cloud.google.com/sql/instances/${saferName}/overview`,
+ );
+ },
+ },
+ fields: [
+ { key: 'ref', label: s__('CloudSeed|Environment') },
+ { key: 'gcp_project', label: s__('CloudSeed|Google Cloud Project') },
+ { key: 'instance_name', label: s__('CloudSeed|CloudSQL Instance') },
+ { key: 'version', label: s__('CloudSeed|Version') },
+ ],
+ i18n,
+};
+</script>
+
+<template>
+ <div class="gl-mx-3">
+ <gl-empty-state
+ v-if="tableData.length === 0"
+ :title="$options.i18n.noInstancesTitle"
+ :description="$options.i18n.noInstancesDescription"
+ :svg-path="emptyIllustrationUrl"
+ />
+
+ <div v-else>
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+ <gl-table :fields="$options.fields" :items="tableData">
+ <template #cell(gcp_project)="{ value }">
+ <gl-link :href="gcpProjectUrl(value)">{{ value }}</gl-link>
+ </template>
+ <template #cell(instance_name)="{ item: { instance_name, gcp_project } }">
+ <a :href="instanceUrl(instance_name, gcp_project)">{{ instance_name }}</a>
+ </template>
+ </gl-table>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js
new file mode 100644
index 00000000000..e240a1116e8
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-databases') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue
new file mode 100644
index 00000000000..e2f18c286a5
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/panel.vue
@@ -0,0 +1,38 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+
+export default {
+ components: {
+ IncubationBanner,
+ GoogleCloudMenu,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="databases"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue
new file mode 100644
index 00000000000..80bd6ef28fb
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/service_table.vue
@@ -0,0 +1,221 @@
+<script>
+import { GlAlert, GlButton, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+const KEY_CLOUDSQL_POSTGRES = 'cloudsql-postgres';
+const KEY_CLOUDSQL_MYSQL = 'cloudsql-mysql';
+const KEY_CLOUDSQL_SQLSERVER = 'cloudsql-sqlserver';
+const KEY_ALLOYDB_POSTGRES = 'alloydb-postgres';
+const KEY_MEMORYSTORE_REDIS = 'memorystore-redis';
+const KEY_FIRESTORE = 'firestore';
+
+const i18n = {
+ columnService: s__('CloudSeed|Service'),
+ columnDescription: s__('CloudSeed|Description'),
+ cloudsqlPostgresTitle: s__('CloudSeed|Cloud SQL for Postgres'),
+ cloudsqlPostgresDescription: s__(
+ 'CloudSeed|Fully managed relational database service for PostgreSQL',
+ ),
+ cloudsqlMysqlTitle: s__('CloudSeed|Cloud SQL for MySQL'),
+ cloudsqlMysqlDescription: s__('CloudSeed|Fully managed relational database service for MySQL'),
+ cloudsqlSqlserverTitle: s__('CloudSeed|Cloud SQL for SQL Server'),
+ cloudsqlSqlserverDescription: s__(
+ 'CloudSeed|Fully managed relational database service for SQL Server',
+ ),
+ alloydbPostgresTitle: s__('CloudSeed|AlloyDB for Postgres'),
+ alloydbPostgresDescription: s__(
+ 'CloudSeed|Fully managed PostgreSQL-compatible service for high-demand workloads',
+ ),
+ memorystoreRedisTitle: s__('CloudSeed|Memorystore for Redis'),
+ memorystoreRedisDescription: s__(
+ 'CloudSeed|Scalable, secure, and highly available in-memory service for Redis',
+ ),
+ firestoreTitle: s__('CloudSeed|Cloud Firestore'),
+ firestoreDescription: s__(
+ 'CloudSeed|Flexible, scalable NoSQL cloud database for client- and server-side development',
+ ),
+ createInstance: s__('CloudSeed|Create instance'),
+ createCluster: s__('CloudSeed|Create cluster'),
+ createDatabase: s__('CloudSeed|Create database'),
+ title: s__('CloudSeed|Services'),
+ description: s__('CloudSeed|Available database services through which instances may be created'),
+ pricingAlert: s__(
+ 'CloudSeed|Learn more about pricing for %{cloudsqlPricingStart}Cloud SQL%{cloudsqlPricingEnd}, %{alloydbPricingStart}Alloy DB%{alloydbPricingEnd}, %{memorystorePricingStart}Memorystore%{memorystorePricingEnd} and %{firestorePricingStart}Firestore%{firestorePricingEnd}.',
+ ),
+ secretManagersDescription: s__(
+ 'CloudSeed|Enhance security by storing database variables in secret managers - learn more about %{docLinkStart}secret management with GitLab%{docLinkEnd}',
+ ),
+};
+
+const helpUrlSecrets = helpPagePath('ee/ci/secrets');
+
+export default {
+ components: { GlAlert, GlButton, GlLink, GlSprintf, GlTable },
+ props: {
+ cloudsqlPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlMysqlUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlSqlserverUrl: {
+ type: String,
+ required: true,
+ },
+ alloydbPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ memorystoreRedisUrl: {
+ type: String,
+ required: true,
+ },
+ firestoreUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ actionUrl(key) {
+ switch (key) {
+ case KEY_CLOUDSQL_POSTGRES:
+ return this.cloudsqlPostgresUrl;
+ case KEY_CLOUDSQL_MYSQL:
+ return this.cloudsqlMysqlUrl;
+ case KEY_CLOUDSQL_SQLSERVER:
+ return this.cloudsqlSqlserverUrl;
+ case KEY_ALLOYDB_POSTGRES:
+ return this.alloydbPostgresUrl;
+ case KEY_MEMORYSTORE_REDIS:
+ return this.memorystoreRedisUrl;
+ case KEY_FIRESTORE:
+ return this.firestoreUrl;
+ default:
+ return '#';
+ }
+ },
+ },
+ fields: [
+ { key: 'title', label: i18n.columnService },
+ { key: 'description', label: i18n.columnDescription },
+ { key: 'action', label: '' },
+ ],
+ items: [
+ {
+ title: i18n.cloudsqlPostgresTitle,
+ description: i18n.cloudsqlPostgresDescription,
+ action: {
+ key: KEY_CLOUDSQL_POSTGRES,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-postgres',
+ },
+ },
+ {
+ title: i18n.cloudsqlMysqlTitle,
+ description: i18n.cloudsqlMysqlDescription,
+ action: {
+ disabled: false,
+ key: KEY_CLOUDSQL_MYSQL,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-mysql',
+ },
+ },
+ {
+ title: i18n.cloudsqlSqlserverTitle,
+ description: i18n.cloudsqlSqlserverDescription,
+ action: {
+ disabled: false,
+ key: KEY_CLOUDSQL_SQLSERVER,
+ title: i18n.createInstance,
+ testId: 'button-cloudsql-sqlserver',
+ },
+ },
+ {
+ title: i18n.alloydbPostgresTitle,
+ description: i18n.alloydbPostgresDescription,
+ action: {
+ disabled: true,
+ key: KEY_ALLOYDB_POSTGRES,
+ title: i18n.createCluster,
+ testId: 'button-alloydb-postgres',
+ },
+ },
+ {
+ title: i18n.memorystoreRedisTitle,
+ description: i18n.memorystoreRedisDescription,
+ action: {
+ disabled: true,
+ key: KEY_MEMORYSTORE_REDIS,
+ title: i18n.createInstance,
+ testId: 'button-memorystore-redis',
+ },
+ },
+ {
+ title: i18n.firestoreTitle,
+ description: i18n.firestoreDescription,
+ action: {
+ disabled: true,
+ key: KEY_FIRESTORE,
+ title: i18n.createDatabase,
+ testId: 'button-firestore',
+ },
+ },
+ ],
+ helpUrlSecrets,
+ i18n,
+};
+</script>
+
+<template>
+ <div class="gl-mx-3">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2>
+ <p>{{ $options.i18n.description }}</p>
+
+ <gl-table :fields="$options.fields" :items="$options.items">
+ <template #cell(action)="{ value }">
+ <gl-button
+ block
+ :disabled="value.disabled"
+ :href="actionUrl(value.key)"
+ :data-testid="value.testId"
+ category="secondary"
+ variant="confirm"
+ >
+ {{ value.title }}
+ </gl-button>
+ </template>
+ </gl-table>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.pricingAlert">
+ <template #cloudsqlPricing="{ content }">
+ <gl-link href="https://cloud.google.com/sql/pricing">{{ content }}</gl-link>
+ </template>
+ <template #alloydbPricing="{ content }">
+ <gl-link href="https://cloud.google.com/alloydb/pricing">{{ content }}</gl-link>
+ </template>
+ <template #memorystorePricing="{ content }">
+ <gl-link href="https://cloud.google.com/memorystore/docs/redis/pricing">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #firestorePricing="{ content }">
+ <gl-link href="https://cloud.google.com/firestore/pricing">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert class="gl-mt-5" :dismissible="false" variant="tip">
+ <gl-sprintf :message="$options.i18n.secretManagersDescription">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.helpUrlSecrets">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/deployments/index.js b/app/assets/javascripts/google_cloud/deployments/index.js
new file mode 100644
index 00000000000..fcbb2209c40
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/deployments/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default (containerId = '#js-google-cloud-deployments') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue
new file mode 100644
index 00000000000..89db132ad5e
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/deployments/panel.vue
@@ -0,0 +1,50 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import ServiceTable from './service_table.vue';
+
+export default {
+ components: {
+ ServiceTable,
+ IncubationBanner,
+ GoogleCloudMenu,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ enableCloudRunUrl: {
+ type: String,
+ required: true,
+ },
+ enableCloudStorageUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="deployments"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ />
+
+ <service-table :cloud-run-url="enableCloudRunUrl" :cloud-storage-url="enableCloudStorageUrl" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/deployments/service_table.vue
index 26c9fd14dc6..26c9fd14dc6 100644
--- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue
+++ b/app/assets/javascripts/google_cloud/deployments/service_table.vue
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
index 23011e5a5b0..23011e5a5b0 100644
--- a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue
diff --git a/app/assets/javascripts/google_cloud/gcp_regions/index.js b/app/assets/javascripts/google_cloud/gcp_regions/index.js
new file mode 100644
index 00000000000..da37c612805
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/gcp_regions/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './form.vue';
+
+export default (containerId = '#js-google-cloud-gcp-regions') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
index 5d403d5cd65..5d403d5cd65 100644
--- a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
+++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue
diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js
deleted file mode 100644
index ab9e8227812..00000000000
--- a/app/assets/javascripts/google_cloud/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import App from './components/app.vue';
-
-export default () => {
- const root = '#js-google-cloud';
- const element = document.querySelector(root);
- const { screen, ...attrs } = JSON.parse(element.getAttribute('data'));
- return new Vue({
- el: element,
- render: (createElement) => createElement(App, { props: { screen }, attrs }),
- });
-};
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue
index faec94e735b..faec94e735b 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue
diff --git a/app/assets/javascripts/google_cloud/service_accounts/index.js b/app/assets/javascripts/google_cloud/service_accounts/index.js
new file mode 100644
index 00000000000..5207b44deac
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/service_accounts/index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './form.vue';
+
+export default (containerId = '#js-google-cloud-service-accounts') => {
+ const element = document.querySelector(containerId);
+ const { ...attrs } = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue
index 4b580c594f5..4b580c594f5 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 2969121bf06..c8204f397ff 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -176,6 +176,14 @@ export const trackSaasTrialGetStarted = () => {
});
};
+export const trackTrialAcceptTerms = () => {
+ if (!isSupported()) {
+ return;
+ }
+
+ pushEvent('saasTrialAcceptTerms');
+};
+
export const trackCheckout = (selectedPlan, quantity) => {
if (!isSupported()) {
return;
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 50b40526ee0..45c5cca68cc 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -131,7 +131,9 @@
"VulnerabilityLocationSecretDetection"
],
"WorkItemWidget": [
+ "WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
- "WorkItemWidgetHierarchy"
+ "WorkItemWidgetHierarchy",
+ "WorkItemWidgetWeight"
]
}
diff --git a/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql
new file mode 100644
index 00000000000..93335c93c1d
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/current_user.query.graphql
@@ -0,0 +1,7 @@
+#import "../fragments/user.fragment.graphql"
+
+query currentUser {
+ currentUser {
+ ...User
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
new file mode 100644
index 00000000000..07398867544
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/user.fragment.graphql"
+
+query getUsersByUsernames($usernames: [String!]) {
+ users(usernames: $usernames) {
+ nodes {
+ ...User
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql
new file mode 100644
index 00000000000..9c75df84e78
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_search_all.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/user.fragment.graphql"
+
+query searchAllUsers($search: String!, $first: Int = null) {
+ users(search: $search, first: $first) {
+ nodes {
+ ...User
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2241d57f96f..7345afb8545 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -4,13 +4,24 @@ import {
GlLoadingIcon,
GlBadge,
GlIcon,
+ GlLabel,
+ GlButton,
+ GlPopover,
+ GlLink,
GlTooltipDirective,
GlSafeHtmlDirective,
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+import {
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+ ITEM_TYPE,
+ VISIBILITY_PRIVATE,
+} from '../constants';
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
@@ -28,16 +39,17 @@ export default {
GlBadge,
GlLoadingIcon,
GlIcon,
+ GlLabel,
+ GlButton,
+ GlPopover,
+ GlLink,
UserAccessRoleBadge,
- ComplianceFrameworkLabel: () =>
- import(
- 'ee_component/vue_shared/components/compliance_framework_label/compliance_framework_label.vue'
- ),
itemCaret,
itemTypeIcon,
itemActions,
itemStats,
},
+ inject: ['currentGroupVisibility'],
props: {
parentGroup: {
type: Object,
@@ -58,6 +70,9 @@ export default {
groupDomId() {
return `group-${this.group.id}`;
},
+ itemTestId() {
+ return `group-overview-item-${this.group.id}`;
+ },
rowClass() {
return {
'is-open': this.group.isOpen,
@@ -76,10 +91,10 @@ export default {
return Boolean(this.group.complianceFramework?.name);
},
isGroup() {
- return this.group.type === 'group';
+ return this.group.type === ITEM_TYPE.GROUP;
},
isGroupPendingRemoval() {
- return this.group.type === 'group' && this.group.pendingRemoval;
+ return this.group.type === ITEM_TYPE.GROUP && this.group.pendingRemoval;
},
visibilityIcon() {
return VISIBILITY_TYPE_ICON[this.group.visibility];
@@ -96,6 +111,13 @@ export default {
showActionsMenu() {
return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave);
},
+ shouldShowVisibilityWarning() {
+ return (
+ this.action === 'shared' &&
+ this.currentGroupVisibility === VISIBILITY_PRIVATE &&
+ this.group.visibility !== VISIBILITY_PRIVATE
+ );
+ },
},
methods: {
onClickRowGroup(e) {
@@ -112,6 +134,17 @@ export default {
}
},
},
+ i18n: {
+ popoverTitle: __('Less restrictive visibility'),
+ popoverBody: __('Project visibility level is less restrictive than the group settings.'),
+ learnMore: __('Learn more'),
+ },
+ shareProjectsWithGroupsHelpPagePath: helpPagePath(
+ 'user/project/members/share_project_with_groups',
+ {
+ anchor: 'share-a-public-project-with-private-group',
+ },
+ ),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
AVATAR_SHAPE_OPTION_RECT,
};
@@ -120,6 +153,7 @@ export default {
<template>
<li
:id="groupDomId"
+ :data-testid="itemTestId"
:class="rowClass"
class="group-row"
:itemprop="microdata.itemprop"
@@ -165,7 +199,7 @@ export default {
data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
- class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!"
+ class="no-expand gl-mr-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
>
{{
@@ -176,20 +210,44 @@ export default {
</a>
<gl-icon
v-gl-tooltip.hover.bottom
- class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500"
+ class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-text-gray-500"
:name="visibilityIcon"
:title="visibilityTooltip"
data-testid="group-visibility-icon"
/>
- <user-access-role-badge v-if="group.permission" class="gl-mt-3">
+ <template v-if="shouldShowVisibilityWarning">
+ <gl-button
+ ref="visibilityWarningButton"
+ class="gl-p-1! gl-bg-transparent! gl-mr-3"
+ category="tertiary"
+ icon="warning"
+ :aria-label="$options.i18n.popoverTitle"
+ @click.stop
+ />
+ <gl-popover
+ :target="() => $refs.visibilityWarningButton.$el"
+ :title="$options.i18n.popoverTitle"
+ triggers="hover focus"
+ >
+ {{ $options.i18n.popoverBody }}
+ <div class="gl-mt-3">
+ <gl-link
+ class="gl-font-sm"
+ :href="$options.shareProjectsWithGroupsHelpPagePath"
+ >{{ $options.i18n.learnMore }}</gl-link
+ >
+ </div>
+ </gl-popover>
+ </template>
+ <user-access-role-badge v-if="group.permission" class="gl-mr-3">
{{ group.permission }}
</user-access-role-badge>
- <compliance-framework-label
+ <gl-label
v-if="hasComplianceFramework"
- class="gl-mt-3"
- :name="complianceFramework.name"
- :color="complianceFramework.color"
+ :title="complianceFramework.name"
+ :background-color="complianceFramework.color"
:description="complianceFramework.description"
+ size="sm"
/>
</div>
<div v-if="group.description" class="description">
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index f9bd8701199..983535d3e9c 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -133,6 +133,8 @@ export default {
signal: this.activeApiRequestAbortController.signal,
});
+ this.apiLoading = false;
+
if (exists) {
if (suggests.length) {
return Promise.resolve({ exists, suggests });
@@ -148,14 +150,14 @@ export default {
return Promise.resolve({ exists, suggests });
} catch (error) {
if (!axios.isCancel(error)) {
+ this.apiLoading = false;
+
createAlert({
message: this.$options.i18n.apiErrorMessage,
});
}
return Promise.reject();
- } finally {
- this.apiLoading = false;
}
},
handlePathInput(value) {
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index cacba2dfd23..29981d09155 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -28,28 +28,32 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
+export const VISIBILITY_PUBLIC = 'public';
+export const VISIBILITY_INTERNAL = 'internal';
+export const VISIBILITY_PRIVATE = 'private';
+
export const GROUP_VISIBILITY_TYPE = {
- public: __(
+ [VISIBILITY_PUBLIC]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- internal: __(
+ [VISIBILITY_INTERNAL]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- private: __('Private - The group and its projects can only be viewed by members.'),
+ [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'),
};
export const PROJECT_VISIBILITY_TYPE = {
- public: __('Public - The project can be accessed without any authentication.'),
- internal: __(
+ [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
+ [VISIBILITY_INTERNAL]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- private: __(
+ [VISIBILITY_PRIVATE]: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
};
export const VISIBILITY_TYPE_ICON = {
- public: 'earth',
- internal: 'shield',
- private: 'lock',
+ [VISIBILITY_PUBLIC]: 'earth',
+ [VISIBILITY_INTERNAL]: 'shield',
+ [VISIBILITY_PRIVATE]: 'lock',
};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index dfcee80aec7..a502fcd31ad 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -55,6 +55,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
renderEmptyState,
canCreateSubgroups,
canCreateProjects,
+ currentGroupVisibility,
},
} = this.$options.el;
@@ -67,6 +68,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
renderEmptyState: parseBoolean(renderEmptyState),
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
+ currentGroupVisibility,
};
},
data() {
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 360a8d3bf8d..9b6113c7444 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import Vue from 'vue';
import { highCountTrim } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
@@ -12,12 +11,18 @@ import Translate from '~/vue_shared/translate';
* @param {String} count
*/
export default function initTodoToggle() {
- $(document).on('todo:toggle', (e, count) => {
- const updatedCount = count || e?.detail?.count || 0;
- const $todoPendingCount = $('.js-todos-count');
+ document.addEventListener('todo:toggle', (e) => {
+ const updatedCount = e.detail.count || 0;
+ const todoPendingCount = document.querySelector('.js-todos-count');
- $todoPendingCount.text(highCountTrim(updatedCount));
- $todoPendingCount.toggleClass('hidden', updatedCount === 0);
+ if (todoPendingCount) {
+ todoPendingCount.textContent = highCountTrim(updatedCount);
+ if (updatedCount === 0) {
+ todoPendingCount.classList.add('hidden');
+ } else {
+ todoPendingCount.classList.remove('hidden');
+ }
+ }
});
}
@@ -85,7 +90,7 @@ function initStatusTriggers() {
function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
const { trackLabel, trackProperty } = elToTrack.dataset;
- $(el).on('shown.bs.dropdown', () => {
+ el.addEventListener('shown.bs.dropdown', () => {
Tracking.event(document.body.dataset.page, trackEvent, {
label: trackLabel,
property: trackProperty,
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index adf304aebc7..0c4f9640972 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,8 +1,17 @@
<script>
-import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
+import {
+ GlSearchBoxByType,
+ GlOutsideDirective as Outside,
+ GlIcon,
+ GlToken,
+ GlSafeHtmlDirective as SafeHtml,
+ GlTooltipDirective,
+ GlResizeObserverDirective,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
+import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
@@ -12,6 +21,8 @@ import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ SCOPE_TOKEN_MAX_LENGTH,
+ INPUT_FIELD_PADDING,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -34,14 +45,22 @@ export default {
'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
),
searchResultsLoading: s__('GlobalSearch|Search results are loading'),
+ searchResultsScope: s__('GlobalSearch|in %{scope}'),
+ kbdHelp: sprintf(
+ s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+ ),
},
- directives: { Outside },
+ directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
DropdownKeyboardNavigation,
+ GlIcon,
+ GlToken,
},
data() {
return {
@@ -50,8 +69,8 @@ export default {
};
},
computed: {
- ...mapState(['search', 'loading']),
- ...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']),
+ ...mapState(['search', 'loading', 'searchContext']),
+ ...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
return this.search;
@@ -70,16 +89,17 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
- const hasResultsUnderMinCharacters =
- this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true;
+ if (!this.showDropdown || !this.isLoggedIn) {
+ return false;
+ }
- return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters;
+ return this.searchOptions?.length > 0;
},
showDefaultItems() {
return !this.searchText;
},
- showShortcuts() {
- return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ showScopes() {
+ return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
defaultIndex() {
if (this.showDefaultItems) {
@@ -88,11 +108,11 @@ export default {
return FIRST_DROPDOWN_INDEX;
},
+
searchInputDescribeBy() {
if (this.isLoggedIn) {
return this.$options.i18n.searchInputDescribeByWithDropdown;
}
-
return this.$options.i18n.searchInputDescribeByNoDropdown;
},
dropdownResultsDescription() {
@@ -112,8 +132,26 @@ export default {
count: this.searchOptions.length,
});
},
- headerSearchActivityDescriptor() {
- return this.showDropdown ? 'is-active' : 'is-not-active';
+ searchBarStateIndicator() {
+ const hasIcon =
+ this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
+ const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
+ const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
+ return `${isActive} ${isSearching} ${hasIcon}`;
+ },
+ searchBarItem() {
+ return this.searchOptions?.[0];
+ },
+ infieldHelpContent() {
+ return this.searchBarItem?.scope || this.searchBarItem?.description;
+ },
+ infieldHelpIcon() {
+ return this.searchBarItem?.icon;
+ },
+ scopeTokenTitle() {
+ return sprintf(this.$options.i18n.searchResultsScope, {
+ scope: this.infieldHelpContent,
+ });
},
},
methods: {
@@ -127,6 +165,9 @@ export default {
this.$emit('toggleDropdown', this.showDropdown);
},
submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
+ return null;
+ }
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
@@ -136,8 +177,19 @@ export default {
this.fetchAutocompleteOptions();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ observeTokenWidth({ contentRect: { width } }) {
+ const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
+ if (!inputField) {
+ return;
+ }
+ inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
+ },
},
SEARCH_BOX_INDEX,
+ FIRST_DROPDOWN_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
};
@@ -149,10 +201,12 @@ export default {
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="headerSearchActivityDescriptor"
+ :class="searchBarStateIndicator"
+ data-testid="header-search-form"
>
<gl-search-box-by-type
id="search"
+ ref="searchInputBox"
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
@@ -165,7 +219,34 @@ export default {
@click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
+ @keydown.esc.stop.prevent="closeDropdown"
/>
+ <gl-token
+ v-if="showScopes"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help"
+ :view-only="true"
+ :title="scopeTokenTitle"
+ ><gl-icon
+ v-if="infieldHelpIcon"
+ class="gl-mr-2"
+ :aria-label="infieldHelpContent"
+ :name="infieldHelpIcon"
+ :size="16"
+ />{{
+ getTruncatedScope(
+ sprintf($options.i18n.searchResultsScope, {
+ scope: infieldHelpContent,
+ }),
+ )
+ }}
+ </gl-token>
+ <kbd
+ v-gl-tooltip.bottom.hover.html
+ class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
+ :title="$options.i18n.kbdHelp"
+ >/</kbd
+ >
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
searchInputDescribeBy
}}</span>
@@ -187,7 +268,7 @@ export default {
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
- :min="$options.SEARCH_BOX_INDEX"
+ :min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
@tab="closeDropdown"
/>
@@ -197,7 +278,7 @@ export default {
/>
<template v-else>
<header-search-scoped-items
- v-if="showShortcuts"
+ v-if="showScopes"
:current-focused-option="currentFocusedOption"
/>
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index 34d1bd71399..f5be1bcb786 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -1,13 +1,16 @@
<script>
-import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { truncate } from '~/lib/utils/text_utility';
+import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
- GlDropdownDivider,
+ GlIcon,
+ GlToken,
},
props: {
currentFocusedOption: {
@@ -25,12 +28,21 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
- return sprintf(__('%{search} %{description} %{scope}'), {
+ return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
search: this.search,
- description: option.description,
+ description: option.description || option.icon,
scope: option.scope || '',
});
},
+ titleLabel(option) {
+ return sprintf(s__('GlobalSearch|in %{scope}'), {
+ search: this.search,
+ scope: option.scope || option.description,
+ });
+ },
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
},
};
</script>
@@ -42,18 +54,30 @@ export default {
:id="option.html_id"
:ref="option.html_id"
:key="option.html_id"
+ class="gl-max-w-full"
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
:aria-selected="isOptionFocused(option)"
:aria-label="ariaLabel(option)"
tabindex="-1"
:href="option.url"
+ :title="titleLabel(option)"
>
- <span aria-hidden="true">
- "<span class="gl-font-weight-bold">{{ search }}</span
- >" {{ option.description }}
- <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
+ <span
+ ref="token-text-content"
+ class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
+ >
+ <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
+ <span class="gl-flex-grow-1 gl-relative">
+ <gl-token
+ class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
+ :view-only="true"
+ >
+ <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(option)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
</span>
</gl-dropdown-item>
- <gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" />
</div>
</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 045a552efb0..a026386b2bd 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re
export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab');
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
-export const MSG_IN_GROUP = s__('GlobalSearch|in group');
+export const MSG_IN_GROUP = s__('GlobalSearch|group');
-export const MSG_IN_PROJECT = s__('GlobalSearch|in project');
+export const MSG_IN_PROJECT = s__('GlobalSearch|project');
-export const GROUPS_CATEGORY = 'Groups';
+export const ICON_PROJECT = 'project';
-export const PROJECTS_CATEGORY = 'Projects';
+export const ICON_GROUP = 'group';
+
+export const ICON_SUBGROUP = 'subgroup';
+
+export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
+
+export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
export const ISSUES_CATEGORY = 'Recent issues';
@@ -39,3 +45,9 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
+
+export const SCOPE_TOKEN_MAX_LENGTH = 36;
+
+export const INPUT_FIELD_PADDING = 52;
+
+export const HEADER_INIT_EVENTS = ['input', 'focus'];
diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js
new file mode 100644
index 00000000000..4e9404007ec
--- /dev/null
+++ b/app/assets/javascripts/header_search/init.js
@@ -0,0 +1,53 @@
+import * as Sentry from '@sentry/browser';
+import { HEADER_INIT_EVENTS } from './constants';
+
+async function eventHandler(callback = () => {}) {
+ if (this.newHeaderSearchFeatureFlag) {
+ const { initHeaderSearchApp } = await import(
+ /* webpackChunkName: 'globalSearch' */ '~/header_search'
+ ).catch((error) => Sentry.captureException(error));
+
+ // In case the user started searching before we bootstrapped,
+ // let's pass the search along.
+ const initialSearchValue = this.searchInputBox.value;
+ initHeaderSearchApp(initialSearchValue);
+
+ // this is new #search input element. We need to re-find it.
+ // And re-focus in it.
+ document.querySelector('#search').focus();
+ callback();
+ return;
+ }
+
+ const { default: initSearchAutocomplete } = await import(
+ /* webpackChunkName: 'globalSearch' */ '../search_autocomplete'
+ ).catch((error) => Sentry.captureException(error));
+
+ const searchDropdown = initSearchAutocomplete();
+ searchDropdown.onSearchInputFocus();
+ callback();
+}
+
+function cleanEventListeners() {
+ HEADER_INIT_EVENTS.forEach((eventType) => {
+ document.querySelector('#search').removeEventListener(eventType, eventHandler);
+ });
+}
+
+function initHeaderSearch() {
+ const searchInputBox = document.querySelector('#search');
+
+ HEADER_INIT_EVENTS.forEach((eventType) => {
+ searchInputBox?.addEventListener(
+ eventType,
+ eventHandler.bind(
+ { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch },
+ cleanEventListeners,
+ ),
+ { once: true },
+ );
+ });
+}
+
+export default initHeaderSearch;
+export { eventHandler, cleanEventListeners };
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 7d08aa859fb..da7bccd35c0 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -7,9 +7,13 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- MSG_IN_PROJECT,
- MSG_IN_GROUP,
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
MSG_IN_ALL_GITLAB,
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
@@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-project',
scope: state.searchContext.project?.name || '',
- description: MSG_IN_PROJECT,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
url: getters.projectUrl,
});
}
@@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-group',
scope: state.searchContext.group?.name || '',
- description: MSG_IN_GROUP,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
url: getters.groupUrl,
});
}
@@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => {
results.push(groupedOptions[option.category]);
}
});
+
return results;
};
@@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => {
[],
);
+ if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return sortedAutocompleteOptions;
+ }
+
return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
};
diff --git a/app/assets/javascripts/helpers/help_page_helper.js b/app/assets/javascripts/helpers/help_page_helper.js
index 0e824548646..21d27b5fea9 100644
--- a/app/assets/javascripts/helpers/help_page_helper.js
+++ b/app/assets/javascripts/helpers/help_page_helper.js
@@ -7,9 +7,10 @@ const HELP_PAGE_URL_ROOT = '/help/';
*
* This is designed to mirror the Ruby `help_page_path` helper function, so that
* the two can be used interchangeably.
- * @param {String} path - Path to doc file relative to the doc/ directory in the GitLab repository.
- * Optionally, including `.md` or `.html` prefix
- * @param {String} options.anchor - Name of the anchor to scroll to on the documentation page.
+ * @param {string} path - Path to doc file relative to the doc/ directory in the GitLab repository.
+ * Optionally, including `.md` or `.html` prefix
+ * @param {object} [options]
+ * @param {string} [options.anchor] - Name of the anchor to scroll to on the documentation page.
*/
export const helpPagePath = (path, { anchor = '' } = {}) => {
let helpPath = joinPaths(gon.relative_url_root || '/', HELP_PAGE_URL_ROOT, path);
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 2df998d7518..6998f8ef0c4 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -54,25 +54,25 @@ export default {
<ide-tree-list @tree-ready="$emit('tree-ready')">
<template #header>
{{ __('Edit') }}
- <div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions">
+ <div class="ide-tree-actions gl-ml-auto gl-display-flex" data-testid="ide-root-actions">
<new-entry-button
:label="__('New file')"
:show-label="false"
- class="d-flex border-0 p-0 mr-3"
+ class="gl-display-flex gl-border-0 gl-p-0 gl-mr-5"
icon="doc-new"
data-qa-selector="new_file_button"
@click="createNewFile()"
/>
<upload
:show-label="false"
- class="d-flex mr-3"
- button-css-classes="border-0 p-0"
+ class="gl-display-flex gl-mr-5"
+ button-css-classes="gl-border-0 gl-p-0"
@create="createTempEntry"
/>
<new-entry-button
:label="__('New directory')"
:show-label="false"
- class="d-flex border-0 p-0"
+ class="gl-display-flex gl-border-0 gl-p-0"
icon="folder-new"
data-qa-selector="new_directory_button"
@click="createNewFolder()"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index e3c230f7660..d6207d4a557 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -68,6 +68,10 @@ export default {
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
+ submitAndClose() {
+ this.submitForm();
+ this.close();
+ },
submitForm() {
this.entryName = trimPathComponents(this.entryName);
@@ -161,15 +165,17 @@ export default {
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
- <input
- ref="fieldName"
- v-model.trim="entryName"
- type="text"
- class="form-control"
- data-testid="file-name-field"
- data-qa-selector="file_name_field"
- :placeholder="placeholder"
- />
+ <form data-testid="file-name-form" @submit.prevent="submitAndClose">
+ <input
+ ref="fieldName"
+ v-model.trim="entryName"
+ type="text"
+ class="form-control"
+ data-testid="file-name-field"
+ data-qa-selector="file_name_field"
+ :placeholder="placeholder"
+ />
+ </form>
<ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index d71ac766933..a1396995a3b 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,4 +1,5 @@
<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
@@ -45,6 +46,8 @@ const MARKDOWN_FILE_TYPE = 'markdown';
export default {
name: 'RepoEditor',
components: {
+ GlTabs,
+ GlTab,
FileAlert,
ContentViewer,
DiffViewer,
@@ -121,16 +124,6 @@ export default {
isPreviewViewMode() {
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
- editTabCSS() {
- return {
- active: this.isEditorViewMode,
- };
- },
- previewTabCSS() {
- return {
- active: this.isPreviewViewMode,
- };
- },
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
@@ -487,28 +480,18 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
- <div v-if="showTabs" class="ide-mode-tabs clearfix">
- <ul class="nav-links float-left border-bottom-0">
- <li :class="editTabCSS">
- <a
- href="javascript:void(0);"
- role="button"
- data-testid="edit-tab"
- @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
- >{{ __('Edit') }}</a
- >
- </li>
- <li :class="previewTabCSS">
- <a
- href="javascript:void(0);"
- role="button"
- data-testid="preview-tab"
- @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
- >{{ previewMode.previewTitle }}</a
- >
- </li>
- </ul>
- </div>
+ <gl-tabs v-if="showTabs" content-class="gl-display-none">
+ <gl-tab
+ :title="__('Edit')"
+ data-testid="edit-tab"
+ @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
+ />
+ <gl-tab
+ :title="previewMode.previewTitle"
+ data-testid="preview-tab"
+ @click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
+ />
+ </gl-tabs>
<file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 6b96fa7c45c..98ee858ca91 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -78,6 +78,11 @@ export default {
type: String,
required: true,
},
+ defaultTargetNamespace: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
@@ -433,7 +438,15 @@ export default {
return this.importTargets[group.id];
}
- const defaultTargetNamespace = this.availableNamespaces[0] ?? ROOT_NAMESPACE;
+ // If we've reached this Vue application we have at least one potential import destination
+ const defaultTargetNamespace =
+ // first option: namespace id was explicitly provided
+ this.availableNamespaces.find((ns) => ns.id === this.defaultTargetNamespace) ??
+ // second option: first available namespace
+ this.availableNamespaces[0] ??
+ // last resort: if no namespaces are available - suggest creating new namespace at root
+ ROOT_NAMESPACE;
+
let importTarget;
if (group.lastImportTarget) {
const targetNamespace = [ROOT_NAMESPACE, ...this.availableNamespaces].find(
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 02af0db7f9a..5d7e7911f5a 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -15,9 +15,10 @@ export function mountImportGroupsApp(mountElement) {
availableNamespacesPath,
createBulkImportPath,
jobsPath,
+ historyPath,
+ defaultTargetNamespace,
sourceUrl,
groupPathRegex,
- historyPath,
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
@@ -40,6 +41,7 @@ export function mountImportGroupsApp(mountElement) {
jobsPath,
groupPathRegex: new RegExp(`^(${groupPathRegex})$`),
historyPath,
+ defaultTargetNamespace: parseInt(defaultTargetNamespace, 10) || null,
},
});
},
diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js
index 98bfa48740c..6c6cadedf00 100644
--- a/app/assets/javascripts/init_confirm_danger.js
+++ b/app/assets/javascripts/init_confirm_danger.js
@@ -12,11 +12,11 @@ export default () => {
phrase,
buttonText,
buttonClass = '',
- buttonTestid = null,
- buttonVariant = null,
+ buttonTestid,
+ buttonVariant,
confirmDangerMessage,
confirmButtonText = null,
- disabled = false,
+ disabled,
additionalInformation,
htmlConfirmationMessage,
} = el.dataset;
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index e4f6e931ec0..437bcc39886 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -18,7 +18,7 @@ export const overrideDropdownDescriptions = {
};
export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__(
- 'Integrations|Connection failed. Please check your settings.',
+ 'Integrations|Connection failed. Check your integration settings.',
);
export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.');
export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.');
@@ -83,3 +83,11 @@ export const billingPlanNames = {
[billingPlans.PREMIUM]: s__('BillingPlans|Premium'),
[billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'),
};
+
+const INTEGRATION_TYPE_SLACK = 'slack';
+const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
+
+export const placeholderForType = {
+ [INTEGRATION_TYPE_SLACK]: __('#general, #development'),
+ [INTEGRATION_TYPE_MATTERMOST]: __('my-channel'),
+};
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 9307d7c2d3d..f1f574c6424 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -140,15 +140,24 @@ export default {
this.isTesting = true;
testIntegrationSettings(this.propsSource.testPath, this.getFormData())
- .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => {
- if (error) {
- this.setIsValidated();
- this.$toast.show(message);
- return;
- }
+ .then(
+ ({
+ data: {
+ error,
+ message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
+ service_response: serviceResponse,
+ },
+ }) => {
+ if (error) {
+ const errorMessage = serviceResponse ? [message, serviceResponse].join(' ') : message;
+ this.setIsValidated();
+ this.$toast.show(errorMessage);
+ return;
+ }
- this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
- })
+ this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
+ },
+ )
.catch((error) => {
this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE);
Sentry.captureException(error);
@@ -284,6 +293,7 @@ export default {
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
/>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
index 9e1ad24ae9f..b8fd8995744 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
@@ -33,6 +33,7 @@ export default {
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
+ :data-qa-selector="`${field.name}_div`"
/>
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 92042a5c981..67647cadf19 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -1,17 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { __ } from '~/locale';
-
-const typeWithPlaceholder = {
- SLACK: 'slack',
- MATTERMOST: 'mattermost',
-};
-
-const placeholderForType = {
- [typeWithPlaceholder.SLACK]: __('#general, #development'),
- [typeWithPlaceholder.MATTERMOST]: __('my-channel'),
-};
+import { placeholderForType } from 'jh_else_ce/integrations/constants';
export default {
name: 'TriggerFields',
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index fc14b2eba6a..e7f5211dc25 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -136,6 +136,7 @@ export default {
v-for="group in groups"
:key="group.id"
:name="group.name"
+ data-qa-selector="group_select_dropdown_item"
@click="selectGroup(group)"
>
<gl-avatar-labeled
diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index fb6c376cfe6..31b7fd4cc42 100644
--- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,21 +1,20 @@
<script>
-import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
+import eventHub from '../event_hub';
import ProjectSelect from './project_select.vue';
export default {
+ name: 'ImportProjectMembersModal',
components: {
- GlButton,
GlFormGroup,
GlModal,
GlSprintf,
ProjectSelect,
},
- directives: {
- GlModal: GlModalDirective,
- },
props: {
projectId: {
type: String,
@@ -45,8 +44,33 @@ export default {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.modalPrimaryButton,
+ attributes: {
+ variant: 'confirm',
+ disabled: this.importDisabled,
+ loading: this.isLoading,
+ },
+ };
+ },
+ actionCancel() {
+ return { text: this.$options.i18n.modalCancelButton };
+ },
+ },
+ mounted() {
+ eventHub.$on('openProjectMembersModal', () => {
+ this.openModal();
+ });
},
methods: {
+ openModal() {
+ this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
+ },
+ resetFields() {
+ this.invalidFeedbackMessage = '';
+ this.projectToBeImported = {};
+ },
submitImport() {
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
@@ -57,11 +81,6 @@ export default {
this.projectToBeImported = {};
});
},
- closeModal() {
- this.invalidFeedbackMessage = '';
-
- this.$refs.modal.hide();
- },
showToastMessage() {
this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions);
@@ -79,7 +98,6 @@ export default {
};
},
i18n: {
- buttonText: s__('ImportAProjectModal|Import from a project'),
projectLabel: __('Project'),
modalTitle: s__('ImportAProjectModal|Import members from another project'),
modalIntro: s__(
@@ -95,63 +113,37 @@ export default {
},
projectSelectLabelId: 'project-select',
modalId: uniqueId('import-a-project-modal-'),
- formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full',
- buttonClasses: 'gl-w-full',
};
</script>
<template>
- <form :class="$options.formClasses">
- <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{
- $options.i18n.buttonText
- }}</gl-button>
-
- <gl-modal
- ref="modal"
- :modal-id="$options.modalId"
- size="sm"
- :title="$options.i18n.modalTitle"
- ok-variant="danger"
- footer-class="gl-bg-gray-10 gl-p-5"
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ size="sm"
+ :title="$options.i18n.modalTitle"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="submitImport"
+ @hidden="resetFields"
+ >
+ <p ref="modalIntro">
+ <gl-sprintf :message="modalIntro">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ data-testid="form-group"
>
- <div>
- <p ref="modalIntro">
- <gl-sprintf :message="modalIntro">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- data-testid="form-group"
- >
- <label :id="$options.projectSelectLabelId" class="col-form-label">{{
- $options.i18n.projectLabel
- }}</label>
- <project-select v-model="projectToBeImported" />
- </gl-form-group>
- <p>{{ $options.i18n.modalHelpText }}</p>
- </div>
- <template #modal-footer>
- <div
- class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"
- >
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.i18n.modalCancelButton }}
- </gl-button>
- <div class="gl-mr-3"></div>
- <gl-button
- :disabled="importDisabled"
- :loading="isLoading"
- variant="confirm"
- data-testid="import-button"
- @click="submitImport"
- >{{ $options.i18n.modalPrimaryButton }}</gl-button
- >
- </div>
- </template>
- </gl-modal>
- </form>
+ <label :id="$options.projectSelectLabelId" class="col-form-label">{{
+ $options.i18n.projectLabel
+ }}</label>
+ <project-select v-model="projectToBeImported" />
+ </gl-form-group>
+ <p>{{ $options.i18n.modalHelpText }}</p>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
new file mode 100644
index 00000000000..5781abb41b7
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('ImportAProjectModal|Import from a project'),
+ },
+ classes: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openProjectMembersModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button :class="classes" @click="openModal">
+ {{ displayText }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index d597c7e53bb..b71cfbb6112 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -7,12 +7,13 @@ import {
GlSprintf,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, uniqueId } from 'lodash';
+import { partition, isString, uniqueId, isEmpty } 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 { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
+import { n__ } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@@ -21,7 +22,8 @@ import {
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
-import { responseMessageFromSuccess } from '../utils/response_message_parser';
+import { responseFromSuccess } from '../utils/response_message_parser';
+import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@@ -101,6 +103,7 @@ export default {
isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
+ invalidMembers: {},
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
source: 'unknown',
@@ -125,6 +128,16 @@ export default {
inviteDisabled() {
return this.newUsersToInvite.length === 0;
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ memberErrorTitle() {
+ return n__(
+ "InviteMembersModal|The following member couldn't be invited",
+ "InviteMembersModal|The following %d members couldn't be invited",
+ Object.keys(this.invalidMembers).length,
+ );
+ },
tasksToBeDoneEnabled() {
return (
(getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
@@ -218,7 +231,7 @@ export default {
},
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
- this.invalidFeedbackMessage = '';
+ this.clearValidation();
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
@@ -242,12 +255,10 @@ export default {
...userId,
})
.then((response) => {
- const message = responseMessageFromSuccess(response);
+ const { error, message } = responseFromSuccess(response);
- if (message) {
- this.showInvalidFeedbackMessage({
- response: { data: { message } },
- });
+ if (error) {
+ this.showMemberErrors(message);
} else {
this.showSuccessMessage();
}
@@ -257,6 +268,13 @@ export default {
this.isLoading = false;
});
},
+ showMemberErrors(message) {
+ this.invalidMembers = message;
+ },
+ tokenName(username) {
+ // initial token creation hits this and nothing is found... so safe navigation
+ return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
+ },
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
@@ -264,8 +282,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
+ this.clearValidation();
this.isLoading = false;
- this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@@ -287,6 +305,11 @@ export default {
},
clearValidation() {
this.invalidFeedbackMessage = '';
+ this.invalidMembers = {};
+ },
+ removeToken(token) {
+ delete this.invalidMembers[memberName(token)];
+ this.invalidMembers = { ...this.invalidMembers };
},
},
labels: MEMBER_MODAL_LABELS,
@@ -324,23 +347,40 @@ export default {
<modal-confetti v-if="isCelebration" />
</template>
- <template #user-limit-notification>
+ <template #alert>
+ <gl-alert
+ v-if="hasInvalidMembers"
+ variant="danger"
+ :dismissible="false"
+ :title="memberErrorTitle"
+ data-testid="alert-member-error"
+ >
+ {{ $options.labels.memberErrorListText }}
+ <ul class="gl-pl-5">
+ <li v-for="(error, member) in invalidMembers" :key="member">
+ <strong>{{ tokenName(member) }}:</strong> {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
<user-limit-notification
+ v-else
:close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
/>
</template>
- <template #select="{ validationState, labelId }">
+ <template #select="{ exceptionState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
- :validation-state="validationState"
+ :exception-state="exceptionState"
:aria-labelledby="labelId"
:users-filter="usersFilter"
:filter-id="filterId"
+ :invalid-members="invalidMembers"
@clear="clearValidation"
+ @token-remove="removeToken"
/>
</template>
<template #form-after>
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 90d266c3155..f917ebc35c2 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -159,7 +159,7 @@ export default {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
- validationState() {
+ exceptionState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
@@ -306,11 +306,11 @@ export default {
<slot name="intro-text-after"></slot>
</div>
- <slot name="user-limit-notification"></slot>
+ <slot name="alert"></slot>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
+ :state="exceptionState"
data-testid="members-form-group"
>
<template #description>
@@ -320,7 +320,7 @@ export default {
<label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label>
<gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled />
- <slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
+ <slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot>
</gl-form-group>
<template v-if="!reachedLimit">
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index 30c9294344e..b2bcb9a5906 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
+import { memberName } from '../utils/member_utils';
import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
export default {
@@ -23,7 +24,7 @@ export default {
type: String,
required: true,
},
- validationState: {
+ exceptionState: {
type: Boolean,
required: false,
default: false,
@@ -38,6 +39,10 @@ export default {
required: false,
default: null,
},
+ invalidMembers: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -109,13 +114,18 @@ export default {
this.hasBeenFocused = true;
},
- handleTokenRemove() {
+ handleTokenRemove(value) {
if (this.selectedTokens.length) {
+ this.$emit('token-remove', value);
+
return;
}
this.$emit('clear');
},
+ hasError(token) {
+ return Object.keys(this.invalidMembers).includes(memberName(token));
+ },
},
defaultQueryOptions: { without_project_bots: true, active: true },
i18n: {
@@ -127,7 +137,7 @@ export default {
<template>
<gl-token-selector
v-model="selectedTokens"
- :state="validationState"
+ :state="exceptionState"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="emailIsValid"
@@ -145,8 +155,19 @@ export default {
@token-remove="handleTokenRemove"
>
<template #token-content="{ token }">
- <gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" />
- <gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" />
+ <gl-icon
+ v-if="hasError(token)"
+ name="error"
+ :size="16"
+ class="gl-mr-2"
+ :data-testid="`error-icon-${token.id}`"
+ />
+ <gl-avatar
+ v-else-if="token.avatar_url"
+ :src="token.avatar_url"
+ :size="16"
+ data-testid="token-avatar"
+ />
{{ token.name }}
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index beb8f5b5aab..6141e5e9e0b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
+export const MEMBER_ERROR_LIST_TEXT = s__(
+ 'InviteMembersModal|Review the invite errors and try again:',
+);
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+ memberErrorListText: MEMBER_ERROR_LIST_TEXT,
};
export const GROUP_MODAL_LABELS = {
diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js
deleted file mode 100644
index 954347467de..00000000000
--- a/app/assets/javascripts/invite_members/init_import_a_project_modal.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
-
-export default function initImportAProjectModal() {
- const el = document.querySelector('.js-import-a-project-modal');
-
- if (!el) {
- return false;
- }
-
- const { projectId, projectName } = el.dataset;
-
- return new Vue({
- el,
- render: (createElement) =>
- createElement(ImportAProjectModal, {
- props: {
- projectId,
- projectName,
- },
- }),
- });
-}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
new file mode 100644
index 00000000000..daaa1315884
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
+
+export default function initImportProjectMembersModal() {
+ const el = document.querySelector('.js-import-project-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectId, projectName } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersModal, {
+ props: {
+ projectId,
+ projectName,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_trigger.js b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
new file mode 100644
index 00000000000..66a9bf118d2
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue';
+
+export default function initImportProjectMembersTrigger() {
+ const el = document.querySelector('.js-import-project-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
new file mode 100644
index 00000000000..d85162626f1
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -0,0 +1,4 @@
+export function memberName(member) {
+ // user defined tokens(invites by email) will have email in `name` and will not contain `username`
+ return member.username || member.name;
+}
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index db8ac303dc4..6e6431b89d9 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -1,15 +1,4 @@
-import { isString } from 'lodash';
-
-function responseKeyedMessageParsed(keyedMessage) {
- try {
- const keys = Object.keys(keyedMessage);
- const msg = keyedMessage[keys[0]];
-
- return msg;
- } catch {
- return '';
- }
-}
+import { isString, isArray } from 'lodash';
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@@ -23,9 +12,9 @@ export function responseMessageFromError(response) {
return data.error || data.message?.error || data.message || '';
}
-export function responseMessageFromSuccess(response) {
+export function responseFromSuccess(response) {
if (!response?.data) {
- return '';
+ return { error: false };
}
const { data } = response;
@@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) {
const { message } = data;
if (isString(message)) {
- return message;
+ return { message, error: true };
+ }
+
+ if (isArray(message)) {
+ return { message: message[0], error: true };
}
+ // we assume object now with our keyed format
+ return { message: { ...message }, error: true };
+ }
- return responseKeyedMessageParsed(message);
+ if (data.error) {
+ return { message: data.error, error: true };
}
- return data.error || '';
+ return { error: false };
}
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
index d46354e240a..8a55176fed0 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
@@ -54,24 +54,23 @@ export default class IssuableBulkUpdateSidebar {
new MilestoneSelect();
subscriptionSelect();
+ // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy
+ // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at
+ // runtime this block won't execute.
if (IS_EE) {
- import('ee/vue_shared/components/sidebar/health_status_select/health_status_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/health_status_select/health_status_bundle')
.then(({ default: HealthStatusSelect }) => {
HealthStatusSelect();
})
.catch(() => {});
- }
- if (IS_EE) {
- import('ee/vue_shared/components/sidebar/epics_select/epics_select_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/epics_select/epics_select_bundle')
.then(({ default: EpicSelect }) => {
EpicSelect();
})
.catch(() => {});
- }
- if (IS_EE) {
- import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle')
+ import('ee_else_ce/vue_shared/components/sidebar/iterations_dropdown_bundle')
.then(({ default: iterationsDropdown }) => {
iterationsDropdown();
})
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index e6379b35f7a..a505a988360 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -84,7 +84,7 @@ export default {
<gl-icon
v-if="hasState"
ref="iconElementXL"
- class="mr-2 d-block"
+ class="gl-mr-3"
:class="iconClasses"
:name="iconName"
:title="stateTitle"
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 38453072af8..cc2608b5c62 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -68,8 +68,7 @@ export default class IssuableForm {
this.gfmAutoComplete = new GfmAutoComplete(
gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
).setup();
- const autoAssignToMe = form.get(0).id === 'new_merge_request';
- this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe });
+ this.usersSelect = new UsersSelect();
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();
@@ -82,7 +81,7 @@ export default class IssuableForm {
this.initAutosave();
this.form.on('submit', this.handleSubmit);
- this.form.on('click', '.btn-cancel', this.resetAutosave);
+ this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave);
this.form.find('.js-unwrap-on-load').unwrap();
this.initWip();
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index edf3789e6dc..92ff7f21eff 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -169,28 +169,27 @@ export default class CreateMergeRequestDropdown {
}
createMergeRequest() {
- return new Promise(() => {
- this.isCreatingMergeRequest = true;
- return this.createBranch(false)
- .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue'))
- .then(() => {
- let path = canCreateConfidentialMergeRequest()
- ? this.createMrPath.replace(
- this.projectPath,
- confidentialMergeRequestState.selectedProject.pathWithNamespace,
- )
- : this.createMrPath;
- path = mergeUrlParams(
- {
- 'merge_request[target_branch]': this.refInput.value,
- 'merge_request[source_branch]': this.branchInput.value,
- },
- path,
- );
-
- window.location.href = path;
- });
- });
+ this.isCreatingMergeRequest = true;
+
+ return this.createBranch(false)
+ .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue'))
+ .then(() => {
+ let path = canCreateConfidentialMergeRequest()
+ ? this.createMrPath.replace(
+ this.projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ )
+ : this.createMrPath;
+ path = mergeUrlParams(
+ {
+ 'merge_request[target_branch]': this.refInput.value,
+ 'merge_request[source_branch]': this.branchInput.value,
+ },
+ path,
+ );
+
+ window.location.href = path;
+ });
}
disable() {
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 67c6c723dcc..380bb5f5346 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -23,6 +23,7 @@ import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
+import initLinkedResources from '~/linked_resources';
import FilteredSearchServiceDesk from './filtered_search_service_desk';
export function initFilteredSearchServiceDesk() {
@@ -59,6 +60,7 @@ export function initShow() {
if (issueType === IssueType.Incident) {
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
initHeaderActions(store, IssueType.Incident);
+ initLinkedResources();
initRelatedIssues(IssueType.Incident);
} else {
initIssueApp(issuableData, store);
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 fa56c0183b2..f567b0f1d68 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -70,6 +70,7 @@ import {
UPDATED_DESC,
urlSortParams,
} from '../constants';
+
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchLabelsQuery from '../queries/search_labels.query.graphql';
@@ -98,6 +99,10 @@ const MilestoneToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
const ReleaseToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue');
+const CrmContactToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue');
+const CrmOrganizationToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue');
export default {
i18n,
@@ -168,6 +173,7 @@ export default {
showBulkEditSidebar: false,
sortKey: CREATED_DESC,
state: IssuableStates.Opened,
+ pageSize: PAGE_SIZE,
};
},
apollo: {
@@ -383,7 +389,11 @@ export default {
type: TOKEN_TYPE_CONTACT,
title: TOKEN_TITLE_CONTACT,
icon: 'user',
- token: GlFilteredSearchToken,
+ token: CrmContactToken,
+ fullPath: this.fullPath,
+ isProject: this.isProject,
+ defaultContacts: DEFAULT_NONE_ANY,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`,
operators: OPERATOR_IS_ONLY,
unique: true,
});
@@ -394,7 +404,11 @@ export default {
type: TOKEN_TYPE_ORGANIZATION,
title: TOKEN_TITLE_ORGANIZATION,
icon: 'users',
- token: GlFilteredSearchToken,
+ token: CrmOrganizationToken,
+ fullPath: this.fullPath,
+ isProject: this.isProject,
+ defaultOrganizations: DEFAULT_NONE_ANY,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`,
operators: OPERATOR_IS_ONLY,
unique: true,
});
@@ -411,6 +425,10 @@ export default {
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
+ showPageSizeControls() {
+ /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */
+ return this.currentTabCount > PAGE_SIZE;
+ },
sortOptions() {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
@@ -433,8 +451,8 @@ export default {
...this.urlFilterParams,
first_page_size: this.pageParams.firstPageSize,
last_page_size: this.pageParams.lastPageSize,
- page_after: this.pageParams.afterCursor,
- page_before: this.pageParams.beforeCursor,
+ page_after: this.pageParams.afterCursor ?? undefined,
+ page_before: this.pageParams.beforeCursor ?? undefined,
};
},
},
@@ -543,7 +561,7 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
- this.pageParams = getInitialPageParams(this.sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
}
this.state = state;
@@ -558,7 +576,7 @@ export default {
return;
}
- this.pageParams = getInitialPageParams(this.sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
this.filterTokens = filter;
this.$router.push({ query: this.urlParams });
@@ -566,7 +584,7 @@ export default {
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
- firstPageSize: PAGE_SIZE,
+ firstPageSize: this.pageSize,
};
scrollUp();
@@ -575,7 +593,7 @@ export default {
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
- lastPageSize: PAGE_SIZE,
+ lastPageSize: this.pageSize,
};
scrollUp();
@@ -624,7 +642,7 @@ export default {
}
if (this.sortKey !== sortKey) {
- this.pageParams = getInitialPageParams(sortKey);
+ this.pageParams = getInitialPageParams(this.pageSize);
}
this.sortKey = sortKey;
@@ -664,6 +682,17 @@ export default {
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
+ handlePageSizeChange(newPageSize) {
+ /** make sure the page number is preserved so that the current context is not lost* */
+ const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
+ const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize';
+ /** depending upon what page or page size we are dynamically set pageParams * */
+ this.pageParams[pageNumberSize] = newPageSize;
+ this.pageSize = newPageSize;
+ scrollUp();
+
+ this.$router.push({ query: this.urlParams });
+ },
updateData(sortValue) {
const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
@@ -696,7 +725,7 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
this.pageParams = getInitialPageParams(
- sortKey,
+ this.pageSize,
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
pageAfter,
@@ -732,8 +761,10 @@ export default {
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
+ :default-page-size="pageSize"
sync-filter-and-sort
use-keyset-pagination
+ :show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
@click-tab="handleClickTab"
@@ -744,6 +775,7 @@ export default {
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
+ @page-size-change="handlePageSizeChange"
>
<template #nav-actions>
<gl-button
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 74f801f685c..a921eb62e26 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -90,6 +90,8 @@ export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
+export const CLOSED_ASC = 'CLOSED_AT_ASC';
+export const CLOSED_DESC = 'CLOSED_AT_DESC';
export const urlSortParams = {
[PRIORITY_ASC]: 'priority',
@@ -98,6 +100,8 @@ export const urlSortParams = {
[CREATED_DESC]: 'created_date',
[UPDATED_ASC]: 'updated_asc',
[UPDATED_DESC]: 'updated_desc',
+ [CLOSED_ASC]: 'closed_asc',
+ [CLOSED_DESC]: 'closed_desc',
[MILESTONE_DUE_ASC]: 'milestone',
[MILESTONE_DUE_DESC]: 'milestone_due_desc',
[DUE_DATE_ASC]: 'due_date',
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 73a13cea94a..35762120f71 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -13,6 +13,7 @@ fragment IssueFragment on Issue {
state
title
updatedAt
+ closedAt
upvotes
userDiscussionsCount @include(if: $isSignedIn)
webPath
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index dfdc6e27f0d..f02c7a23f51 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -21,7 +21,6 @@ import {
MILESTONE_DUE_DESC,
NORMAL_FILTER,
PAGE_SIZE,
- PAGE_SIZE_MANUAL,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@@ -44,11 +43,13 @@ import {
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
+ CLOSED_ASC,
+ CLOSED_DESC,
} from './constants';
export const getInitialPageParams = (
- sortKey,
- firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ pageSize,
+ firstPageSize = pageSize ?? PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,
@@ -92,6 +93,14 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
{
id: 4,
+ title: __('Closed date'),
+ sortDirection: {
+ ascending: CLOSED_ASC,
+ descending: CLOSED_DESC,
+ },
+ },
+ {
+ id: 5,
title: __('Milestone due date'),
sortDirection: {
ascending: MILESTONE_DUE_ASC,
@@ -99,7 +108,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 5,
+ id: 6,
title: __('Due date'),
sortDirection: {
ascending: DUE_DATE_ASC,
@@ -107,7 +116,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 6,
+ id: 7,
title: __('Popularity'),
sortDirection: {
ascending: POPULARITY_ASC,
@@ -115,7 +124,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 7,
+ id: 8,
title: __('Label priority'),
sortDirection: {
ascending: LABEL_PRIORITY_ASC,
@@ -123,7 +132,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 8,
+ id: 9,
title: __('Manual'),
sortDirection: {
ascending: RELATIVE_POSITION_ASC,
@@ -131,7 +140,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
},
},
{
- id: 9,
+ id: 10,
title: __('Title'),
sortDirection: {
ascending: TITLE_ASC,
diff --git a/app/assets/javascripts/issues/new/components/type_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue
index a70e79b70f9..9c43e527f8b 100644
--- a/app/assets/javascripts/issues/new/components/type_popover.vue
+++ b/app/assets/javascripts/issues/new/components/type_popover.vue
@@ -18,8 +18,9 @@ export default {
</script>
<template>
- <span id="popovercontainer">
- <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <span id="popovercontainer" class="gl-ml-2">
+ <gl-icon id="issue-type-info" name="question-o" class="gl-text-blue-600" />
+
<gl-popover
target="issue-type-info"
container="popovercontainer"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 892c631f8ea..449da394841 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlSafeHtmlDirective as SafeHtml,
- GlModal,
- GlToast,
- GlTooltip,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui';
import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
@@ -20,11 +14,16 @@ import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
+import {
+ TRACKING_CATEGORY_SHOW,
+ TASK_TYPE_NAME,
+ WIDGET_TYPE_DESCRIPTION,
+} from '~/work_items/constants';
import animateMixin from '../mixins/animate';
import { convertDescriptionWithNewSort } from '../utils';
@@ -40,12 +39,11 @@ export default {
GlModal: GlModalDirective,
},
components: {
- GlModal,
- CreateWorkItem,
GlTooltip,
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['fullPath'],
props: {
canUpdate: {
type: Boolean,
@@ -103,6 +101,7 @@ export default {
workItemId: isPositiveInteger(workItemId)
? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
: undefined,
+ workItemTypes: [],
};
},
apollo: {
@@ -117,11 +116,28 @@ export default {
return !this.workItemId || !this.workItemsEnabled;
},
},
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ skip() {
+ return !this.workItemsEnabled;
+ },
+ },
},
computed: {
workItemsEnabled() {
return this.glFeatures.workItems;
},
+ taskWorkItemType() {
+ return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
+ },
issueGid() {
return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
},
@@ -344,8 +360,8 @@ export default {
<use href="${gon.sprite_icons}#doc-new"></use>
</svg>
`;
- button.setAttribute('aria-label', s__('WorkItem|Convert to work item'));
- button.addEventListener('click', () => this.openCreateTaskModal(button));
+ button.setAttribute('aria-label', s__('WorkItem|Create task'));
+ button.addEventListener('click', () => this.handleCreateTask(button));
this.insertButtonNextToTaskText(item, button);
});
},
@@ -386,17 +402,11 @@ export default {
lineNumberEnd: lineNumbers[1],
};
},
- openCreateTaskModal(el) {
- this.setActiveTask(el);
- this.$refs.modal.show();
- },
- closeCreateTaskModal() {
- this.$refs.modal.hide();
- },
openWorkItemDetailModal(el) {
if (!el) {
return;
}
+
this.setActiveTask(el);
this.$refs.detailsModal.show();
},
@@ -404,13 +414,58 @@ export default {
this.workItemId = undefined;
this.updateWorkItemIdUrlQuery(undefined);
},
- handleCreateTask(description) {
- this.$emit('updateDescription', description);
- this.closeCreateTaskModal();
+ async handleCreateTask(el) {
+ this.setActiveTask(el);
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: createWorkItemFromTaskMutation,
+ variables: {
+ input: {
+ id: this.issueGid,
+ workItemData: {
+ lockVersion: this.lockVersion,
+ title: this.activeTask.title,
+ lineNumberStart: Number(this.activeTask.lineNumberStart),
+ lineNumberEnd: Number(this.activeTask.lineNumberEnd),
+ workItemTypeId: this.taskWorkItemType,
+ },
+ },
+ },
+ update(store, { data: { workItemCreateFromTask } }) {
+ const { newWorkItem } = workItemCreateFromTask;
+
+ store.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: newWorkItem.id,
+ },
+ data: {
+ workItem: newWorkItem,
+ },
+ });
+ },
+ });
+
+ const { workItem, newWorkItem } = data.workItemCreateFromTask;
+
+ const updatedDescription = workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
+ )?.descriptionHtml;
+
+ this.$emit('updateDescription', updatedDescription);
+ this.workItemId = newWorkItem.id;
+ this.openWorkItemDetailModal(el);
+ } catch (error) {
+ createFlash({
+ message: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ error,
+ captureError: true,
+ });
+ }
},
handleDeleteTask(description) {
this.$emit('updateDescription', description);
- this.$toast.show(s__('WorkItem|Work item deleted'));
+ this.$toast.show(s__('WorkItem|Task deleted'));
},
updateWorkItemIdUrlQuery(workItemId) {
updateHistory({
@@ -452,19 +507,6 @@ export default {
data-testid="textarea"
>
</textarea>
-
- <gl-modal ref="modal" size="lg" modal-id="create-task-modal" hide-footer body-class="gl-p-0!">
- <create-work-item
- is-modal
- :initial-title="activeTask.title"
- :issue-gid="issueGid"
- :lock-version="lockVersion"
- :line-number-start="activeTask.lineNumberStart"
- :line-number-end="activeTask.lineNumberEnd"
- @closeModal="closeCreateTaskModal"
- @onCreate="handleCreateTask"
- />
- </gl-modal>
<work-item-detail-modal
ref="detailsModal"
:can-update="canUpdate"
@@ -478,7 +520,7 @@ export default {
/>
<template v-if="workItemsEnabled">
<gl-tooltip v-for="item in taskButtons" :key="item" :target="item">
- {{ s__('WorkItem|Convert to work item') }}
+ {{ s__('WorkItem|Create task') }}
</gl-tooltip>
</template>
</div>
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 9b31014c1ba..358b53bd131 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -105,7 +105,7 @@ export default {
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
variant="confirm"
- class="qa-save-button gl-mr-3"
+ class="gl-mr-3"
data-testid="issuable-save-button"
type="submit"
@click.prevent="updateIssuable"
@@ -123,7 +123,6 @@ export default {
:disabled="deleteLoading"
category="secondary"
variant="danger"
- class="qa-delete-button"
data-testid="issuable-delete-button"
@click="track('click_button')"
>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 0bb5e7cb2ee..f45af47374a 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -59,7 +59,8 @@ export default {
id="issue-description"
ref="textarea"
:value="value"
- class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-qa-selector="description_field"
dir="auto"
data-supports-quick-actions="true"
:aria-label="__('Description')"
diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue
index 594d1a65700..58d32256da4 100644
--- a/app/assets/javascripts/issues/show/components/fields/title.vue
+++ b/app/assets/javascripts/issues/show/components/fields/title.vue
@@ -19,7 +19,7 @@ export default {
id="issuable-title"
ref="input"
:value="value"
- class="form-control qa-title-input gl-border-gray-200"
+ class="form-control gl-border-gray-200"
dir="auto"
type="text"
:placeholder="__('Title')"
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
new file mode 100644
index 00000000000..9fc5027d457
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -0,0 +1,26 @@
+import { s__ } from '~/locale';
+
+export const timelineTabI18n = Object.freeze({
+ title: s__('Incident|Timeline'),
+ emptyDescription: s__('Incident|No timeline items have been added yet.'),
+ addEventButton: s__('Incident|Add new timeline event'),
+});
+
+export const timelineFormI18n = Object.freeze({
+ createError: s__('Incident|Error creating incident timeline event: %{error}'),
+ createErrorGeneric: s__(
+ 'Incident|Something went wrong while creating the incident timeline event.',
+ ),
+ areaPlaceholder: s__('Incident|Timeline text...'),
+ saveAndAdd: s__('Incident|Save and add another event'),
+ areaLabel: s__('Incident|Timeline text'),
+});
+
+export const timelineListI18n = Object.freeze({
+ deleteButton: s__('Incident|Delete event'),
+ deleteError: s__('Incident|Error deleting incident timeline event: %{error}'),
+ deleteErrorGeneric: s__(
+ 'Incident|Something went wrong while deleting the incident timeline event.',
+ ),
+ deleteModal: s__('Incident|Are you sure you want to delete this event?'),
+});
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..f1fc27dcb2a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql
@@ -0,0 +1,13 @@
+mutation CreateTimelineEvent($input: TimelineEventCreateInput!) {
+ timelineEventCreate(input: $input) {
+ timelineEvent {
+ id
+ note
+ noteHtml
+ action
+ occurredAt
+ createdAt
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..78babf9d62e
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql
@@ -0,0 +1,8 @@
+mutation DestroyTimelineEvent($input: TimelineEventDestroyInput!) {
+ timelineEventDestroy(input: $input) {
+ timelineEvent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
index 7e049d98c1a..bc4e8414bfc 100644
--- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
@@ -4,17 +4,11 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) {
incidentManagementTimelineEvents(incidentId: $incidentId) {
nodes {
id
- author {
- id
- name
- username
- }
note
noteHtml
action
occurredAt
createdAt
- updatedAt
}
}
}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 6fdce6045f2..dd84a1d7d67 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -101,7 +101,7 @@ export default {
>
<gl-tab :title="s__('Incident|Summary')">
<highlight-bar :alert="alert" />
- <description-component v-bind="$attrs" />
+ <description-component v-bind="$attrs" v-on="$listeners" />
</gl-tab>
<incident-metric-tab />
<gl-tab
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
new file mode 100644
index 00000000000..36ec6362a22
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -0,0 +1,266 @@
+<script>
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
+import { produce } from 'immer';
+import { sortBy } from 'lodash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { createAlert } from '~/flash';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { sprintf } from '~/locale';
+import { getUtcShiftedDateNow } from './utils';
+import { timelineFormI18n } from './constants';
+
+import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
+import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+
+export default {
+ name: 'IncidentTimelineEventForm',
+ restrictedToolBarItems: [
+ 'quote',
+ 'strikethrough',
+ 'bullet-list',
+ 'numbered-list',
+ 'task-list',
+ 'collapsible-section',
+ 'table',
+ 'full-screen',
+ ],
+ components: {
+ MarkdownField,
+ GlDatepicker,
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ GlIcon,
+ },
+ i18n: timelineFormI18n,
+ directives: {
+ autofocusonshow,
+ },
+ inject: ['fullPath', 'issuableId'],
+ props: {
+ hasTimelineEvents: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ // Create shifted date to force the datepicker to format in UTC
+ const utcShiftedDate = getUtcShiftedDateNow();
+ return {
+ currentDate: utcShiftedDate,
+ currentHour: utcShiftedDate.getHours(),
+ currentMinute: utcShiftedDate.getMinutes(),
+ timelineText: '',
+ createTimelineEventActive: false,
+ datepickerTextInput: null,
+ };
+ },
+ methods: {
+ clear() {
+ const utcShiftedDate = getUtcShiftedDateNow();
+ this.currentDate = utcShiftedDate;
+ this.currentHour = utcShiftedDate.getHours();
+ this.currentMinute = utcShiftedDate.getMinutes();
+ },
+ hideIncidentTimelineEventForm() {
+ this.$emit('hide-incident-timeline-event-form');
+ },
+ focusDate() {
+ this.$refs.datepicker.$el.focus();
+ },
+ updateCache(store, { data }) {
+ const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
+
+ if (errors.length) {
+ return;
+ }
+
+ const variables = {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ fullPath: this.fullPath,
+ };
+
+ const sourceData = store.readQuery({
+ query: getTimelineEvents,
+ variables,
+ });
+
+ const newData = produce(sourceData, (draftData) => {
+ const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
+ draftEventList.push(event);
+ // ISOStrings sort correctly in lexical order
+ const sortedEvents = sortBy(draftEventList, 'occurredAt');
+ draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
+ });
+
+ store.writeQuery({
+ query: getTimelineEvents,
+ variables,
+ data: newData,
+ });
+ },
+ createIncidentTimelineEvent(addOneEvent) {
+ this.createTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: CreateTimelineEvent,
+ variables: {
+ input: {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ note: this.timelineText,
+ occurredAt: this.createDateString(),
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data = {} }) => {
+ const errors = data.timelineEventCreate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
+ });
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.createErrorGeneric,
+ captureError: true,
+ error,
+ });
+ })
+ .finally(() => {
+ this.createTimelineEventActive = false;
+ this.timelineText = '';
+ if (addOneEvent) {
+ this.hideIncidentTimelineEventForm();
+ }
+ });
+ },
+ createDateString() {
+ const [years, months, days] = this.datepickerTextInput.split('-');
+ const utcDate = new Date(
+ Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute),
+ );
+ return utcDate.toISOString();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-relative gl-display-flex gl-align-items-center"
+ :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
+ >
+ <div
+ v-if="hasTimelineEvents"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
+ >
+ <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
+ <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate">
+ <gl-form-input
+ id="incident-date"
+ ref="datepicker"
+ v-model="datepickerTextInput"
+ data-testid="input-datepicker"
+ class="gl-datepicker-input gl-pr-7!"
+ :value="formattedDate"
+ :placeholder="__('YYYY-MM-DD')"
+ @keydown.enter="onKeydown"
+ />
+ </gl-datepicker>
+ </gl-form-group>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-group :label="__('Time')">
+ <div class="gl-display-flex">
+ <label label-for="timeline-input-hours" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-hours"
+ v-model="currentHour"
+ data-testid="input-hours"
+ size="xs"
+ type="number"
+ min="00"
+ max="23"
+ />
+ <label label-for="timeline-input-minutes" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-minutes"
+ v-model="currentMinute"
+ class="gl-ml-3"
+ data-testid="input-minutes"
+ size="xs"
+ type="number"
+ min="00"
+ max="59"
+ />
+ </div>
+ </gl-form-group>
+ <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
+ </div>
+ </div>
+ <div class="common-note-form">
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
+ <markdown-field
+ :can-attach-file="false"
+ :add-spacing-classes="false"
+ :show-comment-tool-bar="false"
+ :textarea-value="timelineText"
+ :restricted-tool-bar-items="$options.restrictedToolBarItems"
+ markdown-docs-path=""
+ :enable-preview="false"
+ class="bordered-box gl-mt-0"
+ >
+ <template #textarea>
+ <textarea
+ v-model="timelineText"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="$options.i18n.areaPlaceholder"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ </div>
+ <gl-form-group class="gl-mb-0">
+ <gl-button
+ variant="confirm"
+ category="primary"
+ class="gl-mr-3"
+ :loading="createTimelineEventActive"
+ @click="createIncidentTimelineEvent(true)"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ category="secondary"
+ class="gl-mr-3 gl-ml-n2"
+ :loading="createTimelineEventActive"
+ @click="createIncidentTimelineEvent(false)"
+ >
+ {{ $options.i18n.saveAndAdd }}
+ </gl-button>
+ <gl-button
+ class="gl-ml-n2"
+ :disabled="createTimelineEventActive"
+ @click="hideIncidentTimelineEventForm"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <div class="gl-border-b gl-pt-5"></div>
+ </gl-form-group>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index a6e58ee0bdc..519c0d402a0 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -1,9 +1,16 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
+import { createAlert } from '~/flash';
+import { sprintf } from '~/locale';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
+import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
+import { timelineListI18n } from './constants';
export default {
name: 'IncidentTimelineEventList',
+ i18n: timelineListI18n,
components: {
IncidentTimelineEventListItem,
},
@@ -43,6 +50,41 @@ export default {
}
return eventIndex === events.length - 1;
},
+ handleDelete: ignoreWhilePending(async function handleDelete(event) {
+ const msg = this.$options.i18n.deleteModal;
+
+ const confirmed = await confirmAction(msg, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: this.$options.i18n.deleteButton,
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ const result = await this.$apollo.mutate({
+ mutation: deleteTimelineEvent,
+ variables: {
+ input: {
+ id: event.id,
+ },
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(event);
+ cache.evict({ id: cacheId });
+ },
+ });
+ const { errors } = result.data.timelineEventDestroy;
+ if (errors?.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.deleteError, { error: errors.join('. ') }, false),
+ });
+ }
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error });
+ }
+ }),
},
};
</script>
@@ -65,7 +107,7 @@ export default {
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
:is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
- data-testid="timeline-event"
+ @delete="handleDelete(event)"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
index fef9bf713b7..62ccd696ef6 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getEventIcon } from './utils';
@@ -7,15 +7,20 @@ import { getEventIcon } from './utils';
export default {
name: 'IncidentTimelineEventListItem',
i18n: {
+ delete: __('Delete'),
+ moreActions: __('More actions'),
timeUTC: __('%{time} UTC'),
},
components: {
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlSprintf,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ inject: ['canUpdate'],
props: {
isLastItem: {
type: Boolean,
@@ -55,16 +60,32 @@ export default {
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
<div
- class="timeline-event-note gl-w-full"
+ class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row"
:class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
data-testid="event-text-container"
>
- <strong class="gl-font-lg" data-testid="event-time">
- <gl-sprintf :message="$options.i18n.timeUTC">
- <template #time>{{ time }}</template>
- </gl-sprintf>
- </strong>
- <div v-safe-html="noteHtml"></div>
+ <div>
+ <strong class="gl-font-lg" data-testid="event-time">
+ <gl-sprintf :message="$options.i18n.timeUTC">
+ <template #time>{{ time }}</template>
+ </gl-sprintf>
+ </strong>
+ <div v-safe-html="noteHtml"></div>
+ </div>
+ <gl-dropdown
+ v-if="canUpdate"
+ right
+ class="event-note-actions gl-ml-auto gl-align-self-center"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="$options.i18n.moreActions"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item @click="$emit('delete')">
+ {{ $options.i18n.delete }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index 400e1f0b725..e1946ef4d07 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -1,23 +1,29 @@
<script>
-import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
import { displayAndLogError } from './utils';
+import { timelineTabI18n } from './constants';
+import IncidentTimelineEventForm from './timeline_events_form.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
export default {
components: {
+ GlButton,
GlEmptyState,
GlLoadingIcon,
GlTab,
+ IncidentTimelineEventForm,
IncidentTimelineEventsList,
},
- inject: ['fullPath', 'issuableId'],
+ i18n: timelineTabI18n,
+ inject: ['canUpdate', 'fullPath', 'issuableId'],
data() {
return {
+ isEventFormVisible: false,
timelineEvents: [],
};
},
@@ -50,21 +56,43 @@ export default {
return !this.timelineEventLoading && !this.hasTimelineEvents;
},
},
+ methods: {
+ hideEventForm() {
+ this.isEventFormVisible = false;
+ },
+ async showEventForm() {
+ this.$refs.eventForm.clear();
+ this.isEventFormVisible = true;
+ await this.$nextTick();
+ this.$refs.eventForm.focusDate();
+ },
+ },
};
</script>
<template>
- <gl-tab :title="s__('Incident|Timeline')">
+ <gl-tab :title="$options.i18n.title">
<gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" />
<gl-empty-state
v-else-if="showEmptyState"
:compact="true"
- :description="s__('Incident|No timeline items have been added yet.')"
+ :description="$options.i18n.emptyDescription"
/>
<incident-timeline-events-list
v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents"
/>
+ <incident-timeline-event-form
+ v-show="isEventFormVisible"
+ ref="eventForm"
+ :has-timeline-events="hasTimelineEvents"
+ class="timeline-event-note timeline-event-note-form"
+ :class="{ 'gl-pl-0': !hasTimelineEvents }"
+ @hide-incident-timeline-event-form="hideEventForm"
+ />
+ <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
+ {{ $options.i18n.addEventButton }}
+ </gl-button>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index 8b5a2ec4031..256e3025f19 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -10,9 +10,23 @@ export const displayAndLogError = (error) =>
const EVENT_ICONS = {
comment: 'comment',
+ issues: 'issues',
+ status: 'status',
default: 'comment',
};
export const getEventIcon = (actionName) => {
return EVENT_ICONS[actionName] ?? EVENT_ICONS.default;
};
+
+/**
+ * Returns a date shifted by the current timezone offset. Allows
+ * date.getHours() and similar to return UTC values.
+ *
+ * @returns {Date}
+ */
+export const getUtcShiftedDateNow = () => {
+ const date = new Date();
+ date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
+ return date;
+};
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 7f67b31b122..307d9f9f69a 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -74,14 +74,15 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="title qa-title gl-font-size-h-display"
+ class="title gl-font-size-h-display"
+ data-qa-selector="title_content"
dir="auto"
></h1>
<gl-button
v-if="showInlineEditButton && canUpdate"
v-gl-tooltip.bottom
icon="pencil"
- class="btn-edit js-issuable-edit qa-edit-button"
+ class="btn-edit js-issuable-edit"
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
@click="edit"
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 5bdad010af7..459a3804837 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -63,6 +63,7 @@ export function initIncidentApp(issueData = {}) {
return createElement(IssueApp, {
props: {
...issueData,
+ issueId: Number(issuableId),
issuableStatus: state,
descriptionComponent: IncidentTabs,
showTitleBorder: false,
diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue
deleted file mode 100644
index c639e49083b..00000000000
--- a/app/assets/javascripts/jobs/bridge/app.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { __, sprintf } from '~/locale';
-import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import getPipelineQuery from './graphql/queries/pipeline.query.graphql';
-import BridgeEmptyState from './components/empty_state.vue';
-import BridgeSidebar from './components/sidebar.vue';
-import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './components/constants';
-
-export default {
- name: 'BridgePageApp',
- components: {
- BridgeEmptyState,
- BridgeSidebar,
- CiHeader,
- GlLoadingIcon,
- },
- inject: ['buildId', 'projectFullPath', 'pipelineIid'],
- apollo: {
- pipeline: {
- query: getPipelineQuery,
- variables() {
- return {
- fullPath: this.projectFullPath,
- iid: this.pipelineIid,
- };
- },
- update(data) {
- if (!data?.project?.pipeline) {
- return null;
- }
-
- const { pipeline } = data.project;
- const stages = pipeline?.stages.edges.map((edge) => edge.node) || [];
- const jobs = stages.map((stage) => stage.jobs.nodes).flat();
-
- return {
- ...pipeline,
- commit: {
- ...pipeline.commit,
- commit_path: pipeline.commit.webPath,
- short_id: pipeline.commit.shortId,
- },
- id: getIdFromGraphQLId(pipeline.id),
- jobs,
- stages,
- };
- },
- },
- },
- data() {
- return {
- isSidebarExpanded: true,
- pipeline: {},
- };
- },
- computed: {
- bridgeJob() {
- return (
- this.pipeline.jobs?.filter(
- (job) => getIdFromGraphQLId(job.id) === Number(this.buildId),
- )[0] || {}
- );
- },
- bridgeName() {
- return sprintf(__('Job %{jobName}'), { jobName: this.bridgeJob.name });
- },
- isPipelineLoading() {
- return this.$apollo.queries.pipeline.loading;
- },
- },
- created() {
- window.addEventListener('resize', this.onResize);
- },
- mounted() {
- this.onResize();
- },
- methods: {
- toggleSidebar() {
- this.isSidebarExpanded = !this.isSidebarExpanded;
- },
- onResize() {
- const breakpoint = bp.getBreakpointSize();
- if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) {
- this.isSidebarExpanded = false;
- } else if (!this.isSidebarExpanded) {
- this.isSidebarExpanded = true;
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-loading-icon v-if="isPipelineLoading" size="lg" class="gl-mt-4" />
- <div v-else>
- <ci-header
- class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
- :status="bridgeJob.detailedStatus"
- :time="bridgeJob.createdAt"
- :user="pipeline.user"
- :has-sidebar-button="true"
- :item-name="bridgeName"
- @clickedSidebarButton="toggleSidebar"
- />
- <bridge-empty-state :downstream-pipeline-path="bridgeJob.downstreamPipeline.path" />
- <bridge-sidebar
- v-if="isSidebarExpanded"
- :bridge-job="bridgeJob"
- :commit="pipeline.commit"
- :is-sidebar-expanded="isSidebarExpanded"
- @toggleSidebar="toggleSidebar"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js
deleted file mode 100644
index 33310b3157a..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm'];
diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue
deleted file mode 100644
index bd07d863719..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/empty_state.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- name: 'BridgeEmptyState',
- i18n: {
- title: __('This job triggers a downstream pipeline'),
- linkBtnText: __('View downstream pipeline'),
- },
- components: {
- GlButton,
- },
- inject: {
- emptyStateIllustrationPath: {
- type: String,
- require: true,
- },
- },
- props: {
- downstreamPipelinePath: {
- type: String,
- required: false,
- default: undefined,
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
- <img :src="emptyStateIllustrationPath" />
- <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
- <gl-button
- v-if="downstreamPipelinePath"
- class="gl-mt-3"
- category="secondary"
- variant="confirm"
- size="medium"
- :href="downstreamPipelinePath"
- >
- {{ $options.i18n.linkBtnText }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue
deleted file mode 100644
index 3ba07cf55d1..00000000000
--- a/app/assets/javascripts/jobs/bridge/components/sidebar.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<script>
-import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } 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 { JOB_SIDEBAR } from '../../constants';
-import CommitBlock from '../../components/commit_block.vue';
-
-export default {
- styles: {
- width: '290px',
- },
- name: 'BridgeSidebar',
- i18n: {
- ...JOB_SIDEBAR,
- retryButton: __('Retry'),
- retryTriggerJob: __('Retry the trigger job'),
- retryDownstreamPipeline: __('Retry the downstream pipeline'),
- },
- sectionClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100', 'gl-py-5'],
- components: {
- CommitBlock,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- TooltipOnTruncate,
- },
- mixins: [glFeatureFlagsMixin()],
- props: {
- bridgeJob: {
- type: Object,
- required: true,
- },
- commit: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- topPosition: 0,
- };
- },
- computed: {
- rootStyle() {
- return { ...this.$options.styles, top: `${this.topPosition}px` };
- },
- },
- mounted() {
- this.setTopPosition();
- },
- methods: {
- onSidebarButtonClick() {
- this.$emit('toggleSidebar');
- },
- setTopPosition() {
- const navbarEl = document.querySelector('.js-navbar');
-
- if (navbarEl) {
- this.topPosition = navbarEl.getBoundingClientRect().bottom;
- }
- },
- },
-};
-</script>
-<template>
- <aside
- class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden"
- :style="rootStyle"
- >
- <div class="gl-py-5 gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="bridgeJob.name" truncate-target="child"
- ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate">
- {{ bridgeJob.name }}
- </h4>
- </tooltip-on-truncate>
- <!-- TODO: implement retry actions -->
- <div
- v-if="glFeatures.triggerJobRetryAction"
- class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"
- >
- <gl-dropdown
- :text="$options.i18n.retryButton"
- category="primary"
- variant="confirm"
- right
- size="medium"
- >
- <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item>
- <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item>
- </gl-dropdown>
- </div>
- <gl-button
- :aria-label="$options.i18n.toggleSidebar"
- data-testid="sidebar-expansion-toggle"
- category="tertiary"
- class="gl-md-display-none gl-ml-2"
- icon="chevron-double-lg-right"
- @click="onSidebarButtonClick"
- />
- </div>
- <commit-block :commit="commit" :class="$options.sectionClass" />
- <!-- TODO: show stage dropdown, jobs list -->
- </aside>
-</template>
diff --git a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql
deleted file mode 100644
index 338ca9f16c7..00000000000
--- a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql
+++ /dev/null
@@ -1,70 +0,0 @@
-query getPipelineData($fullPath: ID!, $iid: ID!) {
- project(fullPath: $fullPath) {
- id
- pipeline(iid: $iid) {
- id
- iid
- path
- sha
- ref
- refPath
- commit {
- id
- shortId
- title
- webPath
- }
- detailedStatus {
- id
- icon
- group
- }
- stages {
- edges {
- node {
- id
- name
- jobs {
- nodes {
- id
- createdAt
- name
- scheduledAt
- startedAt
- status
- triggered
- detailedStatus {
- id
- detailsPath
- icon
- group
- text
- tooltip
- }
- downstreamPipeline {
- id
- path
- }
- stage {
- id
- name
- }
- }
- }
- }
- }
- }
- user {
- id
- avatarUrl
- name
- username
- webPath
- webUrl
- status {
- message
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 396b015ad83..f9e6c64aad1 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -68,6 +68,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ searchResults: [],
+ };
+ },
computed: {
...mapState([
'isLoading',
@@ -184,6 +189,9 @@ export default {
this.throttled();
},
+ setSearchResults(searchResults) {
+ this.searchResults = searchResults;
+ },
},
};
</script>
@@ -279,10 +287,12 @@ export default {
:is-scroll-top-disabled="isScrollTopDisabled"
:is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
+ :job-log="jobLog"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
+ @searchResults="setSearchResults"
/>
- <log :job-log="jobLog" :is-complete="isJobLogComplete" />
+ <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" />
</div>
<!-- EO job log -->
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index eb6a284dfaf..5e89dd5acc2 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,21 +1,34 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
i18n: {
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
+ searchPlaceholder: s__('Job|Search job log'),
+ noResults: s__('Job|No search results found'),
+ searchPopoverTitle: s__('Job|Job log search'),
+ searchPopoverDescription: s__(
+ 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.',
+ ),
+ logLineNumberNotFound: s__('Job|We could not find this element'),
},
components: {
GlLink,
GlButton,
+ GlSearchBoxByClick,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
size: {
type: Number,
@@ -42,6 +55,16 @@ export default {
type: Boolean,
required: true,
},
+ jobLog: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ searchResults: [],
+ };
},
computed: {
jobLogSize() {
@@ -49,6 +72,9 @@ export default {
size: numberToHumanSize(this.size),
});
},
+ showJobLogSearch() {
+ return this.glFeatures.jobLogSearch;
+ },
},
methods: {
handleScrollToTop() {
@@ -57,6 +83,54 @@ export default {
handleScrollToBottom() {
this.$emit('scrollJobLogBottom');
},
+ searchJobLog() {
+ this.searchResults = [];
+
+ if (!this.searchTerm) return;
+
+ const compactedLog = [];
+
+ this.jobLog.forEach((obj) => {
+ if (obj.lines && obj.lines.length > 0) {
+ compactedLog.push(...obj.lines);
+ }
+
+ if (!obj.lines && obj.content.length > 0) {
+ compactedLog.push(obj);
+ }
+ });
+
+ compactedLog.forEach((line) => {
+ const lineText = line.content[0].text;
+
+ if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) {
+ this.searchResults.push(line);
+ }
+ });
+
+ if (this.searchResults.length > 0) {
+ this.$emit('searchResults', this.searchResults);
+
+ // BE returns zero based index, we need to add one to match the line numbers in the DOM
+ const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`;
+ const logLine = document.querySelector(`.js-line ${firstSearchResult}`);
+
+ if (logLine) {
+ setTimeout(() => scrollToElement(logLine));
+
+ const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), {
+ searchLength: this.searchResults.length,
+ searchTerm: this.searchTerm,
+ });
+
+ this.$toast.show(message);
+ } else {
+ this.$toast.show(this.$options.i18n.logLineNumberNotFound);
+ }
+ } else {
+ this.$toast.show(this.$options.i18n.noResults);
+ }
+ },
},
};
</script>
@@ -81,6 +155,25 @@ export default {
<!-- eo truncate information -->
<div class="controllers gl-float-right">
+ <template v-if="showJobLogSearch">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ class="gl-mr-3"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-testid="job-log-search-box"
+ @clear="$emit('searchResults', [])"
+ @submit="searchJobLog"
+ />
+
+ <help-popover class="gl-mr-3">
+ <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
+
+ <p class="gl-mb-0">
+ {{ $options.i18n.searchPopoverDescription }}
+ </p>
+ </help-popover>
+ </template>
+
<!-- links -->
<gl-button
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index 757b2e458e9..13716b4d391 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -1,6 +1,4 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
import LogLine from './line.vue';
import LogLineHeader from './line_header.vue';
@@ -9,9 +7,7 @@ export default {
components: {
LogLine,
LogLineHeader,
- CollapsibleLogSection: () => import('./collapsible_section.vue'),
},
- mixins: [glFeatureFlagsMixin()],
props: {
section: {
type: Object,
@@ -21,14 +17,16 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
badgeDuration() {
return this.section.line && this.section.line.section_duration;
},
- infinitelyCollapsibleSectionsFlag() {
- return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
- },
},
methods: {
handleOnClickCollapsibleLine(section) {
@@ -47,26 +45,13 @@ export default {
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
<template v-if="!section.isClosed">
- <template v-if="infinitelyCollapsibleSectionsFlag">
- <template v-for="line in section.lines">
- <collapsible-log-section
- v-if="line.isHeader"
- :key="line.line.offset"
- :section="line"
- :job-log-endpoint="jobLogEndpoint"
- @onClickCollapsibleLine="handleOnClickCollapsibleLine"
- />
- <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" />
- </template>
- </template>
- <template v-else>
- <log-line
- v-for="line in section.lines"
- :key="line.offset"
- :line="line"
- :path="jobLogEndpoint"
- />
- </template>
+ <log-line
+ v-for="line in section.lines"
+ :key="line.offset"
+ :line="line"
+ :path="jobLogEndpoint"
+ :search-results="searchResults"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 2d9714cd06b..36b350f4d64 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -14,9 +14,14 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
render(h, { props }) {
- const { line, path } = props;
+ const { line, path, searchResults } = props;
const chars = line.content.map((content) => {
return h(
@@ -46,15 +51,33 @@ export default {
);
});
- return h('div', { class: 'js-line log-line' }, [
- h(LineNumber, {
- props: {
- lineNumber: line.lineNumber,
- path,
- },
- }),
- ...chars,
- ]);
+ let applyHighlight = false;
+
+ if (searchResults.length > 0) {
+ const linesToHighlight = searchResults.map((searchResultLine) => searchResultLine.lineNumber);
+
+ linesToHighlight.forEach((num) => {
+ if (num === line.lineNumber) {
+ applyHighlight = true;
+ }
+ });
+ }
+
+ return h(
+ 'div',
+ {
+ class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''],
+ },
+ [
+ h(LineNumber, {
+ props: {
+ lineNumber: line.lineNumber,
+ path,
+ },
+ }),
+ ...chars,
+ ],
+ );
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index c8ceac2c7ff..7ca9154d2fe 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -1,6 +1,4 @@
<script>
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
-
export default {
functional: true,
props: {
@@ -16,9 +14,7 @@ export default {
render(h, { props }) {
const { lineNumber, path } = props;
- const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]
- ? lineNumber
- : lineNumber + 1;
+ const parsedLineNumber = lineNumber + 1;
const lineId = `L${parsedLineNumber}`;
const lineHref = `${path}#${lineId}`;
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index ef95d79b8ab..9647582b81d 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -8,6 +8,13 @@ export default {
CollapsibleLogSection,
LogLine,
},
+ props: {
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
computed: {
...mapState([
'jobLogEndpoint',
@@ -56,9 +63,16 @@ export default {
:key="`collapsible-${index}`"
:section="section"
:job-log-endpoint="jobLogEndpoint"
+ :search-results="searchResults"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" />
+ <log-line
+ v-else
+ :key="section.offset"
+ :line="section"
+ :path="jobLogEndpoint"
+ :search-results="searchResults"
+ />
</template>
<div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3">
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index cc099dba72f..a42e45ee7e4 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -89,7 +89,7 @@ export default {
<div class="blocks-container">
<div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="job.name" truncate-target="child"
- ><h4 class="my-0 mr-2 gl-text-truncate">
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
{{ job.name }}
</h4>
</tooltip-on-truncate>
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 2ba531c9e95..15c4e503685 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -42,14 +42,11 @@ export default {
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
- this.job.queued ||
+ this.job.queued_duration ||
this.job.runner ||
this.job.coverage,
);
},
- queued() {
- return timeIntervalInWords(this.job.queued);
- },
runnerHelpUrl() {
return helpPagePath('ci/runners/configure_runners.html', {
anchor: 'set-maximum-job-timeout-for-a-runner',
@@ -60,6 +57,9 @@ export default {
return `#${id} (${token}) ${description}`;
},
+ queuedDuration() {
+ return timeIntervalInWords(this.job.queued_duration);
+ },
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
},
@@ -98,7 +98,7 @@ export default {
:title="$options.i18n.FINISHED"
/>
<detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" />
- <detail-row v-if="job.queued" :value="queued" :title="$options.i18n.QUEUED" />
+ <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
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 02aeb46a22b..6f351d91165 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -222,7 +222,7 @@ export default {
/>
<gl-button
v-else-if="isRetryable"
- icon="repeat"
+ icon="retry"
:title="$options.ACTIONS_RETRY"
:aria-label="$options.ACTIONS_RETRY"
:method="currentJobMethod"
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index f3ca958b3ca..5b1032c6448 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,8 +1,8 @@
-query getJobs($fullPath: ID!, $after: String, $statuses: [CiJobStatus!]) {
+query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
__typename
- jobs(after: $after, first: 30, statuses: $statuses) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
count
pageInfo {
endCursor
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index f513d2090fa..d8c5c292f52 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -45,6 +45,7 @@ export default {
:fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
:empty-text="$options.i18n.emptyText"
+ data-testid="jobs-table"
show-empty
stacked="lg"
fixed
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 1ac1a2d68e2..b3db5a94ac5 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -2,7 +2,6 @@
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
@@ -28,7 +27,6 @@ export default {
GlIntersectionObserver,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -93,7 +91,7 @@ export default {
return this.loading && !this.showLoadingSpinner;
},
showFilteredSearch() {
- return this.glFeatures?.jobsTableVueSearch && !this.scope;
+ return !this.scope;
},
jobsCount() {
return this.jobs.count;
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
index 27e3b8028b7..68c6c669a1a 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility';
export default {
components: {
@@ -29,7 +30,7 @@ export default {
return [
{
text: s__('Jobs|All'),
- count: this.allJobsCount,
+ count: limitedCounterWithDelimiter(this.allJobsCount),
scope: null,
testId: 'jobs-all-tab',
showBadge: true,
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 97f31eee57c..3040d4e2379 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -24,5 +24,3 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
};
export const SUCCESS_STATUS = 'SUCCESS';
-
-export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections';
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 26dd38bbe08..5c63ad96ad0 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,10 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import BridgeApp from './bridge/app.vue';
import JobApp from './components/job_app.vue';
import createStore from './store';
+Vue.use(GlToast);
+
const initializeJobPage = (element) => {
const store = createStore();
@@ -51,43 +51,7 @@ const initializeJobPage = (element) => {
});
};
-const initializeBridgePage = (el) => {
- const {
- buildId,
- downstreamPipelinePath,
- emptyStateIllustrationPath,
- pipelineIid,
- projectFullPath,
- } = el.dataset;
-
- Vue.use(VueApollo);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: {
- buildId,
- downstreamPipelinePath,
- emptyStateIllustrationPath,
- pipelineIid,
- projectFullPath,
- },
- render(h) {
- return h(BridgeApp);
- },
- });
-};
-
export default () => {
const jobElement = document.getElementById('js-job-page');
- const bridgeElement = document.getElementById('js-bridge-page');
-
- if (jobElement) {
- initializeJobPage(jobElement);
- } else {
- initializeBridgePage(bridgeElement);
- }
+ initializeJobPage(jobElement);
};
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index eda2ee0349a..87c00ad4d70 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
-import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
-import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils';
+import { logLinesParser, updateIncrementalJobLog } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
@@ -21,26 +20,12 @@ export default {
},
[types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) {
- const infinitelyCollapsibleSectionsFlag =
- gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
state.jobLogState = log.state;
}
if (log.append) {
- if (infinitelyCollapsibleSectionsFlag) {
- if (log.lines) {
- const parsedResult = logLinesParser(
- log.lines,
- state.auxiliaryPartialJobLogHelpers,
- state.jobLog,
- );
- state.jobLog = parsedResult.parsedLines;
- state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
- }
- } else {
- state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
- }
+ state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
state.jobLogSize += log.size;
} else {
@@ -49,13 +34,7 @@ export default {
// html or size. We keep the old value otherwise these
// will be set to `null`
- if (infinitelyCollapsibleSectionsFlag) {
- const parsedResult = logLinesParser(log.lines);
- state.jobLog = parsedResult.parsedLines;
- state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
- } else {
- state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog;
- }
+ state.jobLog = log.lines ? logLinesParser(log.lines) : state.jobLog;
state.jobLogSize = log.size || state.jobLogSize;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index a1ba64aa71e..dfff65c364d 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -30,7 +30,4 @@ export default () => ({
selectedStage: '',
stages: [],
jobs: [],
-
- // to parse partial logs
- auxiliaryPartialJobLogHelpers: {},
});
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 7dfe24afa23..a7b95154c1b 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
-export const logLinesParserLegacy = (lines = [], accumulator = []) =>
+export const logLinesParser = (lines = [], accumulator = []) =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
@@ -131,82 +131,6 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) =>
[...accumulator],
);
-export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => {
- let currentLineCount = previousJobLogState?.prevLineCount ?? 0;
- let currentHeader = previousJobLogState?.currentHeader;
- let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false;
- const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
- const sectionsQueue = previousJobLogState?.sectionsQueue ?? [];
-
- for (let i = 0; i < lines.length; i += 1) {
- const line = lines[i];
- // First run we can use the current index, later runs we have to retrieve the last number of lines
- currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1;
-
- if (line.section_header && !isPreviousLineHeader) {
- // If there's no previous line header that means we're at the root of the log
-
- isPreviousLineHeader = true;
- parsedLines.push(parseHeaderLine(line, currentLineCount));
- currentHeader = { index: parsedLines.length - 1 };
- } else if (line.section_header && isPreviousLineHeader) {
- // If there's a current section, we can't push to the parsedLines array
- sectionsQueue.push(currentHeader);
- currentHeader = parseHeaderLine(line, currentLineCount); // Let's parse the incoming header line
- } else if (line.section && !line.section_duration) {
- // We're inside a collapsible section and want to parse a standard line
- if (currentHeader?.index) {
- // If the current section header is only an index, add the line as part of the lines
- // array of the current collapsible section
- parsedLines[currentHeader.index].lines.push(parseLine(line, currentLineCount));
- } else {
- // Otherwise add it to the innermost collapsible section lines array
- currentHeader.lines.push(parseLine(line, currentLineCount));
- }
- } else if (line.section && line.section_duration) {
- // NOTE: This marks the end of a section_header
- const previousSection = sectionsQueue.pop();
-
- // Add the duration to section header
- // If at the root, just push the end to the current parsedLine,
- // otherwise, push it to the previous sections queue
- if (currentHeader?.index) {
- parsedLines[currentHeader.index].line.section_duration = line.section_duration;
- isPreviousLineHeader = false;
- currentHeader = null;
- } else if (currentHeader?.isHeader) {
- currentHeader.line.section_duration = line.section_duration;
-
- if (previousSection && previousSection?.index) {
- // Is the previous section on root?
- parsedLines[previousSection.index].lines.push(currentHeader);
- } else if (previousSection && !previousSection?.index) {
- previousSection.lines.push(currentHeader);
- }
-
- currentHeader = previousSection;
- } else {
- // On older job logs, there's no `section_header: true` response, it's just an object
- // with the `section_duration` and `section` props, so we just parse it
- // as a standard line
- parsedLines.push(parseLine(line, currentLineCount));
- }
- } else {
- parsedLines.push(parseLine(line, currentLineCount));
- }
- }
-
- return {
- parsedLines,
- auxiliaryPartialJobLogHelpers: {
- isPreviousLineHeader,
- currentHeader,
- sectionsQueue,
- prevLineCount: currentLineCount,
- },
- };
-};
-
/**
* Finds the repeated offset, removes the old one
*
@@ -253,5 +177,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
- return logLinesParserLegacy(newLog, parsedLog);
+ return logLinesParser(newLog, parsedLog);
};
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 4959550e273..a01c6df0003 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -8,6 +8,7 @@ const defaultConfig = {
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
FORBID_TAGS: ['style', 'mstyle'],
+ ALLOW_UNKNOWN_PROTOCOLS: true,
};
// Only icons urls from `gon` are allowed
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index b4f941294de..92118c8929f 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,30 +1,34 @@
+import { pick } from 'lodash';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
-const createParser = () => {
+const skipRenderingHandlers = {
+ footnoteReference: (h, node) =>
+ h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []),
+ footnoteDefinition: (h, node) =>
+ h(
+ node.position,
+ 'footnoteDefinition',
+ { identifier: node.identifier, label: node.label },
+ all(h, node),
+ ),
+ code: (h, node) =>
+ h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [
+ { type: 'text', value: node.value },
+ ]),
+};
+
+const createParser = ({ skipRendering = [] }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
- footnoteReference: (h, node) =>
- h(
- node.position,
- 'footnoteReference',
- { identifier: node.identifier, label: node.label },
- [],
- ),
- footnoteDefinition: (h, node) =>
- h(
- node.position,
- 'footnoteDefinition',
- { identifier: node.identifier, label: node.label },
- all(h, node),
- ),
+ ...pick(skipRenderingHandlers, skipRendering),
},
})
.use(rehypeRaw);
@@ -54,8 +58,10 @@ const compilerFactory = (renderer) =>
* @returns {Promise<any>} Returns a promise with the result of rendering
* the MDast tree
*/
-export const render = async ({ markdown, renderer }) => {
- const { result } = await createParser().use(compilerFactory(renderer)).process(markdown);
+export const render = async ({ markdown, renderer, skipRendering = [] }) => {
+ const { result } = await createParser({ skipRendering })
+ .use(compilerFactory(renderer))
+ .process(markdown);
return result;
};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 1ed0cc3130b..7925a10344a 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -11,6 +11,8 @@ import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import { getLocationHash } from './url_utility';
+export const NO_SCROLL_TO_HASH_CLASS = 'js-no-scroll-to-hash';
+
export const getPagePath = (index = 0) => {
const { page = '' } = document.body.dataset;
return page.split(':')[index];
@@ -68,6 +70,10 @@ export const handleLocationHash = () => {
hash = decodeURIComponent(hash);
const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`);
+
+ // Allow targets to opt out of scroll behavior
+ if (target?.classList.contains(NO_SCROLL_TO_HASH_CLASS)) return;
+
const fixedTabs = document.querySelector('.js-tabs-affix');
const fixedDiffStats = document.querySelector('.js-diff-files-changed');
const fixedNav = document.querySelector('.navbar-gitlab');
@@ -585,8 +591,7 @@ export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
* @param {Number} precision
*/
export const roundOffFloat = (number, precision = 0) => {
- // eslint-disable-next-line no-restricted-properties
- const multiplier = Math.pow(10, precision);
+ const multiplier = 10 ** precision;
return Math.round(number * multiplier) / multiplier;
};
@@ -616,8 +621,7 @@ export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
* @param {Number} precision
*/
export const roundDownFloat = (number, precision = 0) => {
- // eslint-disable-next-line no-restricted-properties
- const multiplier = Math.pow(10, precision);
+ const multiplier = 10 ** precision;
return Math.floor(number * multiplier) / multiplier;
};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index dad9cbcb6f6..7b00995b2e5 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -498,3 +498,17 @@ export const markdownConfig = {
* escaped to `'fix-'\''bug-behavior'\'''`.
*/
export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`;
+
+/**
+ * Adds plus character as delimiter for count
+ * if count is greater than limit of 1000
+ * FE creation of `app/helpers/numbers_helper.rb`
+ *
+ * @param {Number} count
+ * @return {Number|String}
+ */
+export const limitedCounterWithDelimiter = (count) => {
+ const limit = 1000;
+
+ return count > limit ? '1,000+' : count;
+};
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
new file mode 100644
index 00000000000..244adca86c9
--- /dev/null
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default function initLinkedResources() {
+ const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root');
+
+ if (linkedResourcesRootElement) {
+ const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: linkedResourcesRootElement,
+ name: 'LinkedResourcesRoot',
+ components: {
+ resourceLinksBlock: ResourceLinksBlock,
+ },
+ render: (createElement) =>
+ createElement('resource-links-block', {
+ props: {
+ issuableId,
+ helpPath,
+ canAddResourceLinks: parseBoolean(canAddResourceLinks),
+ },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
deleted file mode 100644
index 609592edc3b..00000000000
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ /dev/null
@@ -1,280 +0,0 @@
-<script>
-import {
- GlSprintf,
- GlAlert,
- GlLink,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlInfiniteScroll,
-} from '@gitlab/ui';
-import { throttle } from 'lodash';
-import { mapActions, mapState, mapGetters } from 'vuex';
-
-import { timeRangeFromUrl } from '~/monitoring/utils';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import { formatDate } from '../utils';
-import LogAdvancedFilters from './log_advanced_filters.vue';
-import LogControlButtons from './log_control_buttons.vue';
-import LogSimpleFilters from './log_simple_filters.vue';
-
-export default {
- components: {
- GlSprintf,
- GlLink,
- GlAlert,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlInfiniteScroll,
- LogSimpleFilters,
- LogAdvancedFilters,
- LogControlButtons,
- },
- props: {
- environmentName: {
- type: String,
- required: false,
- default: '',
- },
- currentPodName: {
- type: [String, null],
- required: false,
- default: null,
- },
- environmentsPath: {
- type: String,
- required: false,
- default: '',
- },
- clusterApplicationsDocumentationPath: {
- type: String,
- required: true,
- },
- clustersPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isElasticStackCalloutDismissed: false,
- scrollDownButtonDisabled: true,
- isDeprecationNoticeDismissed: false,
- };
- },
- computed: {
- ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
- ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']),
-
- showLoader() {
- return this.logs.isLoading;
- },
- shouldShowElasticStackCallout() {
- return !(
- this.environments.isLoading ||
- this.isElasticStackCalloutDismissed ||
- this.showAdvancedFilters
- );
- },
- },
- mounted() {
- this.setInitData({
- timeRange: timeRangeFromUrl() || defaultTimeRange,
- environmentName: this.environmentName,
- podName: this.currentPodName,
- });
-
- this.fetchEnvironments(this.environmentsPath);
- },
- methods: {
- ...mapActions('environmentLogs', [
- 'setInitData',
- 'showEnvironment',
- 'fetchEnvironments',
- 'refreshPodLogs',
- 'fetchMoreLogsPrepend',
- 'dismissRequestEnvironmentsError',
- 'dismissInvalidTimeRangeWarning',
- 'dismissRequestLogsError',
- ]),
-
- isCurrentEnvironment(envName) {
- return envName === this.environments.current;
- },
- topReached() {
- if (!this.logs.isLoading) {
- this.fetchMoreLogsPrepend();
- }
- },
- scrollDown() {
- this.$refs.infiniteScroll.scrollDown();
- },
- scroll: throttle(function scrollThrottled({ target = {} }) {
- const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
- this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
- }, 200),
- formatDate,
- },
-};
-</script>
-<template>
- <div class="environment-logs-viewer d-flex flex-column py-3">
- <gl-alert
- v-if="shouldShowElasticStackCallout"
- ref="elasticsearchNotice"
- class="mb-3"
- @dismiss="isElasticStackCalloutDismissed = true"
- >
- {{
- s__(
- 'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
- )
- }}
- <a :href="clusterApplicationsDocumentationPath">
- <strong>
- {{ __('View Documentation') }}
- </strong>
- </a>
- </gl-alert>
- <gl-alert
- v-if="environments.fetchError"
- class="mb-3"
- variant="danger"
- @dismiss="dismissRequestEnvironmentsError"
- >
- {{ s__('Metrics|There was an error fetching the environments data, please try again') }}
- </gl-alert>
- <gl-alert
- v-if="timeRange.invalidWarning"
- class="mb-3"
- variant="warning"
- @dismiss="dismissInvalidTimeRangeWarning"
- >
- {{ 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 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"
- @dismiss="dismissRequestLogsError"
- >
- {{ s__('Environments|There was an error fetching the logs. Please try again.') }}
- </gl-alert>
-
- <div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2">
- <div class="flex-grow-0">
- <gl-dropdown
- id="environments-dropdown"
- :text="environments.current"
- :disabled="environments.isLoading"
- class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown"
- >
- <gl-dropdown-section-header>
- {{ s__('Environments|Environments') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="env in environments.options"
- :key="env.id"
- :is-check-item="true"
- :is-checked="isCurrentEnvironment(env.name)"
- @click="showEnvironment(env.name)"
- >
- {{ env.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-
- <log-advanced-filters
- v-if="showAdvancedFilters"
- ref="log-advanced-filters"
- class="d-md-flex flex-grow-1 min-width-0"
- :disabled="environments.isLoading"
- />
- <log-simple-filters
- v-else
- ref="log-simple-filters"
- class="d-md-flex flex-grow-1 min-width-0"
- :disabled="environments.isLoading"
- />
-
- <log-control-buttons
- ref="scrollButtons"
- class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex"
- :scroll-down-button-disabled="scrollDownButtonDisabled"
- @refresh="refreshPodLogs()"
- @scrollDown="scrollDown"
- />
- </div>
-
- <gl-infinite-scroll
- ref="infiniteScroll"
- class="log-lines overflow-auto flex-grow-1 min-height-0"
- :fetched-items="logs.lines.length"
- @topReached="topReached"
- @scroll="scroll"
- >
- <template #items>
- <pre
- ref="logTrace"
- class="build-log"
- ><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
- <div class="dot"></div>
- <div class="dot"></div>
- <div class="dot"></div>
- </div>{{trace}}
- </code></pre>
- </template>
- <template #default><div></div></template>
- </gl-infinite-scroll>
-
- <div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900">
- <gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
- <template #start>{{ formatDate(timeRange.current.start) }}</template>
- <template #end>{{ formatDate(timeRange.current.end) }}</template>
- </gl-sprintf>
- <gl-sprintf
- v-if="!logs.isComplete"
- :message="s__('Environments|Currently showing %{fetched} results.')"
- >
- <template #fetched>{{ logs.lines.length }}</template>
- </gl-sprintf>
- <template v-else> {{ s__('Environments|Currently showing all results.') }}</template>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue
deleted file mode 100644
index c6d7c9ad1dc..00000000000
--- a/app/assets/javascripts/logs/components/log_advanced_filters.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-<script>
-import { GlFilteredSearch } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
-import { timeRanges } from '~/vue_shared/constants';
-import { TOKEN_TYPE_POD_NAME } from '../constants';
-import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
-
-export default {
- components: {
- GlFilteredSearch,
- DateTimePicker,
- },
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- timeRanges,
- };
- },
- computed: {
- ...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
-
- timeRangeModel: {
- get() {
- return this.timeRange.selected;
- },
- set(val) {
- this.setTimeRange(val);
- },
- },
- /**
- * Token options.
- *
- * Returns null when no pods are present, so suggestions are displayed in the token
- */
- podOptions() {
- if (this.pods.options.length) {
- return this.pods.options.map((podName) => ({ value: podName, title: podName }));
- }
- return null;
- },
-
- tokens() {
- return [
- {
- icon: 'pod',
- type: TOKEN_TYPE_POD_NAME,
- title: s__('Environments|Pod name'),
- token: TokenWithLoadingState,
- operators: OPERATOR_IS_ONLY,
- unique: true,
- options: this.podOptions,
- loading: this.logs.isLoading,
- noOptionsText: s__('Environments|No pods to display'),
- },
- ];
- },
- },
- methods: {
- ...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
-
- filteredSearchSubmit(filters) {
- this.showFilteredLogs(filters);
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="mb-2 pr-2 flex-grow-1 min-width-0">
- <gl-filtered-search
- :placeholder="__('Search')"
- :clear-button-title="__('Clear')"
- :close-button-title="__('Close')"
- class="gl-h-32"
- :disabled="disabled || logs.isLoading"
- :available-tokens="tokens"
- @submit="filteredSearchSubmit"
- />
- </div>
-
- <date-time-picker
- ref="dateTimePicker"
- v-model="timeRangeModel"
- :disabled="disabled"
- :options="timeRanges"
- class="mb-2 gl-h-32 pr-2 d-block date-time-picker-wrapper"
- right
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue
deleted file mode 100644
index e44b5394fa1..00000000000
--- a/app/assets/javascripts/logs/components/log_control_buttons.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- scrollUpButtonDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- scrollDownButtonDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- scrollUpAvailable: Boolean(this.$listeners.scrollUp),
- scrollDownAvailable: Boolean(this.$listeners.scrollDown),
- };
- },
- methods: {
- handleRefreshClick() {
- this.$emit('refresh');
- },
- handleScrollUp() {
- this.$emit('scrollUp');
- },
- handleScrollDown() {
- this.$emit('scrollDown');
- },
- },
-};
-</script>
-
-<template>
- <div>
- <div
- v-if="scrollUpAvailable"
- v-gl-tooltip
- class="controllers-buttons"
- :title="__('Scroll to top')"
- aria-labelledby="scroll-to-top"
- >
- <gl-button
- id="scroll-to-top"
- class="js-scroll-to-top gl-mr-2 btn-blank"
- :aria-label="__('Scroll to top')"
- :disabled="scrollUpButtonDisabled"
- icon="scroll_up"
- category="primary"
- variant="default"
- @click="handleScrollUp()"
- />
- </div>
- <div
- v-if="scrollDownAvailable"
- v-gl-tooltip
- :disabled="scrollUpButtonDisabled"
- class="controllers-buttons"
- :title="__('Scroll to bottom')"
- aria-labelledby="scroll-to-bottom"
- >
- <gl-button
- id="scroll-to-bottom"
- class="js-scroll-to-bottom gl-mr-2 btn-blank"
- :aria-label="__('Scroll to bottom')"
- :v-if="scrollDownAvailable"
- :disabled="scrollDownButtonDisabled"
- icon="scroll_down"
- category="primary"
- variant="default"
- @click="handleScrollDown()"
- />
- </div>
- <gl-button
- id="refresh-log"
- v-gl-tooltip
- class="js-refresh-log"
- :title="__('Refresh')"
- :aria-label="__('Refresh')"
- icon="retry"
- category="primary"
- variant="default"
- @click="handleRefreshClick"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue
deleted file mode 100644
index 55bdd5f0088..00000000000
--- a/app/assets/javascripts/logs/components/log_simple_filters.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- },
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- searchQuery: '',
- };
- },
- computed: {
- ...mapState('environmentLogs', ['pods']),
-
- podDropdownText() {
- return this.pods.current || s__('Environments|No pod selected');
- },
- },
- methods: {
- ...mapActions('environmentLogs', ['showPodLogs']),
- isCurrentPod(podName) {
- return podName === this.pods.current;
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-dropdown
- ref="podsDropdown"
- :text="podDropdownText"
- :disabled="disabled"
- class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block qa-pods-dropdown"
- >
- <gl-dropdown-section-header>
- {{ s__('Environments|Select pod') }}
- </gl-dropdown-section-header>
-
- <gl-dropdown-item v-if="!pods.options.length" disabled>
- <span ref="noPodsMsg" class="text-muted">
- {{ s__('Environments|No pods to display') }}
- </span>
- </gl-dropdown-item>
- <gl-dropdown-item
- v-for="podName in pods.options"
- :key="podName"
- :is-check-item="true"
- :is-checked="isCurrentPod(podName)"
- class="text-nowrap"
- @click="showPodLogs(podName)"
- >
- {{ podName }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
deleted file mode 100644
index 4e672c1d121..00000000000
--- a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<script>
-import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlFilteredSearchToken,
- GlLoadingIcon,
- },
- inheritAttrs: false,
- props: {
- config: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners">
- <template #suggestions>
- <div class="m-1">
- <gl-loading-icon v-if="config.loading" size="sm" />
- <div v-else class="py-1 px-2 text-muted">
- {{ config.noOptionsText }}
- </div>
- </div>
- </template>
- </gl-filtered-search-token>
-</template>
diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js
deleted file mode 100644
index abc4d6679a0..00000000000
--- a/app/assets/javascripts/logs/constants.js
+++ /dev/null
@@ -1,16 +0,0 @@
-export const dateFormatMask = 'mmm dd HH:MM:ss.l';
-
-export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
-
-export const tracking = {
- USED_SEARCH_BAR: 'used_search_bar',
- POD_LOG_CHANGED: 'pod_log_changed',
- TIME_RANGE_SET: 'time_range_set',
- ENVIRONMENT_SELECTED: 'environment_selected',
- REFRESH_POD_LOGS: 'refresh_pod_logs',
- MANAGED_APP_SELECTED: 'managed_app_selected',
-};
-
-export const logExplorerOptions = {
- environments: 'environments',
-};
diff --git a/app/assets/javascripts/logs/index.js b/app/assets/javascripts/logs/index.js
deleted file mode 100644
index 70dbffdc3dd..00000000000
--- a/app/assets/javascripts/logs/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import { getParameterValues } from '~/lib/utils/url_utility';
-import LogViewer from './components/environment_logs.vue';
-import store from './stores';
-
-export default (props = {}) => {
- const el = document.getElementById('environment-logs');
- const [currentPodName] = getParameterValues('pod_name');
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- render(createElement) {
- return createElement(LogViewer, {
- props: {
- ...el.dataset,
- currentPodName,
- ...props,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/logs/logs_tracking_helper.js b/app/assets/javascripts/logs/logs_tracking_helper.js
deleted file mode 100644
index 26043d646b0..00000000000
--- a/app/assets/javascripts/logs/logs_tracking_helper.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Tracking from '~/tracking';
-
-/**
- * The value of 1 in count, means there was one action performed
- * related to the tracked action, in either of the following categories
- * 1. Refreshing the logs
- * 2. Select an environment
- * 3. Change the time range
- * 4. Use the search bar
- */
-const trackLogs = (label) =>
- Tracking.event(document.body.dataset.page, 'logs_view', {
- label,
- property: 'count',
- value: 1,
- });
-
-export default trackLogs;
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
deleted file mode 100644
index 56b832de9b8..00000000000
--- a/app/assets/javascripts/logs/stores/actions.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import { backOff } from '~/lib/utils/common_utils';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import httpStatusCodes from '~/lib/utils/http_status';
-import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants';
-import trackLogs from '../logs_tracking_helper';
-
-import * as types from './mutation_types';
-
-const requestUntilData = (url, params) =>
- backOff((next, stop) => {
- axios
- .get(url, { params })
- .then((res) => {
- if (res.status === httpStatusCodes.ACCEPTED) {
- next();
- return;
- }
- stop(res);
- })
- .catch((err) => {
- stop(err);
- });
- });
-
-const requestLogsUntilData = ({ commit, state }) => {
- const params = {};
- const type = logExplorerOptions.environments;
- const selectedObj = state[type].options.find(({ name }) => name === state[type].current);
- const path = selectedObj.logs_api_path;
-
- if (state.pods.current) {
- params.pod_name = state.pods.current;
- }
- if (state.search) {
- params.search = state.search;
- }
- if (state.timeRange.current) {
- try {
- const { start, end } = convertToFixedRange(state.timeRange.current);
- params.start_time = start;
- params.end_time = end;
- } catch {
- commit(types.SHOW_TIME_RANGE_INVALID_WARNING);
- }
- }
- if (state.logs.cursor) {
- params.cursor = state.logs.cursor;
- }
-
- return requestUntilData(path, params);
-};
-
-/**
- * Converts filters emitted by the component, e.g. a filterered-search
- * to parameters to be applied to the filters of the store
- * @param {Array} filters - List of strings or objects to filter by.
- * @returns {Object} - An object with `search` and `podName` keys.
- */
-const filtersToParams = (filters = []) => {
- // Strings become part of the `search`
- const search = filters
- .filter((f) => typeof f === 'string')
- .join(' ')
- .trim();
-
- // null podName to show all pods
- const podName = filters.find((f) => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
-
- return { search, podName };
-};
-
-export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
- commit(types.SET_TIME_RANGE, timeRange);
- commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
- commit(types.SET_CURRENT_POD_NAME, podName);
-};
-
-export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
- const { podName, search } = filtersToParams(filters);
-
- commit(types.SET_CURRENT_POD_NAME, podName);
- commit(types.SET_SEARCH, search);
-
- dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
-};
-
-export const showPodLogs = ({ dispatch, commit }, podName) => {
- commit(types.SET_CURRENT_POD_NAME, podName);
- dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
-};
-
-export const setTimeRange = ({ dispatch, commit }, timeRange) => {
- commit(types.SET_TIME_RANGE, timeRange);
- dispatch('fetchLogs', tracking.TIME_RANGE_SET);
-};
-
-export const showEnvironment = ({ dispatch, commit }, environmentName) => {
- commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
- dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
-};
-
-export const refreshPodLogs = ({ dispatch, commit }) => {
- commit(types.REFRESH_POD_LOGS);
- dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
-};
-
-/**
- * Fetch environments data and initial logs
- * @param {Object} store
- * @param {String} environmentsPath
- */
-export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
- commit(types.REQUEST_ENVIRONMENTS_DATA);
-
- return axios
- .get(environmentsPath)
- .then(({ data }) => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
- dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
- })
- .catch(() => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
- });
-};
-
-export const fetchLogs = ({ commit, state }, trackingLabel) => {
- commit(types.REQUEST_LOGS_DATA);
-
- return requestLogsUntilData({ commit, state })
- .then(({ data }) => {
- const { pod_name, pods, logs, cursor } = data;
- if (logs && logs.length > 0) {
- trackLogs(trackingLabel);
- }
- commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
- commit(types.SET_CURRENT_POD_NAME, pod_name);
- commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
- })
- .catch(() => {
- commit(types.RECEIVE_PODS_DATA_ERROR);
- commit(types.RECEIVE_LOGS_DATA_ERROR);
- });
-};
-
-export const fetchMoreLogsPrepend = ({ commit, state }) => {
- if (state.logs.isComplete) {
- // return when all logs are loaded
- return Promise.resolve();
- }
-
- commit(types.REQUEST_LOGS_DATA_PREPEND);
-
- return requestLogsUntilData({ commit, state })
- .then(({ data }) => {
- const { logs, cursor } = data;
- commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
- })
- .catch(() => {
- commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
- });
-};
-
-export const dismissRequestEnvironmentsError = ({ commit }) => {
- commit(types.HIDE_REQUEST_ENVIRONMENTS_ERROR);
-};
-
-export const dismissRequestLogsError = ({ commit }) => {
- commit(types.HIDE_REQUEST_LOGS_ERROR);
-};
-
-export const dismissInvalidTimeRangeWarning = ({ commit }) => {
- commit(types.HIDE_TIME_RANGE_INVALID_WARNING);
-};
diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js
deleted file mode 100644
index bf71cfd8eb2..00000000000
--- a/app/assets/javascripts/logs/stores/getters.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { formatDate } from '../utils';
-
-const mapTrace = ({ timestamp = null, pod = '', message = '' }) =>
- [timestamp ? formatDate(timestamp) : '', pod, message].join(' | ');
-
-export const trace = (state) => state.logs.lines.map(mapTrace).join('\n');
-
-export const showAdvancedFilters = (state) => {
- const environment = state.environments.options.find(
- ({ name }) => name === state.environments.current,
- );
-
- return Boolean(environment?.enable_advanced_logs_querying);
-};
diff --git a/app/assets/javascripts/logs/stores/index.js b/app/assets/javascripts/logs/stores/index.js
deleted file mode 100644
index d16941ddf93..00000000000
--- a/app/assets/javascripts/logs/stores/index.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const createStore = () =>
- new Vuex.Store({
- modules: {
- environmentLogs: {
- namespaced: true,
- actions,
- mutations,
- state: state(),
- getters,
- },
- },
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js
deleted file mode 100644
index c1ed65ff48b..00000000000
--- a/app/assets/javascripts/logs/stores/mutation_types.js
+++ /dev/null
@@ -1,26 +0,0 @@
-export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
-export const SET_SEARCH = 'SET_SEARCH';
-export const SET_MANAGED_APP = 'SET_MANAGED_APP';
-
-export const SET_TIME_RANGE = 'SET_TIME_RANGE';
-export const SHOW_TIME_RANGE_INVALID_WARNING = 'SHOW_TIME_RANGE_INVALID_WARNING';
-export const HIDE_TIME_RANGE_INVALID_WARNING = 'HIDE_TIME_RANGE_INVALID_WARNING';
-
-export const SET_CURRENT_POD_NAME = 'SET_CURRENT_POD_NAME';
-
-export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
-export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
-export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR';
-export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR';
-
-export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
-export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
-export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
-export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
-export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
-export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
-export const HIDE_REQUEST_LOGS_ERROR = 'HIDE_REQUEST_LOGS_ERROR';
-export const REFRESH_POD_LOGS = 'REFRESH_POD_LOGS';
-
-export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
-export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR';
diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js
deleted file mode 100644
index 6736d7204b4..00000000000
--- a/app/assets/javascripts/logs/stores/mutations.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import * as types from './mutation_types';
-
-const mapLine = ({ timestamp, pod, message }) => ({
- timestamp,
- pod,
- message,
-});
-
-export default {
- // Search Data
- [types.SET_SEARCH](state, searchQuery) {
- state.search = searchQuery;
- },
-
- // Time Range Data
- [types.SET_TIME_RANGE](state, timeRange) {
- state.timeRange.selected = timeRange;
- state.timeRange.current = convertToFixedRange(timeRange);
- },
- [types.SHOW_TIME_RANGE_INVALID_WARNING](state) {
- state.timeRange.invalidWarning = true;
- },
- [types.HIDE_TIME_RANGE_INVALID_WARNING](state) {
- state.timeRange.invalidWarning = false;
- },
-
- // Environments Data
- [types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
- state.environments.current = environmentName;
-
- // Clear current pod options
- state.pods.current = null;
- state.pods.options = [];
- },
- [types.REQUEST_ENVIRONMENTS_DATA](state) {
- state.environments.options = [];
- state.environments.isLoading = true;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environmentOptions) {
- state.environments.options = environmentOptions;
- state.environments.isLoading = false;
- },
- [types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state) {
- state.environments.options = [];
- state.environments.isLoading = false;
- state.environments.fetchError = true;
- },
- [types.HIDE_REQUEST_ENVIRONMENTS_ERROR](state) {
- state.environments.fetchError = false;
- },
-
- // Logs data
- [types.REQUEST_LOGS_DATA](state) {
- state.timeRange.current = convertToFixedRange(state.timeRange.selected);
-
- state.logs.lines = [];
- state.logs.isLoading = true;
-
- // start pagination from the beginning
- state.logs.cursor = null;
- state.logs.isComplete = false;
- },
- [types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
- state.logs.lines = logs.map(mapLine);
- state.logs.isLoading = false;
- state.logs.cursor = cursor;
-
- if (!cursor) {
- state.logs.isComplete = true;
- }
- },
- [types.RECEIVE_LOGS_DATA_ERROR](state) {
- state.logs.lines = [];
- state.logs.isLoading = false;
- state.logs.fetchError = true;
- },
-
- [types.REQUEST_LOGS_DATA_PREPEND](state) {
- state.logs.isLoading = true;
- },
- [types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
- const lines = logs.map(mapLine);
- state.logs.lines = lines.concat(state.logs.lines);
- state.logs.isLoading = false;
- state.logs.cursor = cursor;
-
- if (!cursor) {
- state.logs.isComplete = true;
- }
- },
- [types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
- state.logs.isLoading = false;
- state.logs.fetchError = true;
- },
- [types.HIDE_REQUEST_LOGS_ERROR](state) {
- state.logs.fetchError = false;
- },
-
- // Pods data
- [types.SET_CURRENT_POD_NAME](state, podName) {
- state.pods.current = podName;
- },
- [types.RECEIVE_PODS_DATA_SUCCESS](state, podOptions) {
- state.pods.options = podOptions;
- },
- [types.RECEIVE_PODS_DATA_ERROR](state) {
- state.pods.options = [];
- },
-};
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
deleted file mode 100644
index ee17e8ecef2..00000000000
--- a/app/assets/javascripts/logs/stores/state.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { timeRanges, defaultTimeRange } from '~/vue_shared/constants';
-
-export default () => ({
- /**
- * Full text search
- */
- search: '',
-
- /**
- * Time range (Show last)
- */
- timeRange: {
- options: timeRanges,
- // Selected time range, can be fixed or relative
- selected: defaultTimeRange,
- // Current time range, must be fixed
- current: convertToFixedRange(defaultTimeRange),
-
- invalidWarning: false,
- },
-
- /**
- * Environments list information
- */
- environments: {
- options: [],
- isLoading: false,
- current: null,
- fetchError: false,
- },
-
- /**
- * Jobs with logs
- */
- logs: {
- lines: [],
- isLoading: false,
- /**
- * Logs `cursor` represents the current pagination position,
- * Should be sent in next batch (page) of logs to be fetched
- */
- cursor: null,
- isComplete: false,
-
- fetchError: false,
- },
-
- /**
- * Pods list information
- */
- pods: {
- options: [],
- current: null,
- },
-});
diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js
deleted file mode 100644
index 74c2f8a68f8..00000000000
--- a/app/assets/javascripts/logs/utils.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import dateFormat from 'dateformat';
-import { dateFormatMask } from './constants';
-
-export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index e3e8efdd771..349a28ace52 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -36,6 +36,7 @@ import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import { initCopyCodeButton } from './behaviors/copy_code';
+import initHeaderSearch from './header_search/init';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
@@ -53,7 +54,7 @@ window.gl = window.gl || {};
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
- import(/* webpackMode: "eager" */ './test_utils/');
+ import(/* webpackMode: "eager" */ './test_utils');
}
document.addEventListener('beforeunload', () => {
@@ -115,34 +116,6 @@ function deferredInitialisation() {
);
}
- const searchInputBox = document.querySelector('#search');
- if (searchInputBox) {
- searchInputBox.addEventListener(
- 'focus',
- () => {
- if (gon.features?.newHeaderSearch) {
- import(/* webpackChunkName: 'globalSearch' */ '~/header_search')
- .then(async ({ initHeaderSearchApp }) => {
- // In case the user started searching before we bootstrapped, let's pass the search along.
- const initialSearchValue = searchInputBox.value;
- await initHeaderSearchApp(initialSearchValue);
- // this is new #search input element. We need to re-find it.
- document.querySelector('#search').focus();
- })
- .catch(() => {});
- } else {
- import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete')
- .then(({ default: initSearchAutocomplete }) => {
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
- })
- .catch(() => {});
- }
- },
- { once: true },
- );
- }
-
addSelectOnFocusBehaviour('.js-select-on-focus');
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
@@ -169,6 +142,11 @@ function deferredInitialisation() {
}
}
+// header search vue component bootstrap
+// loading this inside requestIdleCallback is causing issues
+// see https://gitlab.com/gitlab-org/gitlab/-/issues/365746
+initHeaderSearch();
+
const $body = $('body');
const $document = $(document);
const bootstrapBreakpoint = bp.getBreakpointSize();
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index 98995730df4..b824a013f3b 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -1,40 +1,48 @@
<script>
import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants';
+import { queryToObject } from '~/lib/utils/url_utility';
+import {
+ MEMBER_TYPES,
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+ TAB_QUERY_PARAM_VALUES,
+ EE_TABS,
+} from 'ee_else_ce/members/constants';
import MembersApp from './app.vue';
const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0;
+export const TABS = [
+ {
+ namespace: MEMBER_TYPES.user,
+ title: __('Members'),
+ },
+ {
+ namespace: MEMBER_TYPES.group,
+ title: __('Groups'),
+ attrs: { 'data-qa-selector': 'groups_list_tab' },
+ queryParamValue: TAB_QUERY_PARAM_VALUES.group,
+ },
+ {
+ namespace: MEMBER_TYPES.invite,
+ title: __('Invited'),
+ canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
+ },
+ {
+ namespace: MEMBER_TYPES.accessRequest,
+ title: __('Access requests'),
+ canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
+ },
+ ...EE_TABS,
+];
+
export default {
name: 'MembersTabs',
ACTIVE_TAB_QUERY_PARAM_NAME,
- TABS: [
- {
- namespace: MEMBER_TYPES.user,
- title: __('Members'),
- },
- {
- namespace: MEMBER_TYPES.group,
- title: __('Groups'),
- attrs: { 'data-qa-selector': 'groups_list_tab' },
- queryParamValue: TAB_QUERY_PARAM_VALUES.group,
- },
- {
- namespace: MEMBER_TYPES.invite,
- title: __('Invited'),
- canManageMembersPermissionsRequired: true,
- queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
- },
- {
- namespace: MEMBER_TYPES.accessRequest,
- title: __('Access requests'),
- canManageMembersPermissionsRequired: true,
- queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
- },
- ],
+ TABS,
components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
data() {
@@ -43,20 +51,17 @@ export default {
};
},
computed: {
- ...mapState({
- userCount(state) {
- return countComputed(state, MEMBER_TYPES.user);
- },
- groupCount(state) {
- return countComputed(state, MEMBER_TYPES.group);
- },
- inviteCount(state) {
- return countComputed(state, MEMBER_TYPES.invite);
- },
- accessRequestCount(state) {
- return countComputed(state, MEMBER_TYPES.accessRequest);
- },
- }),
+ ...mapState(
+ Object.values(MEMBER_TYPES).reduce((getters, memberType) => {
+ return {
+ ...getters,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ [`${memberType}Count`](state) {
+ return countComputed(state, memberType);
+ },
+ };
+ }, {}),
+ ),
urlParams() {
return Object.keys(queryToObject(window.location.search, { gatherArrays: true }));
},
diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue
index 92b757ffcba..966eb90e402 100644
--- a/app/assets/javascripts/members/components/table/member_avatar.vue
+++ b/app/assets/javascripts/members/components/table/member_avatar.vue
@@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue';
export default {
name: 'MemberAvatar',
- components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
+ components: {
+ UserAvatar,
+ InviteAvatar,
+ GroupAvatar,
+ AccessRequestAvatar: UserAvatar,
+ BannedAvatar: UserAvatar,
+ },
props: {
memberType: {
type: String,
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 3436bcab2fc..51eff428d63 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -1,5 +1,5 @@
<script>
-import { MEMBER_TYPES } from '../../constants';
+import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import {
isGroup,
isDirectMember,
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 8c40cc3f29d..2fe816c7ea2 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+// Overridden in EE
+export const EE_APP_OPTIONS = {};
+
+// Overridden in EE
+export const EE_TABS = [];
+
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
export const FIELD_KEY_GRANTED = 'granted';
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 0df876cabd7..34660f8f499 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { parseDataAttributes } from '~/members/utils';
+import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import MembersTabs from './components/members_tabs.vue';
-import { MEMBER_TYPES } from './constants';
import membersStore from './store';
export const initMembersApp = (el, options) => {
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 94041d77bb0..ed2e6a5af58 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -177,7 +177,7 @@ export default class MergeRequestTabs {
this.peek = document.getElementById('js-peek');
this.sidebar = document.querySelector('.js-right-sidebar');
this.pageLayout = document.querySelector('.layout-page');
- this.expandSidebar = document.querySelector('.js-expand-sidebar');
+ this.expandSidebar = document.querySelectorAll('.js-expand-sidebar, .js-sidebar-toggle');
this.paddingTop = 16;
this.scrollPositions = {};
@@ -282,7 +282,11 @@ export default class MergeRequestTabs {
const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`);
if (tab) tab.classList.add('active');
- this.expandSidebar?.classList.toggle('gl-display-none!', action !== 'show');
+ if (window.gon?.features?.movedMrSidebar) {
+ this.expandSidebar?.forEach((el) =>
+ el.classList.toggle('gl-display-none!', action !== 'show'),
+ );
+ }
if (action === 'commits') {
this.loadCommits(href);
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 34f9fe778ea..3a13c123d77 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { GlSprintf, GlModal } from '@gitlab/ui';
+import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
@@ -10,9 +10,7 @@ import eventHub from '../event_hub';
export default {
components: {
GlModal,
- },
- directives: {
- SafeHtml,
+ GlSprintf,
},
props: {
issueCount: {
@@ -38,20 +36,10 @@ export default {
},
computed: {
text() {
- const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', {
- milestoneTitle: this.milestoneTitle,
- });
-
if (this.issueCount === 0 && this.mergeRequestCount === 0) {
- return sprintf(
- s__(`Milestones|
+ return s__(`Milestones|
You’re about to permanently delete the milestone %{milestoneTitle}.
-This milestone is not currently used in any issues or merge requests.`),
- {
- milestoneTitle,
- },
- false,
- );
+This milestone is not currently used in any issues or merge requests.`);
}
return sprintf(
@@ -59,7 +47,6 @@ This milestone is not currently used in any issues or merge requests.`),
You’re about to permanently delete the milestone %{milestoneTitle} and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
Once deleted, it cannot be undone or recovered.`),
{
- milestoneTitle,
issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
mergeRequestsWithCount: n__(
'%d merge request',
@@ -98,13 +85,13 @@ Once deleted, it cannot be undone or recovered.`),
});
if (error.response && error.response.status === 404) {
- createFlash({
+ createAlert({
message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
milestoneTitle: this.milestoneTitle,
}),
});
} else {
- createFlash({
+ createAlert({
message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), {
milestoneTitle: this.milestoneTitle,
}),
@@ -132,6 +119,10 @@ Once deleted, it cannot be undone or recovered.`),
:action-cancel="$options.cancelProps"
@primary="onSubmit"
>
- <p v-safe-html="text"></p>
+ <gl-sprintf :message="text">
+ <template #milestoneTitle>
+ <strong>{{ milestoneTitle }}</strong>
+ </template>
+ </gl-sprintf>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js
index 05102f73f92..8f2721c2a5b 100644
--- a/app/assets/javascripts/milestones/milestone.js
+++ b/app/assets/javascripts/milestones/milestone.js
@@ -1,33 +1,27 @@
import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
-import { historyPushState } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
+import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs';
export default class Milestone {
constructor() {
this.tabsEl = document.querySelector('.js-milestone-tabs');
- this.glTabs = new GlTabsBehavior(this.tabsEl);
this.loadedTabs = new WeakSet();
this.bindTabsSwitching();
- this.loadInitialTab();
+ // eslint-disable-next-line no-new
+ new GlTabsBehavior(this.tabsEl, { history: HISTORY_TYPE_HASH });
}
bindTabsSwitching() {
this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => {
const tab = event.target;
const { activeTabPanel } = event.detail;
- historyPushState(tab.getAttribute('href'));
this.loadTab(tab, activeTabPanel);
});
}
- loadInitialTab() {
- const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`);
- this.glTabs.activateTab(tab || this.glTabs.activeTab);
- }
loadTab(tab, tabPanel) {
const { endpoint } = tab.dataset;
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index e375435436e..eb7c43034a4 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -163,7 +163,7 @@ export default class SSHMirror {
const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list');
let fingerprints = '';
sshHostKeys.fingerprints.forEach((fingerprint) => {
- const escFingerprints = escape(fingerprint.fingerprint);
+ const escFingerprints = escape(fingerprint.fingerprint_sha256 || fingerprint.fingerprint);
fingerprints += `<code>${escFingerprints}</code>`;
});
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 70e253508ce..250d4b3c55f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -358,10 +358,6 @@ export default {
actionToRun = 'onExpandFromKeyboardShortcut';
break;
- case keyboardShortcutKeys.VISIT_LOGS:
- actionToRun = 'visitLogsPageFromKeyboardShortcut';
- break;
-
case keyboardShortcutKeys.SHOW_ALERT:
actionToRun = 'showAlertModalFromKeyboardShortcut';
break;
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index f18290e7048..3338635bf96 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -170,7 +170,7 @@ export default {
<template>
<div ref="prometheusGraphsHeader">
- <div class="mb-2 mr-2 d-flex d-sm-block">
+ <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block">
<dashboards-dropdown
id="monitor-dashboards-dropdown"
data-qa-selector="dashboards_filter_dropdown"
@@ -240,7 +240,7 @@ export default {
<div class="flex-grow-1"></div>
<div class="d-sm-flex">
- <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
+ <div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex">
<gl-button
:pressed="isRearrangingPanels"
variant="default"
@@ -253,7 +253,7 @@ export default {
<div
v-if="externalDashboardUrl && externalDashboardUrl.length"
- class="mb-2 mr-2 d-flex d-sm-block"
+ class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
>
<gl-button
class="flex-grow-1 js-external-dashboard-link"
@@ -280,7 +280,7 @@ export default {
<template v-if="shouldShowSettingsButton">
<span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
- <div class="mb-2 mr-2 d-flex d-sm-block">
+ <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
<gl-button
v-gl-tooltip
data-testid="metrics-settings-button"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index ff8ccded83b..7e7dcef7639 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -15,15 +15,13 @@ import {
} from '@gitlab/ui';
import { mapState } from 'vuex';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import invalidUrl from '~/lib/utils/invalid_url';
-import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
+import { isSafeURL } from '~/lib/utils/url_utility';
import { __, n__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { panelTypes } from '../constants';
import { graphDataToCsv } from '../csv_export';
-import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
+import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorColumnChart from './charts/column.vue';
@@ -58,7 +56,6 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
clipboardText: {
type: String,
@@ -106,9 +103,6 @@ export default {
projectPath(state) {
return state[this.namespace].projectPath;
},
- logsPath(state) {
- return state[this.namespace].logsPath;
- },
timeRange(state) {
return state[this.namespace].timeRange;
},
@@ -142,17 +136,6 @@ export default {
const metrics = this.graphData?.metrics || [];
return metrics.some(({ loading }) => loading);
},
- logsPathWithTimeRange() {
- if (!this.glFeatures.monitorLogging) {
- return null;
- }
- const timeRange = this.zoomedTimeRange || this.timeRange;
-
- if (this.logsPath && this.logsPath !== invalidUrl && timeRange) {
- return timeRangeToUrl(timeRange, this.logsPath);
- }
- return null;
- },
csvText() {
if (this.graphData) {
return graphDataToCsv(this.graphData);
@@ -278,16 +261,6 @@ export default {
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
- visitLogsPage() {
- if (this.logsPathWithTimeRange) {
- visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
- }
- },
- visitLogsPageFromKeyboardShortcut() {
- if (this.isContextualMenuShown) {
- this.visitLogsPage();
- }
- },
downloadCsvFromKeyboardShortcut() {
if (this.csvText && this.isContextualMenuShown) {
this.$refs.downloadCsvLink.$el.firstChild.click();
@@ -351,13 +324,6 @@ export default {
>
{{ editCustomMetricLinkText }}
</gl-dropdown-item>
- <gl-dropdown-item
- v-if="logsPathWithTimeRange"
- ref="viewLogsLink"
- :href="logsPathWithTimeRange"
- >
- {{ s__('Metrics|View logs') }}
- </gl-dropdown-item>
<gl-dropdown-item
v-if="csvText"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 060ed896d7c..1b506c6564b 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -172,7 +172,6 @@ export const endpointKeys = [
'dashboardsEndpoint',
'currentDashboard',
'projectPath',
- 'logsPath',
];
/**
@@ -271,7 +270,6 @@ export const VARIABLE_PREFIX = 'var-';
export const keyboardShortcutKeys = {
EXPAND: 'e',
- VISIT_LOGS: 'l',
SHOW_ALERT: 'a',
DOWNLOAD_CSV: 'd',
CHART_COPY: 'c',
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 3883fa3380d..e513b575475 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -83,7 +83,6 @@ export default () => ({
externalDashboardUrl: '',
projectPath: null,
operationsSettingsPath: '',
- logsPath: invalidUrl,
addDashboardDocumentationPath: '',
// static paths
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 221f28e923b..fd8749625da 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -29,7 +29,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
canAccessOperationsSettings,
operationsSettingsPath,
projectPath,
- logsPath,
externalDashboardUrl,
currentEnvironmentName,
customDashboardBasePath,
@@ -53,7 +52,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
canAccessOperationsSettings,
operationsSettingsPath,
projectPath,
- logsPath,
externalDashboardUrl,
currentEnvironmentName,
customDashboardBasePath,
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 4a0602ad512..7527c685c71 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -7,14 +7,16 @@ import mrPageModule from './modules';
Vue.use(Vuex);
+export const createModules = () => ({
+ page: mrPageModule(),
+ notes: notesModule(),
+ diffs: diffsModule(),
+ batchComments: batchCommentsModule(),
+});
+
export const createStore = () =>
new Vuex.Store({
- modules: {
- page: mrPageModule(),
- notes: notesModule(),
- diffs: diffsModule(),
- batchComments: batchCommentsModule(),
- },
+ modules: createModules(),
});
export default createStore();
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index d93db7399e6..ef36e58374c 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */
+/* eslint-disable func-names, no-return-assign, @gitlab/require-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -6,9 +6,9 @@ import RefSelectDropdown from './ref_select_dropdown';
export default class NewBranchForm {
constructor(form, availableRefs) {
this.validate = this.validate.bind(this);
- this.branchNameError = form.find('.js-branch-name-error');
- this.name = form.find('.js-branch-name');
- this.ref = form.find('#ref');
+ this.branchNameError = form.querySelector('.js-branch-name-error');
+ this.name = form.querySelector('.js-branch-name');
+ this.ref = form.querySelector('#ref');
new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new
this.setupRestrictions();
this.addBinding();
@@ -16,12 +16,13 @@ export default class NewBranchForm {
}
addBinding() {
- return this.name.on('blur', this.validate);
+ this.name.addEventListener('blur', this.validate);
}
init() {
- if (this.name.length && this.name.val().length > 0) {
- return this.name.trigger('blur');
+ if (this.name != null && this.name.value.length > 0) {
+ const event = new CustomEvent('blur');
+ this.name.dispatchEvent(event);
}
}
@@ -52,7 +53,7 @@ export default class NewBranchForm {
validate() {
const { indexOf } = [];
- this.branchNameError.empty();
+ this.branchNameError.innerHTML = '';
const unique = function (values, value) {
if (indexOf.call(values, value) === -1) {
values.push(value);
@@ -73,7 +74,7 @@ export default class NewBranchForm {
return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`;
};
const validator = (errors, restriction) => {
- const matched = this.name.val().match(restriction.pattern);
+ const matched = this.name.value.match(restriction.pattern);
if (matched) {
return errors.concat(formatter(matched.reduce(unique, []), restriction));
}
@@ -81,8 +82,7 @@ export default class NewBranchForm {
};
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
- const errorMessage = $('<span/>').text(errors.join(', '));
- return this.branchNameError.append(errorMessage);
+ this.branchNameError.textContent = errors.join(', ');
}
}
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index e7ac27c5e3e..bd5945a951b 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -172,9 +172,6 @@ export default {
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
- internalNotesEnabled() {
- return Boolean(this.glFeatures.confidentialNotes);
- },
disableSubmitButton() {
return this.note.length === 0 || this.isSubmitting;
},
@@ -414,7 +411,7 @@ export default {
</template>
<template v-else>
<gl-form-checkbox
- v-if="internalNotesEnabled && canSetInternalNote"
+ v-if="canSetInternalNote"
v-model="noteIsInternal"
class="gl-mb-2"
data-testid="internal-note-checkbox"
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 83326279423..61af0b06535 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -39,8 +39,8 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="discussion-filter-actions mt-2">
- <gl-button variant="default" @click="selectFilter(0)">
+ <div class="discussion-filter-actions gl-mt-3 gl-display-flex">
+ <gl-button variant="default" class="gl-mr-3" @click="selectFilter(0)">
{{ __('Show all activity') }}
</gl-button>
<gl-button variant="default" @click="selectFilter(1)">
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index 7e8bb75902b..c1e39f31bbb 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -25,7 +25,7 @@ export default {
eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index af0c1e9619e..095ab5ddb0f 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSprintf, GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
@@ -11,7 +11,6 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -31,11 +30,12 @@ export default {
name: 'NoteableNote',
components: {
GlSprintf,
- userAvatarLink,
noteHeader,
noteActions,
NoteBody,
TimelineEntryItem,
+ GlAvatarLink,
+ GlAvatar,
},
directives: {
SafeHtml,
@@ -196,13 +196,11 @@ export default {
return fileResolvedFromAvailableSource || null;
},
- avatarSize() {
- // Use a different size if shown on a Merge Request Diff
- if (this.line && !this.isOverviewTab) {
- return 24;
- }
-
- return 40;
+ isMRDiffView() {
+ return this.line && !this.isOverviewTab;
+ },
+ authorAvatarAdaptiveSize() {
+ return { default: 24, md: 32 };
},
},
created() {
@@ -261,7 +259,7 @@ export default {
});
const confirmed = await confirmAction(msg, {
primaryBtnVariant: 'danger',
- primaryBtnText: this.note.confidential ? __('Delete Internal Note') : __('Delete Comment'),
+ primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'),
});
if (confirmed) {
@@ -428,19 +426,33 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="timeline-icon">
- <user-avatar-link
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="avatarSize"
- lazy
- >
- <template #avatar-badge>
- <slot name="avatar-badge"></slot>
- </template>
- </user-avatar-link>
+
+ <div v-if="isMRDiffView" class="gl-float-left gl-mt-n1 gl-mr-3">
+ <gl-avatar-link :href="author.path">
+ <gl-avatar
+ :src="author.avatar_url"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="24"
+ />
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
+ </div>
+
+ <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2">
+ <gl-avatar-link :href="author.path">
+ <gl-avatar
+ :src="author.avatar_url"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="authorAvatarAdaptiveSize"
+ />
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
</div>
+
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 8cd4477a3bb..2bd3488ae1b 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -1,13 +1,20 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { uniqBy } from 'lodash';
+import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
+ i18n: {
+ collapseReplies: s__('Notes|Collapse replies'),
+ expandReplies: s__('Notes|Expand replies'),
+ lastReplyBy: s__('Notes|Last reply by %{name}'),
+ },
components: {
GlButton,
- GlIcon,
+ GlLink,
+ GlSprintf,
UserAvatarLink,
TimeAgoTooltip,
},
@@ -28,63 +35,83 @@ export default {
uniqueAuthors() {
const authors = this.replies.map((reply) => reply.author || {});
- return uniqBy(authors, (author) => author.username);
+ return uniqBy(authors, 'username');
+ },
+ liClasses() {
+ return this.collapsed
+ ? 'gl-text-gray-500 gl-rounded-bottom-left-base gl-rounded-bottom-right-base'
+ : 'gl-border-b';
},
- className() {
- return this.collapsed ? 'collapsed' : 'expanded';
+ buttonIcon() {
+ return this.collapsed ? 'chevron-right' : 'chevron-down';
+ },
+ buttonLabel() {
+ return this.collapsed ? this.$options.i18n.expandReplies : this.$options.i18n.collapseReplies;
},
},
methods: {
toggle() {
+ this.$refs.toggle.$el.focus();
this.$emit('toggle');
},
},
- ICON_CLASS: 'gl-mr-3 gl-cursor-pointer',
};
</script>
<template>
<li
- :class="className"
- class="replies-toggle js-toggle-replies gl-display-flex! gl-align-items-center gl-flex-wrap"
+ :class="liClasses"
+ class="gl-display-flex! gl-align-items-center gl-flex-wrap gl-bg-gray-10 gl-py-3 gl-px-5 gl-border-t"
>
+ <gl-button
+ ref="toggle"
+ class="gl-my-2 gl-mr-3 gl-p-0!"
+ category="tertiary"
+ :icon="buttonIcon"
+ :aria-label="buttonLabel"
+ @click="toggle"
+ />
<template v-if="collapsed">
- <gl-icon :class="$options.ICON_CLASS" name="chevron-right" @click.native="toggle" />
- <div>
- <user-avatar-link
- v-for="author in uniqueAuthors"
- :key="author.username"
- :link-href="author.path"
- :img-alt="author.name"
- :img-src="author.avatar_url"
- :img-size="24"
- :tooltip-text="author.name"
- tooltip-placement="bottom"
- />
- </div>
+ <user-avatar-link
+ v-for="author in uniqueAuthors"
+ :key="author.username"
+ class="gl-mr-3"
+ :link-href="author.path"
+ :img-alt="author.name"
+ img-css-classes="gl-mr-0!"
+ :img-src="author.avatar_url"
+ :img-size="24"
+ :tooltip-text="author.name"
+ tooltip-placement="bottom"
+ />
<gl-button
- class="js-replies-text gl-mr-2"
- category="tertiary"
+ class="gl-mr-2"
variant="link"
data-qa-selector="expand_replies_button"
@click="toggle"
>
- {{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
+ {{ n__('%d reply', '%d replies', replies.length) }}
</gl-button>
- {{ __('Last reply by') }}
- <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2 gl-button">
- {{ lastReply.author.name }}
- </a>
+ <gl-sprintf :message="$options.i18n.lastReplyBy">
+ <template #name>
+ <gl-link
+ :href="lastReply.author.path"
+ class="gl-text-body! gl-text-decoration-none! gl-mx-2"
+ >
+ {{ lastReply.author.name }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
<time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" />
</template>
- <div
+ <gl-button
v-else
- class="collapse-replies-btn js-collapse-replies gl-display-flex align-items-center"
+ class="gl-text-body! gl-text-decoration-none!"
+ variant="link"
data-qa-selector="collapse_replies_button"
@click="toggle"
>
- <gl-icon :class="$options.ICON_CLASS" name="chevron-down" />
- <span class="gl-cursor-pointer">{{ s__('Notes|Collapse replies') }}</span>
- </div>
+ {{ $options.i18n.collapseReplies }}
+ </gl-button>
</li>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index b8575016762..3317f4e2383 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -10,8 +10,8 @@ export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const MERGED = 'merged';
-export const ISSUE_NOTEABLE_TYPE = 'issue';
-export const EPIC_NOTEABLE_TYPE = 'epic';
+export const ISSUE_NOTEABLE_TYPE = 'Issue';
+export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 754a534e055..45df91796fc 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -41,8 +41,6 @@ function updateUrlWithNoteId(noteId) {
// Unmask the note's ID
note?.setAttribute('id', `note_${noteId}`);
- } else if (noteId) {
- updateHistory(newHistoryEntry);
}
}
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 57bb9e295f9..82417c9134b 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -83,14 +83,17 @@ export const setExpandDiscussions = ({ commit }, { discussionIds, expanded }) =>
commit(types.SET_EXPAND_DISCUSSIONS, { discussionIds, expanded });
};
-export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) => {
+export const fetchDiscussions = (
+ { commit, dispatch, getters },
+ { path, filter, persistFilter },
+) => {
const config =
filter !== undefined
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
if (
- window.gon?.features?.paginatedIssueDiscussions ||
+ getters.noteableType === constants.ISSUE_NOTEABLE_TYPE ||
window.gon?.features?.paginatedMrDiscussions
) {
return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
@@ -114,7 +117,7 @@ export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, curs
return axios.get(path, { params }).then(({ data, headers }) => {
commit(types.ADD_OR_UPDATE_DISCUSSIONS, data);
- if (headers['x-next-page-cursor']) {
+ if (headers && headers['x-next-page-cursor']) {
const nextConfig = { ...config };
if (config?.params?.persist_filter) {
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index ab0418388cd..5d77ff9dc0d 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -20,7 +20,6 @@ import {
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
- ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
@@ -100,7 +99,7 @@ export default {
return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
},
imageName() {
- return this.imageDetails.name || ROOT_IMAGE_TEXT;
+ return this.imageDetails.name || this.imageDetails.project?.path;
},
formattedSize() {
const { size } = this.imageDetails;
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
index 56da8e88b7a..bfa99c01c3f 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
@@ -1,4 +1,5 @@
<script>
+import { uniqueId } from 'lodash';
import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { timeTilRun } from '../../utils';
@@ -43,6 +44,11 @@ export default {
CLEANUP_STATUS_UNFINISHED,
PARTIAL_CLEANUP_CONTINUE_MESSAGE,
},
+ data() {
+ return {
+ iconId: uniqueId('status-info-'),
+ };
+ },
computed: {
showStatus() {
return this.status !== UNSCHEDULED_STATUS;
@@ -85,14 +91,14 @@ export default {
</span>
<gl-icon
v-if="failedDelete"
- id="status-info"
+ :id="iconId"
:size="14"
class="gl-text-gray-500"
data-testid="extra-info"
name="information-o"
/>
<gl-popover
- target="status-info"
+ :target="iconId"
container="status-popover-container"
v-bind="$options.statusPopoverOptions"
>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index e67d77210bb..aecc0bf92ea 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -1,10 +1,12 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
-
+import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { joinPaths } from '~/lib/utils/url_utility';
import {
LIST_DELETE_BUTTON_DISABLED,
LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
@@ -13,8 +15,10 @@ import {
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
IMAGE_MIGRATING_STATE,
- ROOT_IMAGE_TEXT,
COPY_IMAGE_PATH_TITLE,
+ IMAGE_FULL_PATH_LABEL,
+ TRACKING_ACTION_CLICK_SHOW_FULL_PATH,
+ TRACKING_LABEL_REGISTRY_IMAGE_LIST,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
import CleanupStatus from './cleanup_status.vue';
@@ -25,6 +29,7 @@ export default {
ClipboardButton,
DeleteButton,
GlSprintf,
+ GlButton,
GlIcon,
ListItem,
GlSkeletonLoader,
@@ -33,6 +38,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin(), glFeatureFlagsMixin()],
inject: ['config'],
props: {
item: {
@@ -54,6 +60,12 @@ export default {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
COPY_IMAGE_PATH_TITLE,
+ IMAGE_FULL_PATH_LABEL,
+ },
+ data() {
+ return {
+ showFullPath: false,
+ };
},
computed: {
disabledDelete() {
@@ -79,7 +91,17 @@ export default {
);
},
imageName() {
- return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
+ if (this.glFeatures.containerRegistryShowShortenedPath) {
+ if (this.showFullPath) {
+ return this.item.path;
+ }
+ const projectPath = this.item?.project?.path ?? '';
+ if (this.item.name) {
+ return joinPaths(projectPath, this.item.name);
+ }
+ return projectPath;
+ }
+ return this.item.path;
},
routerLinkEvent() {
return this.deleting ? '' : 'click';
@@ -90,6 +112,15 @@ export default {
: LIST_DELETE_BUTTON_DISABLED;
},
},
+ methods: {
+ hideButton() {
+ this.showFullPath = true;
+ this.$refs.imageName.$el.focus();
+ this.track(TRACKING_ACTION_CLICK_SHOW_FULL_PATH, {
+ label: TRACKING_LABEL_REGISTRY_IMAGE_LIST,
+ });
+ },
+ },
};
</script>
@@ -104,7 +135,20 @@ export default {
:disabled="deleting"
>
<template #left-primary>
+ <gl-button
+ v-if="glFeatures.containerRegistryShowShortenedPath && !showFullPath"
+ v-gl-tooltip="{
+ placement: 'top',
+ title: $options.i18n.IMAGE_FULL_PATH_LABEL,
+ }"
+ icon="ellipsis_h"
+ size="small"
+ class="gl-mr-2"
+ :aria-label="$options.i18n.IMAGE_FULL_PATH_LABEL"
+ @click="hideButton"
+ />
<router-link
+ ref="imageName"
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
index 17adaec7a7d..67ad281b835 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
@@ -1,6 +1,5 @@
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
-export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
export const MORE_ACTIONS_TEXT = __('More actions');
export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index c6a7591e0d9..020d78ad364 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -43,6 +43,13 @@ export const EMPTY_RESULT_MESSAGE = s__(
export const COPY_IMAGE_PATH_TITLE = s__('ContainerRegistry|Copy image path');
+export const IMAGE_FULL_PATH_LABEL = s__('ContainerRegistry|Show full path');
+
+// Tracking
+
+export const TRACKING_LABEL_REGISTRY_IMAGE_LIST = 'registry_image_list';
+export const TRACKING_ACTION_CLICK_SHOW_FULL_PATH = 'click_show_full_path';
+
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
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 71a85d8885e..9ebbdfa920d 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
@@ -24,7 +24,6 @@ import {
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- ROOT_IMAGE_TEXT,
GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
@@ -111,7 +110,7 @@ export default {
methods: {
updateBreadcrumb() {
const name = this.containerRepository?.id
- ? this.containerRepository?.name || ROOT_IMAGE_TEXT
+ ? this.containerRepository?.name || this.containerRepository?.project?.path
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
index a25839be7e1..b91af19d623 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue
@@ -2,6 +2,12 @@
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
+import {
+ TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA,
+ TRACKING_LABEL_PACKAGE_ASSET,
+} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'FileSha',
@@ -9,6 +15,7 @@ export default {
DetailsRow,
ClipboardButton,
},
+ mixins: [Tracking.mixin()],
props: {
sha: {
type: String,
@@ -22,6 +29,18 @@ export default {
i18n: {
copyButtonTitle: s__('PackageRegistry|Copy SHA'),
},
+ computed: {
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
+ },
+ methods: {
+ copySha() {
+ this.track(TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA, { label: TRACKING_LABEL_PACKAGE_ASSET });
+ },
+ },
};
</script>
@@ -35,6 +54,7 @@ export default {
:title="$options.i18n.copyButtonTitle"
category="tertiary"
size="small"
+ @click="copySha"
/>
</div>
</details-row>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index 9e700a5236f..a049b0eff8d 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -5,8 +5,13 @@ import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ TRACKING_LABEL_PACKAGE_ASSET,
+ TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
+} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'PackageFiles',
@@ -76,6 +81,11 @@ export default {
},
].filter((c) => !c.hide);
},
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
},
methods: {
formatSize(size) {
@@ -84,6 +94,11 @@ export default {
hasDetails(item) {
return item.fileSha256 || item.fileMd5 || item.fileSha1;
},
+ trackToggleDetails(detailsShowing) {
+ if (!detailsShowing) {
+ this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET });
+ }
+ },
},
i18n: {
deleteFile: __('Delete file'),
@@ -106,7 +121,10 @@ export default {
:aria-label="detailsShowing ? __('Collapse') : __('Expand')"
category="tertiary"
size="small"
- @click="toggleDetails"
+ @click="
+ toggleDetails();
+ trackToggleDetails(detailsShowing);
+ "
/>
<gl-link
:href="item.downloadPath"
@@ -129,8 +147,8 @@ export default {
:href="item.pipeline.commitPath"
class="gl-text-gray-500"
data-testid="commit-link"
- >{{ item.pipeline.sha }}</gl-link
- >
+ >{{ item.pipeline.sha }}
+ </gl-link>
</template>
<template #cell(created)="{ item }">
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index 3c090951b7d..cea053992f8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -9,6 +9,7 @@ export {
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/shared/constants';
export const PACKAGE_TYPE_CONAN = 'CONAN';
@@ -62,6 +63,12 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND =
export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
+export const TRACKING_LABEL_PACKAGE_ASSET = 'package_assets';
+
+export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
+export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
+export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
+
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 768c8d6478b..29438fba86b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -33,7 +33,6 @@ import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
- PULL_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
@@ -41,6 +40,7 @@ import {
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
@@ -76,10 +76,10 @@ export default {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
- PULL_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
},
data() {
return {
@@ -288,7 +288,7 @@ export default {
v-if="showFiles"
:can-delete="packageEntity.canDestroy"
:package-files="packageFiles"
- @download-file="track($options.trackingActions.PULL_PACKAGE)"
+ @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
@delete-file="handleFileDelete"
/>
</gl-tab>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
index 482a3ef2ead..3689199751d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
@@ -1,7 +1,6 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import SettingsApp from './components/group_settings_app.vue';
import { apolloProvider } from './graphql';
@@ -20,7 +19,6 @@ export default () => {
provide: {
groupPath: el.dataset.groupPath,
groupDependencyProxyPath: el.dataset.groupDependencyProxyPath,
- defaultExpanded: parseBoolean(el.dataset.defaultExpanded),
},
render(createElement) {
return createElement(SettingsApp);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
index 130d6977936..59d4f5e24d0 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -1,8 +1,7 @@
<script>
import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
-import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
@@ -13,6 +12,7 @@ import {
import {
DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
@@ -23,15 +23,14 @@ export default {
GlSprintf,
GlLink,
SettingsBlock,
- SettingsTitles,
},
i18n: {
DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_DESCRIPTION,
enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'),
enabledProxyHelpText: s__(
'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}',
),
- storageSettingsTitle: s__('DependencyProxy|Storage settings'),
ttlPolicyEnabledLabel: s__('DependencyProxy|Clear the Dependency Proxy cache automatically'),
ttlPolicyEnabledHelpText: s__(
'DependencyProxy|When enabled, images older than 90 days will be removed from the cache.',
@@ -40,7 +39,7 @@ export default {
links: {
DEPENDENCY_PROXY_DOCS_PATH,
},
- inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'],
+ inject: ['groupPath', 'groupDependencyProxyPath'],
props: {
dependencyProxySettings: {
type: Object,
@@ -130,11 +129,9 @@ export default {
</script>
<template>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="dependency_proxy_settings_content"
- >
+ <settings-block data-qa-selector="dependency_proxy_settings_content">
<template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template>
+ <template #description> {{ $options.i18n.DEPENDENCY_PROXY_DESCRIPTION }} </template>
<template #default>
<div>
<gl-toggle
@@ -156,13 +153,12 @@ export default {
</span>
</template>
</gl-toggle>
-
- <settings-titles :title="$options.i18n.storageSettingsTitle" class="gl-my-6" />
<gl-toggle
v-model="ttlEnabled"
:disabled="isLoading"
:label="$options.i18n.ttlPolicyEnabledLabel"
:help="$options.i18n.ttlPolicyEnabledHelpText"
+ class="gl-mt-6"
data-testid="dependency-proxy-ttl-policies-toggle"
/>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
index b0088838acc..51a97aead49 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -1,11 +1,9 @@
<script>
-import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { isEqual } from 'lodash';
import {
DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_ALLOWED_DISABLED,
- DUPLICATES_ALLOWED_ENABLED,
DUPLICATES_SETTING_EXCEPTION_TITLE,
DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
} from '~/packages_and_registries/settings/group/constants';
@@ -18,7 +16,6 @@ export default {
DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
},
components: {
- GlSprintf,
GlToggle,
GlFormGroup,
GlFormInput,
@@ -63,9 +60,6 @@ export default {
},
},
computed: {
- enabledButtonLabel() {
- return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED;
- },
isExceptionRegexValid() {
return !this.duplicateExceptionRegexError;
},
@@ -80,41 +74,30 @@ export default {
<template>
<form>
- <div class="gl-display-flex">
- <gl-toggle
- :data-qa-selector="toggleQaSelector"
- :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
- label-position="hidden"
- :value="duplicatesAllowed"
+ <gl-toggle
+ :data-qa-selector="toggleQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ :value="!duplicatesAllowed"
+ :disabled="loading"
+ @change="update(modelNames.allowed, !$event)"
+ />
+ <gl-form-group
+ v-if="!duplicatesAllowed"
+ class="gl-mt-4"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-size="sm"
+ :state="isExceptionRegexValid"
+ :invalid-feedback="duplicateExceptionRegexError"
+ :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
+ label-for="maven-duplicated-settings-regex-input"
+ >
+ <gl-form-input
+ id="maven-duplicated-settings-regex-input"
:disabled="loading"
- @change="update(modelNames.allowed, $event)"
+ size="lg"
+ :value="duplicateExceptionRegex"
+ @change="update(modelNames.exception, $event)"
/>
- <div class="gl-ml-5">
- <div data-testid="toggle-label" :data-qa-selector="labelQaSelector">
- <gl-sprintf :message="enabledButtonLabel">
- <template #bold="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <gl-form-group
- v-if="!duplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isExceptionRegexValid"
- :invalid-feedback="duplicateExceptionRegexError"
- :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :disabled="loading"
- :value="duplicateExceptionRegex"
- @change="update(modelNames.exception, $event)"
- />
- </gl-form-group>
- </div>
- </div>
+ </gl-form-group>
</form>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
index b7e88945dbd..abb9f02d290 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -1,17 +1,15 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
export default {
name: 'PackageSettings',
@@ -19,18 +17,13 @@ export default {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
- links: {
- PACKAGES_DOCS_PATH,
- },
components: {
- GlSprintf,
- GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
},
- inject: ['defaultExpanded', 'groupPath'],
+ inject: ['groupPath'],
props: {
packageSettings: {
type: Object,
@@ -91,20 +84,11 @@ export default {
</script>
<template>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="package_registry_settings_content"
- >
+ <settings-block data-qa-selector="package_registry_settings_content">
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
- <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
- <template #link="{ content }">
- <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
+ {{ $options.i18n.PACKAGE_SETTINGS_DESCRIPTION }}
</span>
</template>
<template #default>
@@ -116,8 +100,8 @@ export default {
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
- toggle-qa-selector="allow_duplicates_toggle"
- label-qa-selector="allow_duplicates_label"
+ toggle-qa-selector="reject_duplicates_toggle"
+ label-qa-selector="reject_duplicates_label"
@update="updateSettings"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 0249b475e46..34764663892 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -1,17 +1,13 @@
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale';
-export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry');
+export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages');
export const PACKAGE_SETTINGS_DESCRIPTION = s__(
- 'PackageRegistry|Use GitLab as a private registry for common package formats. %{linkStart}Learn more.%{linkEnd}',
+ 'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.',
);
-export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
-export const DUPLICATES_ALLOWED_DISABLED = s__(
- 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Reject packages with the same name and version.',
-);
-export const DUPLICATES_ALLOWED_ENABLED = s__(
- 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Accept packages with the same name and version.',
+export const DUPLICATES_TOGGLE_LABEL = s__(
+ 'PackageRegistry|Reject packages with the same name and version',
);
export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
@@ -19,6 +15,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
);
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
+export const DEPENDENCY_PROXY_DESCRIPTION = s__(
+ 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.',
+);
// Parameters
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
index fdc7bd39780..90a18d5cf5a 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
@@ -81,7 +81,7 @@ export default {
</script>
<template>
- <settings-block :collapsible="false">
+ <settings-block data-testid="container-expiration-policy-project-settings">
<template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template>
<template #description>
<span>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index d75fb31fd98..7682754fdcb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -30,6 +30,11 @@ export default {
type: String,
required: true,
},
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
};
</script>
@@ -46,5 +51,10 @@ export default {
{{ option.label }}
</option>
</gl-form-select>
+ <template v-if="description" #description>
+ <span data-testid="description" class="gl-text-gray-400">
+ {{ description }}
+ </span>
+ </template>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
new file mode 100644
index 00000000000..1170407a349
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ PACKAGES_CLEANUP_POLICY_TITLE,
+ PACKAGES_CLEANUP_POLICY_DESCRIPTION,
+} from '~/packages_and_registries/settings/project/constants';
+import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue';
+
+export default {
+ components: {
+ SettingsBlock,
+ GlAlert,
+ GlSprintf,
+ PackagesCleanupPolicyForm,
+ },
+ inject: ['projectPath'],
+ i18n: {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ PACKAGES_CLEANUP_POLICY_TITLE,
+ PACKAGES_CLEANUP_POLICY_DESCRIPTION,
+ },
+ apollo: {
+ packagesCleanupPolicy: {
+ query: packagesCleanupPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.packagesCleanupPolicy || {},
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ packagesCleanupPolicy: {},
+ };
+ },
+};
+</script>
+
+<template>
+ <settings-block>
+ <template #title> {{ $options.i18n.PACKAGES_CLEANUP_POLICY_TITLE }}</template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.PACKAGES_CLEANUP_POLICY_DESCRIPTION" />
+ </span>
+ </template>
+ <template #default>
+ <gl-alert v-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ <packages-cleanup-policy-form
+ v-else
+ v-model="packagesCleanupPolicy"
+ :is-loading="$apollo.queries.packagesCleanupPolicy.loading"
+ />
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
new file mode 100644
index 00000000000..b1751d5174a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import {
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ SET_CLEANUP_POLICY_BUTTON,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+} from '~/packages_and_registries/settings/project/constants';
+import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
+import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
+import Tracking from '~/tracking';
+import ExpirationDropdown from './expiration_dropdown.vue';
+
+export default {
+ components: {
+ GlButton,
+ ExpirationDropdown,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['projectPath'],
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ formOptions: formOptionsGenerator(),
+ i18n: {
+ KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
+ SET_CLEANUP_POLICY_BUTTON,
+ },
+ data() {
+ return {
+ tracking: {
+ label: 'packages_cleanup_policies',
+ },
+ mutationLoading: false,
+ };
+ },
+ computed: {
+ prefilledForm() {
+ return {
+ ...this.value,
+ keepNDuplicatedPackageFiles: this.findDefaultOption(
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ ),
+ };
+ },
+ showLoadingIcon() {
+ return this.isLoading || this.mutationLoading;
+ },
+ isSubmitButtonDisabled() {
+ return this.showLoadingIcon;
+ },
+ isFieldDisabled() {
+ return this.showLoadingIcon;
+ },
+ mutationVariables() {
+ return {
+ projectPath: this.projectPath,
+ keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles,
+ };
+ },
+ },
+ methods: {
+ findDefaultOption(option) {
+ return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key;
+ },
+ submit() {
+ this.track('submit_packages_cleanup_form');
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: updatePackagesCleanupPolicyMutation,
+ variables: {
+ input: this.mutationVariables,
+ },
+ })
+ .then(({ data }) => {
+ const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? [];
+ if (errorMessage) {
+ throw errorMessage;
+ } else {
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
+ }
+ })
+ .catch(() => {
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ this.mutationLoading = false;
+ });
+ },
+ onModelChange(newValue, model) {
+ this.$emit('input', { ...this.value, [model]: newValue });
+ },
+ },
+};
+</script>
+
+<template>
+ <form ref="form-element" @submit.prevent="submit">
+ <div class="gl-md-max-w-50p">
+ <expiration-dropdown
+ v-model="prefilledForm.keepNDuplicatedPackageFiles"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
+ :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
+ :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
+ name="keep-n-duplicated-package-files"
+ data-testid="keep-n-duplicated-package-files-dropdown"
+ @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
+ />
+ </div>
+ <div class="gl-mt-7 gl-display-flex gl-align-items-center">
+ <gl-button
+ data-testid="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-4"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 95af19e6d85..710cfe7b1eb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -1,15 +1,19 @@
<script>
import ContainerExpirationPolicy from './container_expiration_policy.vue';
+import PackagesCleanupPolicy from './packages_cleanup_policy.vue';
export default {
components: {
ContainerExpirationPolicy,
+ PackagesCleanupPolicy,
},
+ inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'],
};
</script>
<template>
- <section data-testid="registry-settings-app">
- <container-expiration-policy />
- </section>
+ <div>
+ <packages-cleanup-policy v-if="showPackageRegistrySettings" />
+ <container-expiration-policy v-if="showContainerRegistrySettings" />
+ </div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 40f980d15fb..948520151ce 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -55,6 +55,31 @@ export const EXPIRATION_POLICY_FOOTER_NOTE = s__(
'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time',
);
+export const PACKAGES_CLEANUP_POLICY_TITLE = s__(
+ 'PackageRegistry|Manage storage used by package assets',
+);
+export const PACKAGES_CLEANUP_POLICY_DESCRIPTION = s__(
+ 'PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets.',
+);
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__(
+ 'PackageRegistry|Number of duplicate assets to keep',
+);
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__(
+ 'PackageRegistry|Examples of assets include .pom & .jar files',
+);
+
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles';
+
+export const KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS = [
+ { key: 'ONE_PACKAGE_FILE', label: 1, default: false },
+ { key: 'TEN_PACKAGE_FILES', label: 10, default: false },
+ { key: 'TWENTY_PACKAGE_FILES', label: 20, default: false },
+ { key: 'THIRTY_PACKAGE_FILES', label: 30, default: false },
+ { key: 'FORTY_PACKAGE_FILES', label: 40, default: false },
+ { key: 'FIFTY_PACKAGE_FILES', label: 50, default: false },
+ { key: 'ALL_PACKAGE_FILES', label: __('All'), default: true },
+];
+
export const KEEP_N_OPTIONS = [
{ key: 'ONE_TAG', variable: 1, default: false },
{ key: 'FIVE_TAGS', variable: 5, default: false },
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql
new file mode 100644
index 00000000000..a77ede37884
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql
@@ -0,0 +1,4 @@
+fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy {
+ keepNDuplicatedPackageFiles
+ nextRunAt
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql
new file mode 100644
index 00000000000..31cdd67e881
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/packages_cleanup_policy.fragment.graphql"
+
+mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) {
+ updatePackagesCleanupPolicy(input: $input) {
+ packagesCleanupPolicy {
+ ...PackagesCleanupPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql
new file mode 100644
index 00000000000..0e9af253f2c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/packages_cleanup_policy.fragment.graphql"
+
+query getProjectPackagesCleanupPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ packagesCleanupPolicy {
+ ...PackagesCleanupPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index 17c33073668..daf1da6eac8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -20,6 +20,8 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
+ showContainerRegistrySettings,
+ showPackageRegistrySettings,
} = el.dataset;
return new Vue({
el,
@@ -34,6 +36,8 @@ export default () => {
adminSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
+ showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
+ showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings),
},
render(createElement) {
return createElement('registry-settings-app', {});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
index b577a051862..847965454e9 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
@@ -1,5 +1,11 @@
import { n__ } from '~/locale';
-import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+import {
+ KEEP_N_OPTIONS,
+ CADENCE_OPTIONS,
+ OLDER_THAN_OPTIONS,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS,
+} from './constants';
export const findDefaultOption = (options) => {
const item = options.find((o) => o.default);
@@ -25,5 +31,6 @@ export const formOptionsGenerator = () => {
olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
cadence: CADENCE_OPTIONS,
keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
+ [KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS,
};
};
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
new file mode 100644
index 00000000000..5caf95cd050
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -0,0 +1,17 @@
+<template>
+ <section class="settings gl-py-7">
+ <div class="gl-lg-display-flex">
+ <div class="gl-lg-w-half gl-pr-10">
+ <h4>
+ <slot name="title"></slot>
+ </h4>
+ <p>
+ <slot name="description"></slot>
+ </p>
+ </div>
+ <div class="gl-lg-w-half gl-pt-3">
+ <slot></slot>
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index afc72a2c627..5505205cf33 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -11,6 +11,7 @@ export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
+export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset';
export const TRACKING_ACTIONS = {
DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION,
@@ -20,6 +21,7 @@ export const TRACKING_ACTIONS = {
DELETE_PACKAGE_FILE: DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE: REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE: CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET: DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
};
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
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 5ecacb84d65..ccb449f96e1 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
@@ -12,6 +12,7 @@ import {
import { toSafeInteger } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, n__, s__, sprintf } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SignupCheckbox from './signup_checkbox.vue';
const DENYLIST_TYPE_RAW = 'raw';
@@ -31,7 +32,12 @@ export default {
GlLink,
SignupCheckbox,
GlModal,
+ PasswordComplexityCheckboxGroup: () =>
+ import(
+ 'ee_component/pages/admin/application_settings/general/components/password_complexity_checkbox_group.vue'
+ ),
},
+ mixins: [glFeatureFlagMixin()],
inject: [
'host',
'settingsPath',
@@ -178,6 +184,9 @@ export default {
this.submitForm();
},
+ setPasswordComplexity({ name, value }) {
+ this.$set(this.form, name, value);
+ },
submitForm() {
this.$refs.form.submit();
},
@@ -291,9 +300,7 @@ export default {
<template #description>
<gl-sprintf
:message="
- s__(
- 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}.',
- )
+ s__('ApplicationSettings|See %{linkStart}password policy guidelines%{linkEnd}.')
"
>
<template #link="{ content }">
@@ -305,6 +312,10 @@ export default {
</template>
</gl-form-group>
+ <password-complexity-checkbox-group
+ v-if="glFeatures.passwordComplexity"
+ @set-password-complexity="setPasswordComplexity"
+ />
<gl-form-group
:description="$options.i18n.domainAllowListDescription"
:label="$options.i18n.domainAllowListLabel"
diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
index a50d8de0e88..0d5c55cb87b 100644
--- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
+++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
@@ -18,6 +18,10 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for
'domainDenylistEnabled',
'denylistTypeRawSelected',
'emailRestrictionsEnabled',
+ 'passwordNumberRequired',
+ 'passwordLowercaseRequired',
+ 'passwordUppercaseRequired',
+ 'passwordSymbolRequired',
],
});
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 44299d235d5..e45a40bd44c 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -28,20 +28,32 @@ export default class Todos {
}
unbindEvents() {
- $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
- $('.todo').off('click', this.goToTodoUrl);
- $('.todo').off('auxclick', this.goToTodoUrl);
+ document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => {
+ el.removeEventListener('click', this.updateRowStateClickedWrapper);
+ });
+ document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => {
+ el.removeEventListener('click', this.updateallStateClickedWrapper);
+ });
+ document.querySelectorAll('.todo').forEach((el) => {
+ el.removeEventListener('click', this.goToTodoUrl);
+ el.removeEventListener('auxclick', this.goToTodoUrl);
+ });
}
bindEvents() {
this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this);
this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this);
- $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
- $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
- $('.todo').on('click', this.goToTodoUrl);
- $('.todo').on('auxclick', this.goToTodoUrl);
+ document.querySelectorAll('.js-done-todo, .js-undo-todo, .js-add-todo').forEach((el) => {
+ el.addEventListener('click', this.updateRowStateClickedWrapper);
+ });
+ document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => {
+ el.addEventListener('click', this.updateAllStateClickedWrapper);
+ });
+ document.querySelectorAll('.todo').forEach((el) => {
+ el.addEventListener('click', this.goToTodoUrl);
+ el.addEventListener('auxclick', this.goToTodoUrl);
+ });
}
initFilters() {
@@ -181,7 +193,13 @@ export default class Todos {
}
updateBadges(data) {
- $(document).trigger('todo:toggle', data.count);
+ const event = new CustomEvent('todo:toggle', {
+ detail: {
+ count: data.count,
+ },
+ });
+
+ document.dispatchEvent(event);
document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter(
data.count,
);
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index c7c2f6f773e..62d47cb49b8 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import { initMembersApp } from '~/members';
-import { MEMBER_TYPES } from '~/members/constants';
+import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
-
-initMembersApp(document.querySelector('.js-group-members-list-app'), {
+const APP_OPTIONS = {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
@@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: groupMemberRequestFormatter,
},
-});
+ ...EE_APP_OPTIONS,
+};
+
+initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS);
initInviteMembersModal();
initInviteGroupsModal();
diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js
index ca1a6bdab75..ca1a6bdab75 100644
--- a/app/assets/javascripts/pages/groups/runners/index.js
+++ b/app/assets/javascripts/pages/groups/runners/index/index.js
diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js
new file mode 100644
index 00000000000..c59e3b80dc1
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initGroupRunnerShow } from '~/runner/group_runner_show';
+
+initGroupRunnerShow();
diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js
index 364223f1898..dbae89b5ade 100644
--- a/app/assets/javascripts/pages/projects/branches/new/index.js
+++ b/app/assets/javascripts/pages/projects/branches/new/index.js
@@ -1,8 +1,7 @@
-import $ from 'jquery';
import NewBranchForm from '~/new_branch_form';
// eslint-disable-next-line no-new
new NewBranchForm(
- $('.js-create-branch-form'),
+ document.querySelector('.js-create-branch-form'),
JSON.parse(document.getElementById('availableRefs').innerHTML),
);
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 701bf0c1e1d..f92a40e057f 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -329,7 +329,7 @@ export default {
</div>
<p class="gl-mt-n5 gl-text-gray-500">
- {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }}
+ {{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }}
<gl-link :href="newGroupPath" target="_blank">
{{ s__('ForkProject|Create a group') }}
</gl-link>
diff --git a/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js
new file mode 100644
index 00000000000..abececa44ee
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/configuration/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
new file mode 100644
index 00000000000..5482324f1cd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js
new file mode 100644
index 00000000000..b5a29b3825b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/deployments/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js
new file mode 100644
index 00000000000..fb66e2fa051
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/gcp_regions/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js
deleted file mode 100644
index 4506ea8efd1..00000000000
--- a/app/assets/javascripts/pages/projects/google_cloud/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initGoogleCloud from '~/google_cloud/index';
-
-initGoogleCloud();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js
new file mode 100644
index 00000000000..8b644c2b324
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/service_accounts/index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 75194499a7f..eb3a24f38a8 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,23 +1,3 @@
-import Vue from 'vue';
import initJobsTable from '~/jobs/components/table';
-import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-if (gon.features?.jobsTableVue) {
- initJobsTable();
-} else {
- const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
-
- remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
- );
-}
+initJobsTable();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
new file mode 100644
index 00000000000..693dc6a15ad
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
@@ -0,0 +1,15 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+ name: 'IncludedInTrialIndicator',
+ i18n: {
+ trialOnly: s__('LearnGitlab|- Included in trial'),
+ },
+};
+</script>
+<template>
+ <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
+ {{ $options.i18n.trialOnly }}
+ </span>
+</template>
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 db9ef4df8af..54e15b6552c 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
@@ -38,14 +38,16 @@ export default {
actionsData: this.actions,
};
},
- maxValue: Object.keys(ACTION_LABELS).length,
actionSections: Object.keys(ACTION_SECTIONS),
computed: {
+ maxValue() {
+ return Object.keys(this.actionsData).length;
+ },
progressValue() {
return Object.values(this.actionsData).filter((a) => a.completed).length;
},
progressPercentage() {
- return Math.round((this.progressValue / this.$options.maxValue) * 100);
+ return Math.round((this.progressValue / this.maxValue) * 100);
},
},
mounted() {
@@ -125,7 +127,7 @@ export default {
<template #percentSymbol>%</template>
</gl-sprintf>
</p>
- <gl-progress-bar :value="progressValue" :max="$options.maxValue" />
+ <gl-progress-bar :value="progressValue" :max="maxValue" />
</div>
<div class="row">
<div
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
deleted file mode 100644
index 09cc0032871..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<script>
-import { GlLink, GlCard, GlIcon } from '@gitlab/ui';
-import { s__ } from '~/locale';
-
-export default {
- name: 'LearnGitlabInfoCard',
- components: { GlLink, GlCard, GlIcon },
- i18n: {
- trial: s__('Learn GitLab|Trial only'),
- },
- props: {
- title: {
- required: true,
- type: String,
- },
- description: {
- required: true,
- type: String,
- },
- actionLabel: {
- required: true,
- type: String,
- },
- url: {
- required: true,
- type: String,
- },
- completed: {
- required: true,
- type: Boolean,
- },
- svg: {
- required: true,
- type: String,
- },
- trialRequired: {
- default: false,
- required: false,
- type: Boolean,
- },
- },
-};
-</script>
-<template>
- <gl-card class="gl-pt-0">
- <div class="gl-text-right gl-h-5">
- <gl-icon
- v-if="completed"
- name="check-circle-filled"
- class="gl-text-green-500"
- :size="16"
- data-testid="completed-icon"
- />
- <span
- v-else-if="trialRequired"
- class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
- data-testid="trial-only"
- >{{ $options.i18n.trial }}</span
- >
- </div>
- <div
- class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
- >
- <img :src="svg" :alt="actionLabel" />
- <h6>{{ title }}</h6>
- <p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
- <gl-link :href="url" target="_blank" rel="noopener noreferrer" />
- </div>
- </gl-card>
-</template>
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 1912477758b..4eab0cccb06 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
@@ -6,6 +6,7 @@ import { isExperimentVariant } from '~/experimentation/utils';
import eventHub from '~/invite_members/event_hub';
import { s__, __ } from '~/locale';
import { ACTION_LABELS } from '../constants';
+import IncludedInTrialIndicator from './included_in_trial_indicator.vue';
export default {
name: 'LearnGitlabSectionLink',
@@ -15,12 +16,12 @@ export default {
GlButton,
GlPopover,
GitlabExperiment,
+ IncludedInTrialIndicator,
},
directives: {
GlTooltip,
},
i18n: {
- trialOnly: s__('LearnGitlab|Trial only'),
contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'),
viewAdminList: s__('LearnGitlab|View administrator list'),
watchHow: __('Watch how'),
@@ -41,12 +42,6 @@ export default {
};
},
computed: {
- linkTitle() {
- return ACTION_LABELS[this.action].title;
- },
- trialOnly() {
- return ACTION_LABELS[this.action].trialRequired;
- },
showInviteModalLink() {
return (
this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
@@ -55,49 +50,51 @@ export default {
openInNewTab() {
return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
},
- linkToVideoTutorial() {
- return ACTION_LABELS[this.action].videoTutorial;
- },
},
methods: {
openModal() {
eventHub.$emit('openModal', { source: 'learn_gitlab' });
},
+ actionLabelValue(value) {
+ return ACTION_LABELS[this.action][value];
+ },
},
};
</script>
<template>
<div class="gl-mb-4">
- <div v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- {{ $options.i18n.trialOnly }}
- </div>
<div class="flex align-items-center">
<span v-if="value.completed" class="gl-text-green-500">
<gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
- {{ linkTitle }}
+ {{ actionLabelValue('title') }}
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
</span>
- <gl-link
- v-else-if="showInviteModalLink"
- data-track-action="click_link"
- :data-track-label="linkTitle"
- data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
- data-testid="invite-for-help-continuous-onboarding-experiment-link"
- @click="openModal"
- >
- {{ linkTitle }}
- </gl-link>
- <gl-link
- v-else-if="value.enabled"
- :target="openInNewTab ? '_blank' : '_self'"
- :href="value.url"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- :data-track-label="linkTitle"
- >
- {{ linkTitle }}
- </gl-link>
+ <div v-else-if="showInviteModalLink">
+ <gl-link
+ data-track-action="click_link"
+ :data-track-label="actionLabelValue('trackLabel')"
+ data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
+ data-testid="invite-for-help-continuous-onboarding-experiment-link"
+ @click="openModal"
+ >{{ actionLabelValue('title') }}</gl-link
+ >
+
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
+ </div>
+ <div v-else-if="value.enabled">
+ <gl-link
+ :target="openInNewTab ? '_blank' : '_self'"
+ :href="value.url"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ :data-track-label="actionLabelValue('trackLabel')"
+ >{{ actionLabelValue('title') }}</gl-link
+ >
+
+ <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
+ </div>
<template v-else>
- <div data-testid="disabled-learn-gitlab-link">{{ linkTitle }}</div>
+ <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div>
<gl-button
:id="popoverId"
category="tertiary"
@@ -127,19 +124,19 @@ export default {
<template #control></template>
<template #candidate>
<gl-button
- v-if="linkToVideoTutorial"
+ v-if="actionLabelValue('videoTutorial')"
v-gl-tooltip
category="tertiary"
icon="live-preview"
:title="$options.i18n.watchHow"
:aria-label="$options.i18n.watchHow"
- :href="linkToVideoTutorial"
+ :href="actionLabelValue('videoTutorial')"
target="_blank"
class="ml-auto"
size="small"
data-testid="video-tutorial-link"
data-track-action="click_video_link"
- :data-track-label="linkTitle"
+ :data-track-label="actionLabelValue('trackLabel')"
data-track-property="Growth::Conversion::Experiment::LearnGitLab"
data-track-experiment="video_tutorials_continuous_onboarding"
/>
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 05bacd9b350..cb1a0302d91 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -2,9 +2,10 @@ import { s__ } from '~/locale';
export const ACTION_LABELS = {
gitWrite: {
- title: s__('LearnGitLab|Create or import a repository'),
- actionLabel: s__('LearnGitLab|Create or import a repository'),
+ title: s__('LearnGitLab|Create a repository'),
+ actionLabel: s__('LearnGitLab|Create a repository'),
description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ trackLabel: 'create_a_repository',
section: 'workspace',
position: 1,
},
@@ -14,20 +15,23 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
),
+ trackLabel: 'invite_your_colleagues',
section: 'workspace',
position: 0,
},
pipelineCreated: {
- title: s__('LearnGitLab|Set up CI/CD'),
- actionLabel: s__('LearnGitLab|Set-up CI/CD'),
+ title: s__("LearnGitLab|Set up your first project's CI/CD"),
+ actionLabel: s__('LearnGitLab|Set up CI/CD'),
description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ trackLabel: 'set_up_your_first_project_s_ci_cd',
section: 'workspace',
position: 2,
},
trialStarted: {
- title: s__('LearnGitLab|Start a free Ultimate trial'),
+ title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'),
actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ trackLabel: 'start_a_free_trial_of_gitlab_ultimate',
section: 'workspace',
position: 3,
openInNewTab: true,
@@ -38,6 +42,7 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
),
+ trackLabel: 'add_code_owners',
trialRequired: true,
section: 'workspace',
position: 4,
@@ -45,9 +50,10 @@ export const ACTION_LABELS = {
videoTutorial: 'https://vimeo.com/670896787',
},
requiredMrApprovalsEnabled: {
- title: s__('LearnGitLab|Add merge request approval'),
+ title: s__('LearnGitLab|Enable require merge approvals'),
actionLabel: s__('LearnGitLab|Enable require merge approvals'),
description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
+ trackLabel: 'enable_require_merge_approvals',
trialRequired: true,
section: 'workspace',
position: 5,
@@ -55,28 +61,52 @@ export const ACTION_LABELS = {
videoTutorial: 'https://vimeo.com/670904904',
},
mergeRequestCreated: {
- title: s__('LearnGitLab|Submit a merge request'),
+ title: s__('LearnGitLab|Submit a merge request (MR)'),
actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ trackLabel: 'submit_a_merge_request_mr',
section: 'plan',
position: 1,
},
- securityScanEnabled: {
- title: s__('LearnGitLab|Run a Security scan using CI/CD'),
- actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
- description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
- section: 'deploy',
- position: 1,
- },
issueCreated: {
title: s__('LearnGitLab|Create an issue'),
actionLabel: s__('LearnGitLab|Create an issue'),
description: s__(
'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.',
),
+ trackLabel: 'create_an_issue',
section: 'plan',
position: 0,
},
+ securityScanEnabled: {
+ title: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ trackLabel: 'run_a_security_scan_using_ci_cd',
+ section: 'deploy',
+ position: 1,
+ },
+ licenseScanningRun: {
+ title: s__('LearnGitLab|Scan dependencies for licenses'),
+ trackLabel: 'scan_dependencies_for_licenses',
+ trialRequired: true,
+ section: 'deploy',
+ position: 2,
+ },
+ secureDependencyScanningRun: {
+ title: s__('LearnGitLab|Scan dependencies for vulnerabilities'),
+ trackLabel: 'scan_dependencies_for_vulnerabilities',
+ trialRequired: true,
+ section: 'deploy',
+ position: 3,
+ },
+ secureDastRun: {
+ title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'),
+ trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast',
+ trialRequired: true,
+ section: 'deploy',
+ position: 4,
+ },
};
export const ACTION_SECTIONS = {
diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js
deleted file mode 100644
index 0cff1ffc27e..00000000000
--- a/app/assets/javascripts/pages/projects/logs/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import logsBundle from '~/logs';
-
-logsBundle();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 48e360ce762..2db804e1ad8 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -9,6 +9,7 @@ import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
+import { initMrExperienceSurvey } from '~/surveys/merge_request_experience';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
@@ -18,6 +19,7 @@ export default function initMergeRequestShow() {
initSourcegraph();
initIssuableSidebar();
initAwardsApp(document.getElementById('js-vue-awards-block'));
+ initMrExperienceSurvey();
const el = document.querySelector('.js-mr-status-box');
const { iid, issuableType, projectPath } = el.dataset;
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index bf4fb5f3b7e..9a7fd74fd8c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,4 +1,5 @@
-import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
+import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger';
+import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
@@ -9,11 +10,12 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
-initImportAProjectModal();
+initImportProjectMembersModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
+initImportProjectMembersTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
@@ -38,7 +40,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), {
},
},
[MEMBER_TYPES.group]: {
- tableFields: SHARED_FIELDS.concat('granted'),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
tr: { 'data-qa-selector': 'group_row' },
@@ -46,7 +48,7 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), {
requestFormatter: groupLinkRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: [],
+ tokens: ['groups_with_inherited_permissions'],
searchParam: 'search_groups',
placeholder: s__('Members|Search groups'),
recentSearchesStorageKey: 'project_group_links',
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 43ab829f5f9..6a9bd34db22 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -9,6 +9,7 @@ import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
+import { initCiSecureFiles } from '~/ci_secure_files';
// Initialize expandable settings panels
initSettingsPanels();
@@ -41,3 +42,4 @@ initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
initTokenAccess();
+initCiSecureFiles();
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 81b0dbec0bd..f2c30870a68 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
@@ -61,6 +61,10 @@ export default {
GlFormCheckbox,
GlToggle,
ConfirmDanger,
+ otherProjectSettings: () =>
+ import(
+ 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
+ ),
},
mixins: [settingsMixin, glFeatureFlagsMixin()],
@@ -182,6 +186,10 @@ export default {
required: false,
default: false,
},
+ membersPagePath: {
+ type: String,
+ required: true,
+ },
},
data() {
const defaults = {
@@ -521,12 +529,22 @@ export default {
/>
</div>
</div>
- <span v-if="!visibilityAllowed(visibilityLevel)" class="form-text text-muted">{{
- s__(
- 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.',
- )
- }}</span>
- <span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
+ <span
+ v-if="!visibilityAllowed(visibilityLevel)"
+ class="gl-display-block gl-text-gray-500 gl-mt-2"
+ >{{
+ s__(
+ 'ProjectSettings|Visibility options for this fork are limited by the current visibility of the source project.',
+ )
+ }}</span
+ >
+ <span class="gl-display-block gl-text-gray-500 gl-mt-2">
+ <gl-sprintf :message="visibilityLevelDescription">
+ <template #membersPageLink="{ content }">
+ <gl-link class="gl-link" :href="membersPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
<div v-if="showAdditonalSettings" class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
@@ -891,6 +909,7 @@ export default {
<template #help>{{ $options.i18n.pucWarningHelpText }}</template>
</gl-form-checkbox>
</project-setting-row>
+ <other-project-settings />
<confirm-danger
v-if="isVisibilityReduced"
button-variant="confirm"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index fb1acd5311c..cfca9d400e3 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -8,12 +8,10 @@ export const visibilityOptions = {
export const visibilityLevelDescriptions = {
[visibilityOptions.PRIVATE]: __(
- 'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
- ),
- [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'),
- [visibilityOptions.PUBLIC]: __(
- 'The project can be accessed by anyone, regardless of authentication.',
+ `Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`,
),
+ [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'),
+ [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'),
};
export const featureAccessLevel = {
diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js
index 11c257611f0..6eef2352e2c 100644
--- a/app/assets/javascripts/pages/projects/work_items/index.js
+++ b/app/assets/javascripts/pages/projects/work_items/index.js
@@ -1,3 +1,5 @@
import { initWorkItemsRoot } from '~/work_items/index';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
initWorkItemsRoot();
+initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index 7c23f60954a..e92f386a29e 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -3,6 +3,7 @@ import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { handleLocationHash } from '~/lib/utils/common_utils';
import { renderGFM } from '../render_gfm_facade';
export default {
@@ -43,6 +44,7 @@ export default {
this.$nextTick()
.then(() => {
renderGFM(this.$refs.content);
+ handleLocationHash();
})
.catch(() =>
createFlash({
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 024b3bc9595..3c22844434d 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -1,5 +1,16 @@
<script>
-import { GlForm, GlIcon, GlLink, GlButton, GlSprintf, GlAlert } from '@gitlab/ui';
+import {
+ GlForm,
+ GlIcon,
+ GlLink,
+ GlButton,
+ GlSprintf,
+ GlAlert,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+} from '@gitlab/ui';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility';
@@ -75,12 +86,16 @@ export default {
},
components: {
GlAlert,
+ GlIcon,
GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
GlSprintf,
- GlIcon,
GlLink,
GlButton,
MarkdownField,
+ LocalStorageSync,
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
@@ -186,6 +201,10 @@ export default {
this.useContentEditor = !this.useContentEditor;
},
+ setUseContentEditor(value) {
+ this.useContentEditor = value;
+ },
+
async handleFormSubmit(e) {
e.preventDefault();
@@ -305,150 +324,151 @@ export default {
name="wiki[last_commit_sha]"
:value="pageInfo.lastCommitSha"
/>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_title">{{
- $options.i18n.title.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <input
- id="wiki_title"
- v-model="title"
- name="wiki[title]"
- type="text"
- class="form-control"
- data-qa-selector="wiki_title_textbox"
- :required="true"
- :autofocus="!pageInfo.persisted"
- :placeholder="$options.i18n.title.placeholder"
- @input="updateCommitMessage"
- />
- <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600">
- <gl-icon class="gl-mr-n1" name="bulb" />
- {{ titleHelpText }}
- <gl-link :href="helpPath" target="_blank">
- {{ $options.i18n.title.helpText.learnMore }}
- </gl-link>
- </span>
- </div>
- </div>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_format">{{
- $options.i18n.format.label
- }}</label>
+
+ <div class="row">
+ <div class="col-sm-9">
+ <gl-form-group :label="$options.i18n.title.label" label-for="wiki_title">
+ <template #description>
+ <gl-icon class="gl-mr-n1" name="bulb" />
+ {{ titleHelpText }}
+ <gl-link :href="helpPath" target="_blank">
+ {{ $options.i18n.title.helpText.learnMore }}
+ </gl-link>
+ </template>
+
+ <gl-form-input
+ id="wiki_title"
+ v-model="title"
+ name="wiki[title]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_title_textbox"
+ :required="true"
+ :autofocus="!pageInfo.persisted"
+ :placeholder="$options.i18n.title.placeholder"
+ @input="updateCommitMessage"
+ />
+ </gl-form-group>
</div>
- <div class="col-sm-10">
- <select
- id="wiki_format"
- v-model="format"
- class="form-control"
- name="wiki[format]"
- :disabled="isContentEditorActive"
- >
- <option v-for="(key, label) of formatOptions" :key="key" :value="key">
- {{ label }}
- </option>
- </select>
+
+ <div class="col-sm-3 row-sm-10">
+ <gl-form-group :label="$options.i18n.format.label" label-for="wiki_format">
+ <gl-form-select
+ id="wiki_format"
+ v-model="format"
+ name="wiki[format]"
+ :disabled="isContentEditorActive"
+ class="form-control"
+ :value="formatOptions.Markdown"
+ >
+ <option v-for="(key, label) of formatOptions" :key="key" :value="key">
+ {{ label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
</div>
</div>
- <div class="form-group row" data-testid="wiki-form-content-fieldset">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_content">{{
- $options.i18n.content.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3">
- <gl-button
- data-testid="toggle-editing-mode-button"
- data-qa-selector="editing_mode_button"
- :data-qa-mode="toggleEditingModeButtonText"
- variant="link"
- @click="toggleEditingMode"
- >{{ toggleEditingModeButtonText }}</gl-button
- >
- </div>
- <markdown-field
- v-if="!isContentEditorActive"
- :markdown-preview-path="pageInfo.markdownPreviewPath"
- :can-attach-file="true"
- :enable-autocomplete="true"
- :textarea-value="content"
- :markdown-docs-path="pageInfo.markdownHelpPath"
- :uploads-path="pageInfo.uploadsPath"
- :enable-preview="isMarkdownFormat"
- class="bordered-box"
- >
- <template #textarea>
- <textarea
- id="wiki_content"
- ref="textarea"
- v-model="content"
- name="wiki[content]"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- data-qa-selector="wiki_content_textarea"
- :autofocus="pageInfo.persisted"
- :aria-label="$options.i18n.content.label"
- :placeholder="$options.i18n.content.placeholder"
- @input="handleContentChange"
+
+ <div class="row" data-testid="wiki-form-content-fieldset">
+ <div class="col-sm-12 row-sm-5">
+ <gl-form-group>
+ <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3">
+ <gl-button
+ data-testid="toggle-editing-mode-button"
+ data-qa-selector="editing_mode_button"
+ :data-qa-mode="toggleEditingModeButtonText"
+ variant="link"
+ @click="toggleEditingMode"
+ >{{ toggleEditingModeButtonText }}</gl-button
>
- </textarea>
- </template>
- </markdown-field>
- <div v-if="isContentEditorActive">
- <content-editor
- :render-markdown="renderMarkdown"
- :uploads-path="pageInfo.uploadsPath"
- @initialized="loadInitialContent"
- @change="handleContentEditorChange"
+ </div>
+ <local-storage-sync
+ storage-key="gl-wiki-content-editor-enabled"
+ :value="useContentEditor"
+ @input="setUseContentEditor"
/>
- <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
- </div>
+ <markdown-field
+ v-if="!isContentEditorActive"
+ :markdown-preview-path="pageInfo.markdownPreviewPath"
+ :can-attach-file="true"
+ :enable-autocomplete="true"
+ :textarea-value="content"
+ :markdown-docs-path="pageInfo.markdownHelpPath"
+ :uploads-path="pageInfo.uploadsPath"
+ :enable-preview="isMarkdownFormat"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ id="wiki_content"
+ ref="textarea"
+ v-model="content"
+ name="wiki[content]"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="wiki_content_textarea"
+ :autofocus="pageInfo.persisted"
+ :aria-label="$options.i18n.content.label"
+ :placeholder="$options.i18n.content.placeholder"
+ @input="handleContentChange"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div v-if="isContentEditorActive">
+ <content-editor
+ :render-markdown="renderMarkdown"
+ :uploads-path="pageInfo.uploadsPath"
+ @initialized="loadInitialContent"
+ @change="handleContentEditorChange"
+ />
+ <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
+ </div>
- <div class="clearfix"></div>
- <div class="error-alert"></div>
+ <div class="clearfix"></div>
+ <div class="error-alert"></div>
- <div class="form-text gl-text-gray-600">
- <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText">
- <template #linkExample
- ><code>{{ linkExample }}</code></template
+ <div class="form-text gl-text-gray-600">
+ <gl-sprintf
+ v-if="displayWikiSpecificMarkdownHelp"
+ :message="$options.i18n.linksHelpText"
>
- <template
- #link="// eslint-disable-next-line vue/no-template-shadow
+ <template #linkExample>
+ <code>{{ linkExample }}</code>
+ </template>
+ <template
+ #link="// eslint-disable-next-line vue/no-template-shadow
{ content }"
- ><gl-link
- :href="wikiSpecificMarkdownHelpPath"
- target="_blank"
- data-testid="wiki-markdown-help-link"
- >{{ content }}</gl-link
- ></template
- >
- </gl-sprintf>
- </div>
+ ><gl-link
+ :href="wikiSpecificMarkdownHelpPath"
+ target="_blank"
+ data-testid="wiki-markdown-help-link"
+ >{{ content }}</gl-link
+ ></template
+ >
+ </gl-sprintf>
+ </div>
+ </gl-form-group>
</div>
</div>
- <div class="form-group row">
- <div class="col-sm-2 col-form-label">
- <label class="control-label-full-width" for="wiki_message">{{
- $options.i18n.commitMessage.label
- }}</label>
- </div>
- <div class="col-sm-10">
- <input
- id="wiki_message"
- v-model.trim="commitMessage"
- name="wiki[message]"
- type="text"
- class="form-control"
- data-qa-selector="wiki_message_textbox"
- :placeholder="$options.i18n.commitMessage.label"
- />
+
+ <div class="row">
+ <div class="col-sm-12 row-sm-5">
+ <gl-form-group :label="$options.i18n.commitMessage.label" label-for="wiki_message">
+ <gl-form-input
+ id="wiki_message"
+ v-model.trim="commitMessage"
+ name="wiki[message]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_message_textbox"
+ :placeholder="$options.i18n.commitMessage.label"
+ />
+ </gl-form-group>
</div>
</div>
+
<div class="form-actions">
<gl-button
category="primary"
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 1da4a8fea73..a5fa85f1ed5 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -216,7 +216,7 @@ export default {
v-if="currentRequest"
:current-request="currentRequest"
:requests="requests"
- class="ml-auto"
+ class="gl-ml-auto"
@change-current-request="changeCurrentRequest"
/>
<add-request v-on="$listeners" />
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 23f1592cac1..610a570c4ce 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -19,7 +19,7 @@ export const i18n = {
invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
unavailableValidation: s__('Pipelines|Configuration validation currently not available.'),
- valid: s__('Pipelines|This GitLab CI configuration is valid.'),
+ valid: s__('Pipelines|Pipeline syntax is correct.'),
};
export default {
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 9a789ccab4d..0f19b9386e6 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -52,6 +52,11 @@ export default {
required: false,
default: false,
},
+ hideAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
isValid: {
type: Boolean,
required: true,
@@ -63,7 +68,8 @@ export default {
},
lintHelpPagePath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
warnings: {
type: Array,
@@ -96,6 +102,7 @@ export default {
<template>
<div>
<gl-alert
+ v-if="!hideAlert"
class="gl-mb-5"
:variant="status.variant"
:title="__('Status:')"
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 08d246a9a00..99ee244577e 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -16,6 +16,7 @@ import {
TAB_QUERY_PARAM,
TABS_INDEX,
VALIDATE_TAB,
+ VALIDATE_TAB_BADGE_DISMISSED_KEY,
VISUALIZE_TAB,
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
@@ -29,6 +30,7 @@ import WalkthroughPopover from './popovers/walkthrough_popover.vue';
export default {
i18n: {
+ new: __('NEW'),
tabEdit: s__('Pipelines|Edit'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
@@ -87,6 +89,10 @@ export default {
required: false,
default: '',
},
+ currentTab: {
+ type: String,
+ required: true,
+ },
isNewCiConfigFile: {
type: Boolean,
required: true,
@@ -104,6 +110,11 @@ export default {
},
},
},
+ data() {
+ return {
+ showValidateNewBadge: false,
+ };
+ },
computed: {
isMergedYamlAvailable() {
return this.ciConfigData?.mergedYaml;
@@ -123,6 +134,16 @@ export default {
isLoading() {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
+ validateTabBadgeTitle() {
+ if (this.showValidateNewBadge) {
+ return this.$options.i18n.new;
+ }
+
+ return '';
+ },
+ },
+ mounted() {
+ this.showValidateNewBadge = !JSON.parse(localStorage.getItem(VALIDATE_TAB_BADGE_DISMISSED_KEY));
},
created() {
const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM);
@@ -134,6 +155,11 @@ export default {
},
methods: {
setCurrentTab(tabName) {
+ if (this.currentTab === VALIDATE_TAB) {
+ localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true');
+ this.showValidateNewBadge = false;
+ }
+
this.$emit('set-current-tab', tabName);
},
setDefaultTab(tabName) {
@@ -189,11 +215,11 @@ export default {
v-if="glFeatures.simulatePipeline"
class="gl-mb-3"
data-testid="validate-tab"
+ :badge-title="validateTabBadgeTitle"
:title="$options.i18n.tabValidate"
@click="setCurrentTab($options.tabConstants.VALIDATE_TAB)"
>
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
- <ci-validate v-else />
+ <ci-validate :ci-file-content="ciFileContent" />
</editor-tab>
<editor-tab
v-else
diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue
new file mode 100644
index 00000000000..4730a521227
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { VALIDATE_TAB_FEEDBACK_URL } from '../../constants';
+
+export const i18n = {
+ feedbackLink: __('Provide Feedback'),
+ popoverContent: s__(
+ 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}',
+ ),
+ title: s__('PipelineEditor|Validate pipeline under simulated conditions'),
+};
+
+export default {
+ name: 'ValidatePipelinePopover',
+ directives: { Outside },
+ components: {
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ inject: ['simulatePipelineHelpPagePath'],
+ data() {
+ return {
+ showPopover: false,
+ };
+ },
+ methods: {
+ dismiss() {
+ this.showPopover = false;
+ },
+ },
+ i18n,
+ VALIDATE_TAB_FEEDBACK_URL,
+};
+</script>
+
+<template>
+ <gl-popover
+ :show.sync="showPopover"
+ target="validate-pipeline-help"
+ triggers="hover focus"
+ placement="top"
+ >
+ <p class="gl-my-3 gl-font-weight-bold">{{ $options.i18n.title }}</p>
+ <p>
+ <gl-sprintf :message="$options.i18n.popoverContent">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="simulatePipelineHelpPagePath"
+ data-testid="help-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-right gl-mb-3">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="$options.VALIDATE_TAB_FEEDBACK_URL"
+ data-testid="feedback-link"
+ >{{ $options.i18n.feedbackLink }}</gl-link
+ >
+ </p>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
index 673599da085..65f399d1912 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlTab } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlTab } from '@gitlab/ui';
import { __, s__ } from '~/locale';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
@@ -48,6 +48,7 @@ export default {
},
components: {
GlAlert,
+ GlBadge,
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
@@ -59,6 +60,16 @@ export default {
},
inheritAttrs: false,
props: {
+ badgeTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ badgeVariant: {
+ type: String,
+ required: false,
+ default: 'info',
+ },
emptyMessage: {
type: String,
required: false,
@@ -91,6 +102,10 @@ export default {
required: false,
default: false,
},
+ title: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -98,7 +113,11 @@ export default {
};
},
computed: {
+ hasBadgeTitle() {
+ return this.badgeTitle.length > 0;
+ },
slots() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Object.keys(this.$slots);
},
},
@@ -116,6 +135,12 @@ export default {
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
+ <template #title>
+ <span>{{ title }}</span>
+ <gl-badge v-if="hasBadgeTitle" class="gl-ml-2" size="sm" :variant="badgeVariant">{{
+ badgeTitle
+ }}</gl-badge>
+ </template>
<gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
<gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false">
{{ $options.i18n.unavailable }}</gl-alert
diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
index 5f26318497b..47673119db9 100644
--- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
@@ -1,9 +1,35 @@
<script>
-import { GlButton, GlDropdown, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ GlTooltip,
+ GlTooltipDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
+import CiLintResults from '../lint/ci_lint_results.vue';
+import getBlobContent from '../../graphql/queries/blob_content.query.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql';
+import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql';
export const i18n = {
+ alertDesc: s__(
+ 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}',
+ ),
+ cancelBtn: __('Cancel'),
+ contentChange: s__(
+ 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.',
+ ),
+ cta: s__('PipelineEditor|Validate pipeline'),
+ ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'),
+ errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'),
help: __('Help'),
+ loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'),
pipelineSource: s__('PipelineEditor|Pipeline Source'),
pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'),
pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'),
@@ -14,37 +40,179 @@ export const i18n = {
simulationNote: s__(
'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.',
),
- cta: s__('PipelineEditor|Validate pipeline'),
+ successAlertTitle: s__('PipelineEditor|Simulation completed successfully'),
};
+export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT';
+export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS';
+export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING';
+const BASE_CLASSES = [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-align-items-center',
+ 'gl-mt-11',
+];
+
export default {
name: 'CiValidateTab',
components: {
+ CiLintResults,
+ GlAlert,
GlButton,
GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
GlSprintf,
+ GlTooltip,
+ ValidatePipelinePopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['validateTabIllustrationPath'],
+ inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'],
+ props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ initialBlobContent: {
+ query: getBlobContent,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ path: this.ciConfigPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ return data?.project?.repository?.blobs?.nodes[0]?.rawBlob;
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches?.current?.name;
+ },
+ },
+ },
+ data() {
+ return {
+ yaml: this.ciFileContent,
+ state: VALIDATE_TAB_INIT,
+ errors: [],
+ hasCiContentChanged: false,
+ isValid: false,
+ jobs: [],
+ warnings: [],
+ };
+ },
+ computed: {
+ isInitialCiContentLoading() {
+ return this.$apollo.queries.initialBlobContent.loading;
+ },
+ isInitState() {
+ return this.state === VALIDATE_TAB_INIT;
+ },
+ isSimulationLoading() {
+ return this.state === VALIDATE_TAB_LOADING;
+ },
+ hasSimulationResults() {
+ return this.state === VALIDATE_TAB_RESULTS;
+ },
+ resultStatus() {
+ return {
+ title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle,
+ variant: this.isValid ? 'success' : 'danger',
+ };
+ },
+ },
+ watch: {
+ ciFileContent(value) {
+ this.yaml = value;
+ this.hasCiContentChanged = true;
+ },
+ },
+ methods: {
+ cancelSimulation() {
+ this.state = VALIDATE_TAB_INIT;
+ },
+ async validateYaml() {
+ this.state = VALIDATE_TAB_LOADING;
+
+ try {
+ const {
+ data: {
+ lintCI: { errors, jobs, valid, warnings },
+ },
+ } = await this.$apollo.mutate({
+ mutation: lintCiMutation,
+ variables: {
+ dry_run: true,
+ content: this.yaml,
+ endpoint: this.ciLintPath,
+ },
+ });
+
+ // only save the result if the user did not cancel the simulation
+ if (this.state === VALIDATE_TAB_LOADING) {
+ this.errors = errors;
+ this.jobs = jobs;
+ this.warnings = warnings;
+ this.isValid = valid;
+ this.state = VALIDATE_TAB_RESULTS;
+ this.hasCiContentChanged = false;
+ }
+ } catch (error) {
+ this.cancelSimulation();
+ }
+ },
+ },
i18n,
+ BASE_CLASSES,
};
</script>
<template>
<div>
- <div class="gl-mt-3">
- <label>{{ $options.i18n.pipelineSource }}</label>
- <gl-dropdown
- v-gl-tooltip.hover
- :title="$options.i18n.pipelineSourceTooltip"
- :text="$options.i18n.pipelineSourceDefault"
- disabled
- data-testid="pipeline-source"
- />
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-3">
+ <div>
+ <label>{{ $options.i18n.pipelineSource }}</label>
+ <gl-dropdown
+ v-gl-tooltip.hover
+ class="gl-ml-3"
+ :title="$options.i18n.pipelineSourceTooltip"
+ :text="$options.i18n.pipelineSourceDefault"
+ disabled
+ data-testid="pipeline-source"
+ />
+ <validate-pipeline-popover />
+ <gl-icon
+ id="validate-pipeline-help"
+ name="question-o"
+ class="gl-ml-1 gl-fill-blue-500"
+ category="secondary"
+ variant="confirm"
+ :aria-label="$options.i18n.help"
+ />
+ </div>
+ <div v-if="hasSimulationResults && hasCiContentChanged">
+ <span class="gl-text-gray-400" data-testid="content-status">
+ {{ $options.i18n.contentChange }}
+ </span>
+ <gl-button
+ variant="confirm"
+ class="gl-ml-2 gl-mb-2"
+ data-testid="resimulate-pipeline-button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
</div>
- <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <div v-if="isInitState" :class="$options.BASE_CLASSES">
<img :src="validateTabIllustrationPath" />
<h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1>
<ul>
@@ -57,9 +225,61 @@ export default {
</gl-sprintf>
</li>
</ul>
- <gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline">
- {{ $options.i18n.cta }}
- </gl-button>
+ <div ref="simulatePipelineButton">
+ <gl-button
+ ref="simulatePipelineButton"
+ variant="confirm"
+ class="gl-mt-3"
+ :disabled="isInitialCiContentLoading"
+ data-testid="simulate-pipeline-button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ <gl-tooltip
+ v-if="isInitialCiContentLoading"
+ :target="() => $refs.simulatePipelineButton"
+ :title="$options.i18n.ctaDisabledTooltip"
+ data-testid="cta-tooltip"
+ />
+ </div>
+ <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES">
+ <gl-loading-icon size="lg" class="gl-m-3" />
+ <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1>
+ <div>
+ <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation">
+ {{ $options.i18n.cancelBtn }}
+ </gl-button>
+ <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button">
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-else-if="hasSimulationResults" class="gl-mt-5">
+ <gl-alert
+ class="gl-mb-5"
+ :dismissible="false"
+ :title="resultStatus.title"
+ :variant="resultStatus.variant"
+ >
+ <gl-sprintf :message="$options.i18n.alertDesc">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link target="_blank" href="#">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <ci-lint-results
+ dry-run
+ hide-alert
+ :is-valid="isValid"
+ :jobs="jobs"
+ :errors="errors"
+ :warnings="warnings"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 8f688e6ba76..05db0afd15d 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -54,6 +54,7 @@ export const SOURCE_EDITOR_DEBOUNCE = 500;
export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display';
export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed';
export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed';
+export const VALIDATE_TAB_BADGE_DISMISSED_KEY = 'pipeline_editor_validate_tab_badge_dismissed';
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
@@ -81,6 +82,7 @@ export const pipelineEditorTrackingOptions = {
export const TEMPLATE_REPOSITORY_URL =
'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
+export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687';
export const COMMIT_SHA_POLL_INTERVAL = 1000;
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 4caa253b85e..4f5b69107bf 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -27,6 +27,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
+ ciLintPath,
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
@@ -40,6 +41,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
+ simulatePipelineHelpPagePath,
totalBranches,
validateTabIllustrationPath,
ymlHelpPagePath,
@@ -115,6 +117,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
+ ciLintPath,
configurationPaths,
dataMethod: 'graphql',
defaultBranch,
@@ -130,6 +133,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
+ simulatePipelineHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
validateTabIllustrationPath,
ymlHelpPagePath,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index f26cdd8b017..2d5c01a58b7 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -150,6 +150,7 @@ export default {
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
:commit-sha="commitSha"
+ :current-tab="currentTab"
:is-new-ci-config-file="isNewCiConfigFile"
:show-drawer="showDrawer"
v-on="$listeners"
diff --git a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
index 5efae2471e5..c9649b2f2f7 100644
--- a/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
@@ -3,10 +3,12 @@ import { isNode, isDocument, isSeq, visit } from 'yaml';
import { capitalize } from 'lodash';
import TextWidget from '~/pipeline_wizard/components/widgets/text.vue';
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
+import ChecklistWidget from '~/pipeline_wizard/components/widgets/checklist.vue';
const widgets = {
TextWidget,
ListWidget,
+ ChecklistWidget,
};
function isNullOrUndefined(v) {
@@ -30,8 +32,9 @@ export default {
},
target: {
type: String,
- required: true,
+ required: false,
validator: (v) => /^\$.*/g.test(v),
+ default: null,
},
widget: {
type: String,
@@ -48,6 +51,7 @@ export default {
},
computed: {
path() {
+ if (!this.target) return null;
let res;
visit(this.template, (seqKey, node, path) => {
if (node && node.value === this.target) {
diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue
index 220b068f747..c6ee883aec8 100644
--- a/app/assets/javascripts/pipeline_wizard/components/step.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/step.vue
@@ -31,10 +31,7 @@ export default {
inputs: {
type: Array,
required: true,
- validator: (value) =>
- value.every((i) => {
- return i?.target && i?.widget;
- }),
+ validator: (value) => value.every((i) => i?.widget),
},
template: {
type: null,
@@ -131,7 +128,7 @@ export default {
:template="template"
:validate="validate"
:widget="input.widget"
- class="gl-mb-2"
+ class="gl-mb-8"
v-bind="input"
@highlight="onHighlight"
@update:valid="(validationState) => onInputValidationStateChange(i, validationState)"
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
new file mode 100644
index 00000000000..f2b159acfee
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlFormGroup, GlFormCheckbox, GlFormCheckboxGroup } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+const isValidItemDefinition = (value) => {
+ // The Item definition should either be a simple string
+ // or an object with at least a "title" property
+ return typeof value === 'string' || Boolean(value.text);
+};
+
+export default {
+ name: 'ChecklistWidget',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormCheckboxGroup,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ items: {
+ type: Array,
+ required: false,
+ validator: (v) => v.every(isValidItemDefinition),
+ default: () => [],
+ },
+ validate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ checklistItems() {
+ return this.items.map((rawItem) => {
+ const id = rawItem.id || uniqueId();
+ return {
+ id,
+ text: rawItem.text || rawItem,
+ help: rawItem.help || null,
+ };
+ });
+ },
+ },
+ created() {
+ if (this.items.length > 0) {
+ this.$emit('update:valid', false);
+ }
+ },
+ methods: {
+ updateValidState(values) {
+ this.$emit(
+ 'update:valid',
+ this.checklistItems.every((item) => values.includes(item.id)),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group #default="{ ariaDescribedby }" :label="title">
+ <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState">
+ <gl-form-checkbox
+ v-for="item in checklistItems"
+ :id="item.id"
+ :key="item.id"
+ :value="item.id"
+ >
+ {{ item.text }}
+ <template v-if="item.help" #help>
+ {{ item.help }}
+ </template>
+ </gl-form-checkbox>
+ </gl-form-checkbox-group>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index f50cd175510..0fe87bcee7b 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -128,6 +128,7 @@ export default {
:filename="filename"
:project-path="projectPath"
@back="currentStepIndex--"
+ @done="$emit('done')"
/>
<wizard-step
v-for="(step, i) in stepList"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 7200b4e3782..939702fd1b5 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -60,6 +60,7 @@ export default {
:filename="filename"
:project-path="projectPath"
:steps="steps"
+ @done="$emit('done')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep b/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
+++ /dev/null
diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
new file mode 100644
index 00000000000..cd2242b1ba7
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
@@ -0,0 +1,53 @@
+title: Get started with Pages
+description: "GitLab Pages lets you deploy static websites in minutes. All you
+ need is a .gitlab-ci.yml file. Follow the below steps to
+ create one for your app now."
+steps:
+ - inputs:
+ - label: Select your build image
+ description: A Docker image that we can use to build your image
+ placeholder: node:lts
+ widget: text
+ target: $BUILD_IMAGE
+ required: true
+ pattern: "(?:[a-z]+/)?([a-z]+)(?::[0-9]+)?"
+ invalid-feedback: Please enter a valid docker image
+ - widget: checklist
+ title: "Before we begin, please check:"
+ items:
+ - text: The app's built output files are in a folder named "public"
+ help: GitLab Pages will only publish files in that folder.
+ You may need to adjust your build engine's config.
+ template:
+ # The Docker image that will be used to build your app
+ image: $BUILD_IMAGE
+ - inputs:
+ - label: Installation Steps
+ description: "Enter the steps that need to run to set up a local build
+ environment, for example installing dependencies."
+ placeholder: npm ci
+ widget: list
+ target: $INSTALLATION_STEPS
+ template:
+ # Functions that should be executed before the build script is run
+ before_script: $INSTALLATION_STEPS
+ - inputs:
+ - label: Build Steps
+ description: "Enter the steps necessary to build a production version of
+ your application."
+ widget: list
+ target: $BUILD_STEPS
+ template:
+
+ pages:
+ script: $BUILD_STEPS
+
+ artifacts:
+ paths:
+ # The folder that contains the files to be exposed at the Page URL
+ - public
+
+ rules:
+ # This ensures that only pushes to the default branch will trigger
+ # a pages deploy
+ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index f822e2c0874..14872c34afb 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -281,6 +281,7 @@ export default {
:type="graphViewType"
:show-links="showLinks"
:tip-previously-dismissed="hoverTipPreviouslyDismissed"
+ :is-pipeline-complete="pipeline.complete"
@dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index 1920fed84ec..a8c5d85f4ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,17 +1,33 @@
<script>
-import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlLoadingIcon,
+ GlToggle,
+ GlModalDirective,
+} from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import PerformanceInsightsModal from '../performance_insights_modal.vue';
+import { performanceModalId } from '../../constants';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
+ performanceModalId,
components: {
GlAlert,
GlButton,
GlButtonGroup,
GlLoadingIcon,
GlToggle,
+ PerformanceInsightsModal,
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [Tracking.mixin()],
props: {
showLinks: {
type: Boolean,
@@ -25,6 +41,10 @@ export default {
type: String,
required: true,
},
+ isPipelineComplete: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -39,6 +59,7 @@ export default {
hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
linksLabelText: s__('GraphViewType|Show dependencies'),
viewLabelText: __('Group jobs by'),
+ performanceBtnText: __('Performance insights'),
},
views: {
[STAGE_VIEW]: {
@@ -129,6 +150,9 @@ export default {
this.$emit('updateShowLinksState', val);
});
},
+ trackInsightsClick() {
+ this.track('click_insights_button', { label: 'performance_insights' });
+ },
},
};
</script>
@@ -154,6 +178,15 @@ export default {
</gl-button>
</gl-button-group>
+ <gl-button
+ v-if="isPipelineComplete"
+ v-gl-modal="$options.performanceModalId"
+ data-testid="pipeline-insights-btn"
+ @click="trackInsightsClick"
+ >
+ {{ $options.i18n.performanceBtnText }}
+ </gl-button>
+
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
v-model="showLinksActive"
@@ -169,5 +202,7 @@ export default {
<gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
{{ $options.i18n.hoverTipText }}
</gl-alert>
+
+ <performance-insights-modal />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 37878f3fb6d..fabae62fc45 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -63,6 +63,18 @@ export default {
default: '',
},
},
+ modal: {
+ id: DELETE_MODAL_ID,
+ actionPrimary: {
+ text: __('Delete pipeline'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
apollo: {
pipeline: {
context() {
@@ -275,7 +287,7 @@ export default {
<gl-button
v-if="pipeline.userPermissions.destroyPipeline"
- v-gl-modal="$options.DELETE_MODAL_ID"
+ v-gl-modal="$options.modal.id"
:loading="isDeleting"
:disabled="isDeleting"
class="gl-ml-3"
@@ -289,11 +301,11 @@ export default {
<gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
- :modal-id="$options.DELETE_MODAL_ID"
+ :modal-id="$options.modal.id"
:title="__('Delete pipeline')"
- :ok-title="__('Delete pipeline')"
- ok-variant="danger"
- @ok="deletePipeline()"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="deletePipeline()"
>
<p>
{{ deleteModalConfirmationText }}
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index 070c5ee59de..0c6b8b9ed2b 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -96,7 +96,7 @@ export default {
<template #cell(actions)="{ item }">
<gl-button
v-if="canRetryJob(item)"
- icon="repeat"
+ icon="retry"
:title="$options.retry"
:aria-label="$options.retry"
@click="retryJob(item.id)"
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
new file mode 100644
index 00000000000..ae6b9186930
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql';
+import { performanceModalId } from '../constants';
+import { calculateJobStats, calculateSlowestFiveJobs } from '../utils';
+
+export default {
+ name: 'PerformanceInsightsModal',
+ i18n: {
+ queuedCardHeader: s__('Pipeline|Longest queued job'),
+ queuedCardHelp: s__(
+ 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner',
+ ),
+ executedCardHeader: s__('Pipeline|Last executed job'),
+ executedCardHelp: s__(
+ 'Pipeline|The last executed job is the last job to start in the pipeline.',
+ ),
+ viewDependency: s__('Pipeline|View dependency'),
+ slowJobsTitle: s__('Pipeline|Five slowest jobs'),
+ feeback: __('Feedback issue'),
+ insightsLimit: s__('Pipeline|Only able to show first 100 results'),
+ },
+ modal: {
+ title: s__('Pipeline|Performance insights'),
+ actionCancel: {
+ text: __('Close'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ },
+ performanceModalId,
+ components: {
+ GlAlert,
+ GlCard,
+ GlLink,
+ GlModal,
+ GlLoadingIcon,
+ HelpPopover,
+ },
+ inject: {
+ pipelineIid: {
+ default: '',
+ },
+ pipelineProjectPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: getPerformanceInsightsQuery,
+ variables() {
+ return {
+ fullPath: this.pipelineProjectPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return data.project?.pipeline?.jobs;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: null,
+ };
+ },
+ computed: {
+ longestQueuedJob() {
+ return calculateJobStats(this.jobs, 'queuedDuration');
+ },
+ lastExecutedJob() {
+ return calculateJobStats(this.jobs, 'startedAt');
+ },
+ slowestFiveJobs() {
+ return calculateSlowestFiveJobs(this.jobs);
+ },
+ queuedDurationDisplay() {
+ return humanizeTimeInterval(this.longestQueuedJob.queuedDuration);
+ },
+ showLimitMessage() {
+ return this.jobs.pageInfo.hasNextPage;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :modal-id="$options.performanceModalId"
+ :title="$options.modal.title"
+ :action-cancel="$options.modal.actionCancel"
+ >
+ <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
+
+ <template v-else>
+ <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false">
+ <p>{{ $options.i18n.insightsLimit }}</p>
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5">
+ {{ $options.i18n.feeback }}
+ </gl-link>
+ </gl-alert>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-7">
+ <gl-card class="gl-w-half gl-mr-7 gl-text-center">
+ <template #header>
+ <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span>
+ <help-popover>
+ {{ $options.i18n.queuedCardHelp }}
+ </help-popover>
+ </template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span
+ class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
+ data-testid="insights-queued-card-data"
+ >
+ {{ queuedDurationDisplay }}
+ </span>
+ <gl-link
+ :href="longestQueuedJob.detailedStatus.detailsPath"
+ data-testid="insights-queued-card-link"
+ >
+ {{ longestQueuedJob.name }}
+ </gl-link>
+ </div>
+ </gl-card>
+ <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card">
+ <template #header>
+ <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span>
+ <help-popover>
+ {{ $options.i18n.executedCardHelp }}
+ </help-popover>
+ </template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span
+ class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
+ data-testid="insights-executed-card-data"
+ >
+ {{ lastExecutedJob.name }}
+ </span>
+ <gl-link
+ :href="lastExecutedJob.detailedStatus.detailsPath"
+ data-testid="insights-executed-card-link"
+ >
+ {{ $options.i18n.viewDependency }}
+ </gl-link>
+ </div>
+ </gl-card>
+ </div>
+
+ <div class="gl-mt-7">
+ <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span>
+ <div
+ v-for="job in slowestFiveJobs"
+ :key="job.name"
+ class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100"
+ >
+ <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span>
+ <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{
+ job.name
+ }}</gl-link>
+ </div>
+ </div>
+ </template>
+ </gl-modal>
+</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 fa0e153b2af..7a08dacb824 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -80,7 +80,7 @@ export default {
class="js-pipelines-retry-button"
data-qa-selector="pipeline_retry_button"
data-testid="pipelines-retry-button"
- icon="repeat"
+ icon="retry"
variant="default"
category="secondary"
@click="handleRetryClick"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
index 76ee6ab613b..69509c9088b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -1,5 +1,6 @@
<script>
-import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui';
+import { GlBadge, GlFriendlyWrap, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import { __, n__, s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue';
@@ -11,6 +12,10 @@ export default {
GlFriendlyWrap,
GlLink,
GlModal,
+ ModalCopyButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
modalId: {
@@ -57,6 +62,7 @@ export default {
history: __('History'),
trace: __('System output'),
attachment: s__('TestReports|Attachment'),
+ copyTestName: s__('TestReports|Copy test name to rerun locally'),
},
modalCloseButton: {
text: __('Close'),
@@ -85,6 +91,13 @@ export default {
{{ testCase.file }}
</gl-link>
<span v-else>{{ testCase.file }}</span>
+ <modal-copy-button
+ :title="$options.text.copyTestName"
+ :text="testCase.file"
+ :modal-id="modalId"
+ category="tertiary"
+ class="gl-ml-1"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 58d072b0005..3fb46a4f128 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import createTestReportsStore from '../../stores/test_reports';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
@@ -15,9 +16,10 @@ export default {
TestSummary,
TestSummaryTable,
},
+ inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'],
computed: {
- ...mapState(['isLoading', 'selectedSuiteIndex', 'testReports']),
- ...mapGetters(['getSelectedSuite']),
+ ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']),
+ ...mapGetters('testReports', ['getSelectedSuite']),
showSuite() {
return this.selectedSuiteIndex !== null;
},
@@ -27,10 +29,19 @@ export default {
},
},
created() {
+ this.$store.registerModule(
+ 'testReports',
+ createTestReportsStore({
+ blobPath: this.blobPath,
+ summaryEndpoint: this.summaryEndpoint,
+ suiteEndpoint: this.suiteEndpoint,
+ }),
+ );
+
this.fetchSummary();
},
methods: {
- ...mapActions([
+ ...mapActions('testReports', [
'fetchTestSuite',
'fetchSummary',
'setSelectedSuiteIndex',
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 1e481d37017..1f438c63fee 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -51,14 +51,18 @@ export default {
},
},
computed: {
- ...mapState(['pageInfo']),
- ...mapGetters(['getSuiteTests', 'getSuiteTestCount', 'getSuiteArtifactsExpired']),
+ ...mapState('testReports', ['pageInfo']),
+ ...mapGetters('testReports', [
+ 'getSuiteTests',
+ 'getSuiteTestCount',
+ 'getSuiteArtifactsExpired',
+ ]),
hasSuites() {
return this.getSuiteTests.length > 0;
},
},
methods: {
- ...mapActions(['setPage']),
+ ...mapActions('testReports', ['setPage']),
},
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
i18n,
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 2b44ce57faa..8389c2a5104 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -19,7 +19,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getTestSuites']),
+ ...mapGetters('testReports', ['getTestSuites']),
hasSuites() {
return this.getTestSuites.length > 0;
},
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 0510992e962..2e825016c91 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -109,3 +109,5 @@ export const DEFAULT_FIELDS = [
columnClass: 'gl-w-20p',
},
];
+
+export const performanceModalId = 'performanceInsightsModal';
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
new file mode 100644
index 00000000000..25e990c8934
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
@@ -0,0 +1,28 @@
+query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs {
+ pageInfo {
+ hasNextPage
+ }
+ nodes {
+ id
+ duration
+ detailedStatus {
+ id
+ detailsPath
+ }
+ name
+ stage {
+ id
+ name
+ }
+ startedAt
+ queuedDuration
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index e7c00d89a10..c0e769e2485 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { removeParams, updateHistory } from '~/lib/utils/url_utility';
@@ -7,6 +8,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
+Vue.use(Vuex);
export const createAppOptions = (selector, apolloProvider) => {
const el = document.querySelector(selector);
@@ -37,6 +39,7 @@ export const createAppOptions = (selector, apolloProvider) => {
PipelineTabs,
},
apolloProvider,
+ store: new Vuex.Store(),
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js
index 27ab2418440..fe4ca8e9529 100644
--- a/app/assets/javascripts/pipelines/pipeline_test_details.js
+++ b/app/assets/javascripts/pipelines/pipeline_test_details.js
@@ -1,9 +1,10 @@
import Vue from 'vue';
+import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '~/vue_shared/translate';
import TestReports from './components/test_reports/test_reports.vue';
-import createTestReportsStore from './stores/test_reports';
+Vue.use(Vuex);
Vue.use(Translate);
export const createTestDetails = (selector) => {
@@ -16,11 +17,6 @@ export const createTestDetails = (selector) => {
suiteEndpoint,
artifactsExpiredImagePath,
} = el?.dataset || {};
- const testReportsStore = createTestReportsStore({
- blobPath,
- summaryEndpoint,
- suiteEndpoint,
- });
// eslint-disable-next-line no-new
new Vue({
@@ -32,8 +28,11 @@ export const createTestDetails = (selector) => {
emptyStateImagePath,
artifactsExpiredImagePath,
hasTestReport: parseBoolean(hasTestReport),
+ blobPath,
+ summaryEndpoint,
+ suiteEndpoint,
},
- store: testReportsStore,
+ store: new Vuex.Store(),
render(createElement) {
return createElement('test-reports');
},
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/pipelines/stores/test_reports/constants.js
index 8eebfb6b208..83d14e1a109 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/constants.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/constants.js
@@ -1 +1 @@
-export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts have expired';
+export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts not found';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js
index 64d4b8bafb1..f45a53f47b7 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/index.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js
@@ -1,16 +1,14 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
-Vue.use(Vuex);
-
-export default (initialState) =>
- new Vuex.Store({
+export default (initialState) => {
+ return {
+ namespaced: true,
actions,
getters,
mutations,
state: state(initialState),
- });
+ };
+};
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 588d15495ab..83e00b80426 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -153,3 +153,24 @@ export const getPipelineDefaultTab = (url) => {
return null;
};
+
+export const calculateJobStats = (jobs, sortField) => {
+ const jobNodes = [...jobs.nodes];
+
+ const sorted = jobNodes.sort((a, b) => {
+ return b[sortField] - a[sortField];
+ });
+
+ return sorted[0];
+};
+
+export const calculateSlowestFiveJobs = (jobs) => {
+ const jobNodes = [...jobs.nodes];
+ const limit = 5;
+
+ return jobNodes
+ .sort((a, b) => {
+ return b.duration - a.duration;
+ })
+ .slice(0, limit);
+};
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index 00fe0bcf89b..f208280af27 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -30,7 +30,7 @@ export default () => {
deleteAccountModal,
},
mounted() {
- deleteAccountButton.classList.remove('disabled');
+ deleteAccountButton.disabled = false;
deleteAccountButton.addEventListener('click', () => {
this.$root.$emit(BV_SHOW_MODAL, 'delete-account-modal', '#delete-account-button');
});
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 25fefff219c..064bcf8e4c4 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import createFlash, { FLASH_TYPES } from '~/flash';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { Rails } from '~/lib/utils/rails_ujs';
@@ -10,7 +10,7 @@ import TimezoneDropdown, {
export default class Profile {
constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this);
- this.form = form || $('.edit-user');
+ this.form = form || $('.js-edit-user');
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
@@ -84,9 +84,9 @@ export default class Profile {
this.updateHeaderAvatar();
}
- createFlash({
+ createAlert({
message: data.message,
- type: data.status === 'error' ? FLASH_TYPES.ALERT : FLASH_TYPES.NOTICE,
+ variant: data.status === 'error' ? VARIANT_DANGER : VARIANT_INFO,
});
})
.then(() => {
@@ -95,8 +95,9 @@ export default class Profile {
self.form.find(':input[disabled]').enable();
})
.catch((error) =>
- createFlash({
+ createAlert({
message: error.message,
+ variant: VARIANT_DANGER,
}),
);
}
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 9bd78b7c89e..1cdf26b76b7 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -43,7 +43,11 @@ export default {
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getLinkedPipelinesQuery,
+ pollInterval: COMMIT_BOX_POLL_INTERVAL,
variables() {
return {
fullPath: this.fullPath,
@@ -116,6 +120,7 @@ export default {
},
mounted() {
toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
},
};
</script>
diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
index 833e946af5c..bc2c16b9e83 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_details_button.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
@@ -1,9 +1,7 @@
-import $ from 'jquery';
-
export const initDetailsButton = () => {
- $('body').on('click', '.js-details-expand', function expand(e) {
+ document.querySelector('.commit-info').addEventListener('click', function expand(e) {
e.preventDefault();
- $(this).next('.js-details-content').removeClass('hide');
- $(this).hide();
+ this.querySelector('.js-details-content').classList.remove('hide');
+ this.querySelector('.js-details-expand').classList.add('gl-display-none');
});
};
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 884ef732144..f85be67d4b3 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -110,7 +110,7 @@ export default {
:text="dropdownText"
:disabled="hasSearchParam"
toggle-class="gl-py-3 gl-border-0"
- class="w-100 mt-2 mt-sm-0"
+ class="w-100 gl-mt-3 mt-sm-0"
>
<gl-dropdown-section-header>
{{ __('Search by author') }}
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index 3945bed9649..bda58091b97 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -121,27 +121,21 @@ export default {
@selectRevision="onSelectRevision"
/>
</div>
- <div class="gl-mt-6">
+ <div class="gl-display-flex gl-mt-6 gl-gap-3">
<gl-button category="primary" variant="confirm" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
- <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision">
+ <gl-button data-testid="swapRevisionsButton" @click="onSwapRevision">
{{ s__('CompareRevisions|Swap revisions') }}
</gl-button>
<gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
data-testid="projectMrButton"
- class="btn btn-default gl-button"
>
{{ s__('CompareRevisions|View open merge request') }}
</gl-button>
- <gl-button
- v-else-if="createMrPath"
- :href="createMrPath"
- data-testid="createMrButton"
- class="btn btn-default gl-button"
- >
+ <gl-button v-else-if="createMrPath" :href="createMrPath" data-testid="createMrButton">
{{ s__('CompareRevisions|Create merge request') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 476d6466cbb..59ca393fe92 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -16,7 +16,7 @@ const PANELS = [
selector: '#blank-project-pane',
title: s__('ProjectsNew|Create blank project'),
description: s__(
- 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.',
+ 'ProjectsNew|Create a blank project to store your files, plan your work, and collaborate on code, among other things.',
),
illustration: blankProjectIllustration,
},
diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
index 506f1ec5ffd..eccfb3d844c 100644
--- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -7,6 +7,7 @@ import {
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlTruncate,
} from '@gitlab/ui';
import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
@@ -26,6 +27,7 @@ export default {
GlDropdownText,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlTruncate,
},
mixins: [Tracking.mixin()],
apollo: {
@@ -55,10 +57,7 @@ export default {
id: this.namespaceId,
fullPath: this.namespaceFullPath,
}
- : {
- id: undefined,
- fullPath: s__('ProjectsNew|Pick a group or namespace'),
- },
+ : this.$options.emptyNameSpace,
shouldSkipQuery: true,
userNamespaceId: this.userNamespaceId,
};
@@ -118,12 +117,18 @@ export default {
this.setNamespace({ id, fullPath });
},
setNamespace({ id, fullPath }) {
- this.selectedNamespace = {
- id: getIdFromGraphQLId(id),
- fullPath,
- };
+ this.selectedNamespace = id
+ ? {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ }
+ : this.$options.emptyNameSpace;
},
},
+ emptyNameSpace: {
+ id: undefined,
+ fullPath: s__('ProjectsNew|Pick a group or namespace'),
+ },
};
</script>
@@ -137,13 +142,20 @@ export default {
>
<gl-dropdown
- :text="selectedNamespace.fullPath"
class="js-group-namespace-dropdown gl-flex-grow-1"
:toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`"
data-qa-selector="select_namespace_dropdown"
@show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
@shown="handleDropdownShown"
>
+ <template #button-text>
+ <gl-truncate
+ v-if="selectedNamespace.fullPath"
+ :text="selectedNamespace.fullPath"
+ position="start"
+ with-tooltip
+ />
+ </template>
<gl-search-box-by-type
ref="search"
v-model.trim="search"
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 35e7554aee2..186fcf70838 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -14,12 +14,15 @@ export default {
LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
TimeToRestoreServiceCharts: () =>
import('ee_component/dora/components/time_to_restore_service_charts.vue'),
+ ChangeFailureRateCharts: () =>
+ import('ee_component/dora/components/change_failure_rate_charts.vue'),
ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'),
},
piplelinesTabEvent: 'p_analytics_ci_cd_pipelines',
deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency',
leadTimeTabEvent: 'p_analytics_ci_cd_lead_time',
timeToRestoreServiceTabEvent: 'p_analytics_ci_cd_time_to_restore_service',
+ changeFailureRateTabEvent: 'p_analytics_ci_cd_change_failure_rate',
inject: {
shouldRenderDoraCharts: {
type: Boolean,
@@ -40,7 +43,12 @@ export default {
const chartsToShow = ['pipelines'];
if (this.shouldRenderDoraCharts) {
- chartsToShow.push('deployment-frequency', 'lead-time', 'time-to-restore-service');
+ chartsToShow.push(
+ 'deployment-frequency',
+ 'lead-time',
+ 'time-to-restore-service',
+ 'change-failure-rate',
+ );
}
if (this.shouldRenderQualitySummary) {
@@ -105,6 +113,13 @@ export default {
>
<time-to-restore-service-charts />
</gl-tab>
+ <gl-tab
+ :title="s__('DORA4Metrics|Change failure rate')"
+ data-testid="change-failure-rate-tab"
+ @click="trackTabClick($options.changeFailureRateTabEvent)"
+ >
+ <change-failure-rate-charts />
+ </gl-tab>
</template>
<gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')">
<project-quality-summary />
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 186946a83ad..fe84660422b 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -342,6 +342,7 @@ const bindEvents = () => {
export default {
bindEvents,
+ validateGroupNamespaceDropdown,
deriveProjectPathFromUrl,
onProjectNameChange,
onProjectPathChange,
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index d299e106b14..b8ac17a01f2 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -10,7 +10,7 @@ const visibilityLevel = {
};
function setVisibilityOptions({ name, visibility, showPath, editPath }) {
- document.querySelectorAll('.visibility-level-setting .form-check').forEach((option) => {
+ document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => {
// Don't change anything if the option is restricted by admin
if (option.classList.contains('restricted')) {
return;
@@ -24,7 +24,7 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) {
optionInput.disabled = true;
const reason = option.querySelector('.option-disabled-reason');
if (reason) {
- const optionTitle = option.querySelector('.option-title');
+ const optionTitle = option.querySelector('.js-visibility-level-radio span');
const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
reason.innerHTML = sprintf(
__(
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index 79dfa166b1a..060178a3cfb 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -441,11 +441,13 @@ export default class AccessDropdown {
const {
id,
fingerprint,
+ fingerprint_sha256: fingerprintSha256,
title,
owner: { avatar_url, name, username },
} = response;
- const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+ const availableFingerprint = fingerprintSha256 || fingerprint;
+ const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`;
return {
id,
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index 9823b0229a0..fcf81c9d1f7 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -203,11 +203,13 @@ export default {
const {
id,
fingerprint,
+ fingerprint_sha256: fingerprintSha256,
title,
owner: { avatar_url, name, username },
} = response;
- const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+ const availableFingerprint = fingerprintSha256 || fingerprint;
+ const shortFingerprint = `(${availableFingerprint.substring(0, 14)}...)`;
return {
id,
@@ -351,7 +353,6 @@ export default {
<gl-dropdown-item
v-for="group in groups"
:key="`${group.id}${group.name}`"
- fingerprint
data-testid="group-dropdown-item"
:avatar-url="group.avatar_url"
is-check-item
@@ -388,7 +389,7 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="key in deployKeys"
- :key="`${key.id}${key.fingerprint}`"
+ :key="`${key.id}-{key.title}`"
data-testid="deploy_key-dropdown-item"
is-check-item
:is-checked="isSelected(key)"
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 578e22ca25d..5bbace11b15 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -1,31 +1,33 @@
-import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
export default class Star {
- constructor(container = '.project-home-panel') {
- $(`${container} .toggle-star`).on('click', function toggleStarClickCallback() {
- const $this = $(this);
- const $starSpan = $this.find('span');
- const $starIcon = $this.find('svg');
- const iconClasses = $starIcon.attr('class').split(' ');
+ constructor(containerSelector = '.project-home-panel') {
+ const container = document.querySelector(containerSelector);
+ const starToggle = container.querySelector('.toggle-star');
+ starToggle.addEventListener('click', function toggleStarClickCallback() {
+ const starSpan = starToggle.querySelector('span');
+ const starIcon = starToggle.querySelector('svg');
+ const iconClasses = Array.from(starIcon.classList.values());
axios
- .post($this.data('endpoint'))
+ .post(starToggle.dataset.endpoint)
.then(({ data }) => {
- const isStarred = $starSpan.hasClass('starred');
- $this.parent().find('.count').text(data.star_count);
+ const isStarred = starSpan.classList.contains('starred');
+ starToggle.parentNode.querySelector('.count').textContent = data.star_count;
if (isStarred) {
- $starSpan.removeClass('starred').text(s__('StarProject|Star'));
- $starIcon.remove();
- $this.prepend(spriteIcon('star-o', iconClasses));
+ starSpan.classList.remove('starred');
+ starSpan.textContent = s__('StarProject|Star');
+ starIcon.remove();
+ starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses));
} else {
- $starSpan.addClass('starred').text(__('Unstar'));
- $starIcon.remove();
- $this.prepend(spriteIcon('star', iconClasses));
+ starSpan.classList.add('starred');
+ starSpan.textContent = __('Unstar');
+ starIcon.remove();
+ starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses));
}
})
.catch(() =>
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 42de419aec4..d765033d00b 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -173,7 +173,7 @@ export default {
:label="issuableCategoryHeaderText"
label-for="linked-issue-type-radio"
label-class="label-bold"
- class="mb-2"
+ class="gl-mb-3"
>
<gl-form-radio-group
id="linked-issue-type-radio"
@@ -216,12 +216,12 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="float-left"
+ class="gl-float-left"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
- <gl-button class="float-right" @click="onFormCancel">
+ <gl-button class="gl-float-right" @click="onFormCancel">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 327da1fb2a1..022c3224bb4 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlFormCheckbox, GlFormInput, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDatepicker,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -7,6 +15,7 @@ import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import AssetLinksForm from './asset_links_form.vue';
+import ConfirmDeleteModal from './confirm_delete_modal.vue';
import TagField from './tag_field.vue';
export default {
@@ -16,8 +25,10 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
+ GlDatepicker,
GlLink,
GlSprintf,
+ ConfirmDeleteModal,
MarkdownField,
AssetLinksForm,
MilestoneCombobox,
@@ -25,12 +36,14 @@ export default {
},
computed: {
...mapState('editNew', [
+ 'isExistingRelease',
'isFetchingRelease',
'isUpdatingRelease',
'fetchError',
'markdownDocsPath',
'markdownPreviewPath',
'editReleaseDocsPath',
+ 'upcomingReleaseDocsPath',
'releasesPagePath',
'release',
'newMilestonePath',
@@ -40,7 +53,7 @@ export default {
'groupMilestonesAvailable',
'tagNotes',
]),
- ...mapGetters('editNew', ['isValid', 'isExistingRelease', 'formattedReleaseNotes']),
+ ...mapGetters('editNew', ['isValid', 'formattedReleaseNotes']),
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
@@ -76,6 +89,14 @@ export default {
this.updateIncludeTagNotes(includeTagNotes);
},
},
+ releasedAt: {
+ get() {
+ return this.release.releasedAt;
+ },
+ set(date) {
+ this.updateReleasedAt(date);
+ },
+ },
cancelPath() {
const backUrl = getParameterByName(BACK_URL_PARAM);
@@ -114,10 +135,12 @@ export default {
...mapActions('editNew', [
'initializeRelease',
'saveRelease',
+ 'deleteRelease',
'updateReleaseTitle',
'updateReleaseNotes',
'updateReleaseMilestones',
'updateIncludeTagNotes',
+ 'updateReleasedAt',
]),
submitForm() {
if (!this.isFormSubmissionDisabled) {
@@ -166,6 +189,22 @@ export default {
/>
</div>
</gl-form-group>
+ <gl-form-group :label="__('Release date')" label-for="release-released-at">
+ <template #label-description>
+ <gl-sprintf
+ :message="
+ __(
+ 'The date when the release is ready. A release with a date in the future is labeled as an %{linkStart}Upcoming Release%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="upcomingReleaseDocsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-datepicker id="release-released-at" v-model="releasedAt" :default-date="releasedAt" />
+ </gl-form-group>
<gl-form-group data-testid="release-notes">
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
@@ -224,6 +263,7 @@ export default {
>
{{ saveButtonLabel }}
</gl-button>
+ <confirm-delete-modal v-if="isExistingRelease" @delete="deleteRelease" />
<gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index a949a9d1318..d63a83d1a08 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -4,9 +4,9 @@ import createFlash from '~/flash';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import { convertAllReleasesGraphQLResponse, deleteReleaseSessionKey } from '~/releases/util';
import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -172,6 +172,20 @@ export default {
return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
+ mounted() {
+ const key = deleteReleaseSessionKey(this.projectPath);
+ const deletedRelease = window.sessionStorage.getItem(key);
+
+ if (deletedRelease) {
+ this.$toast.show(
+ sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
+ deletedRelease,
+ }),
+ );
+ }
+
+ window.sessionStorage.removeItem(key);
+ },
created() {
this.updateQueryParamsFromUrl();
diff --git a/app/assets/javascripts/releases/components/confirm_delete_modal.vue b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
new file mode 100644
index 00000000000..aa948fbbaf6
--- /dev/null
+++ b/app/assets/javascripts/releases/components/confirm_delete_modal.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { __, s__, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlButton,
+ },
+ data() {
+ return {
+ visible: false,
+ };
+ },
+ computed: {
+ ...mapState('editNew', ['release', 'deleteReleaseDocsPath']),
+ title() {
+ return sprintf(__('Delete release %{release}?'), { release: this.release.name });
+ },
+ },
+ modalOptions: {
+ modalId: 'confirm-delete-release',
+ static: true,
+ actionPrimary: {
+ attributes: { variant: 'danger' },
+ text: __('Delete release'),
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: { variant: 'default' },
+ },
+ },
+ i18n: {
+ buttonLabel: __('Delete'),
+ line1: s__(
+ 'DeleteRelease|You are about to delete release %{release} and its assets. The Git tag %{tag} will not be deleted.',
+ ),
+ line2: s__(
+ 'DeleteRelease|For more details, see %{docsPathStart}Deleting a release%{docsPathEnd}.',
+ ),
+ line3: s__('DeleteRelease|Are you sure you want to delete this release?'),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button class="gl-mr-3" variant="danger" @click="visible = true">
+ {{ $options.i18n.buttonLabel }}
+ </gl-button>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ v-model="visible"
+ :title="title"
+ @primary="$emit('delete')"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.line1">
+ <template #release>{{ release.name }}</template>
+ <template #tag>
+ <gl-link :href="release.tagPath">{{ release.tagName }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.i18n.line2">
+ <template #docsPath="{ content }">
+ <gl-link :href="deleteReleaseDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>{{ $options.i18n.line3 }}</p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index 91d6d0911a4..3881c83b5c2 100644
--- a/app/assets/javascripts/releases/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
@@ -42,9 +42,9 @@ export default {
default: null,
},
releasedAt: {
- type: String,
+ type: Date,
required: false,
- default: '',
+ default: null,
},
},
computed: {
@@ -66,8 +66,11 @@ export default {
</script>
<template>
<div>
- <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info">
- <gl-icon ref="commitIcon" name="commit" class="mr-1" />
+ <div
+ v-if="commit"
+ class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-commit-info"
+ >
+ <gl-icon ref="commitIcon" name="commit" class="gl-mr-2" />
<div v-gl-tooltip.bottom :title="commit.title">
<gl-link v-if="commitPath" :href="commitPath">
{{ commit.shortId }}
@@ -76,8 +79,11 @@ export default {
</div>
</div>
- <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info">
- <gl-icon name="tag" class="mr-1" />
+ <div
+ v-if="tagName"
+ class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-tag-info"
+ >
+ <gl-icon name="tag" class="gl-mr-2" />
<div v-gl-tooltip.bottom :title="__('Tag')">
<gl-link v-if="tagPath" :href="tagPath">
{{ tagName }}
@@ -88,23 +94,23 @@ export default {
<div
v-if="releasedAt || author"
- class="float-left d-flex align-items-center js-author-date-info"
+ class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info"
>
- <span class="text-secondary">{{ createdTime }}&nbsp;</span>
+ <span class="gl-text-secondary">{{ createdTime }}&nbsp;</span>
<template v-if="releasedAt">
<span
v-gl-tooltip.bottom
:title="tooltipTitle(releasedAt)"
- class="text-secondary flex-shrink-0"
+ class="gl-text-secondary gl-flex-shrink-0"
>
{{ releasedAtTimeAgo }}&nbsp;
</span>
</template>
- <div v-if="author" class="d-flex">
- <span class="text-secondary">{{ __('by') }}&nbsp;</span>
+ <div v-if="author" class="gl-display-flex">
+ <span class="gl-text-secondary">{{ __('by') }}&nbsp;</span>
<user-avatar-link
- class="gl-my-n1"
+ class="gl-my-n1 gl-display-flex"
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="userImageAltDescription"
diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue
index f4c0fd5e9ce..b4fea9bee35 100644
--- a/app/assets/javascripts/releases/components/tag_field.vue
+++ b/app/assets/javascripts/releases/components/tag_field.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapState } from 'vuex';
import TagFieldExisting from './tag_field_existing.vue';
import TagFieldNew from './tag_field_new.vue';
@@ -9,7 +9,7 @@ export default {
TagFieldNew,
},
computed: {
- ...mapGetters('editNew', ['isExistingRelease']),
+ ...mapState('editNew', ['isExistingRelease']),
},
};
</script>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index d3b6d07590f..08b727dcca0 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -22,12 +22,10 @@ export default {
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
-
- showCreateFrom: true,
};
},
computed: {
- ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']),
...mapGetters('editNew', ['validationErrors']),
tagName: {
get() {
@@ -40,7 +38,7 @@ export default {
// When this is called, the selection originated from the
// dropdown list of existing tag names, so we know the tag
// already exists and don't need to show the "create from" input
- this.showCreateFrom = false;
+ this.updateShowCreateFrom(false);
},
},
createFromModel: {
@@ -70,7 +68,12 @@ export default {
},
},
methods: {
- ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom', 'fetchTagNotes']),
+ ...mapActions('editNew', [
+ 'updateReleaseTagName',
+ 'updateCreateFrom',
+ 'fetchTagNotes',
+ 'updateShowCreateFrom',
+ ]),
markInputAsDirty() {
this.isInputDirty = true;
},
@@ -80,7 +83,7 @@ export default {
// This method is called when the user selects the "create tag"
// option, so the tag does not already exist. Because of this,
// we need to show the "create from" input.
- this.showCreateFrom = true;
+ this.updateShowCreateFrom(true);
},
shouldShowCreateTagOption(isLoading, matches, query) {
// Show the "create tag" option if:
diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
index 236d266a40a..3ad66afa259 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
@@ -3,6 +3,8 @@ fragment ReleaseForEditing on Release {
name
tagName
description
+ releasedAt
+ tagPath
assets {
links {
nodes {
diff --git a/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql
new file mode 100644
index 00000000000..7a8bf9944a3
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/delete_release.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteRelease($input: ReleaseDeleteInput!) {
+ releaseDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index fad0451ceef..c3130a0b778 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- editNew: createEditNewModule(el.dataset),
+ editNew: createEditNewModule({ ...el.dataset, isExistingRelease: true }),
},
});
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index afb8ab461cd..8e806f0e8d7 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
@@ -7,6 +8,7 @@ export default () => {
const el = document.getElementById('js-releases-page');
Vue.use(VueApollo);
+ Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index b358a27f06d..0a3f8b5e63b 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- editNew: createEditNewModule(el.dataset),
+ editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }),
},
});
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 08197377f61..a71a8125d65 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -3,16 +3,21 @@ import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
+import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql';
-import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import {
+ gqClient,
+ convertOneReleaseGraphQLResponse,
+ deleteReleaseSessionKey,
+} from '~/releases/util';
import * as types from './mutation_types';
-export const initializeRelease = ({ commit, dispatch, getters }) => {
- if (getters.isExistingRelease) {
+export const initializeRelease = ({ commit, dispatch, state }) => {
+ if (state.isExistingRelease) {
// When editing an existing release,
// fetch the release object from the API
return dispatch('fetchRelease');
@@ -53,6 +58,9 @@ export const updateReleaseTagName = ({ commit }, tagName) =>
export const updateCreateFrom = ({ commit }, createFrom) =>
commit(types.UPDATE_CREATE_FROM, createFrom);
+export const updateShowCreateFrom = ({ commit }, showCreateFrom) =>
+ commit(types.UPDATE_SHOW_CREATE_FROM, showCreateFrom);
+
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
@@ -88,10 +96,10 @@ export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
redirectTo(urlToRedirectTo);
};
-export const saveRelease = ({ commit, dispatch, getters }) => {
+export const saveRelease = ({ commit, dispatch, state }) => {
commit(types.REQUEST_SAVE_RELEASE);
- dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
+ dispatch(state.isExistingRelease ? 'updateRelease' : 'createRelease');
};
/**
@@ -246,3 +254,30 @@ export const fetchTagNotes = ({ commit, state }, tagName) => {
export const updateIncludeTagNotes = ({ commit }, includeTagNotes) => {
commit(types.UPDATE_INCLUDE_TAG_NOTES, includeTagNotes);
};
+
+export const updateReleasedAt = ({ commit }, releasedAt) => {
+ commit(types.UPDATE_RELEASED_AT, releasedAt);
+};
+
+export const deleteRelease = ({ commit, getters, dispatch, state }) => {
+ commit(types.REQUEST_SAVE_RELEASE);
+ return gqClient
+ .mutate({
+ mutation: deleteReleaseMutation,
+ variables: getters.releaseDeleteMutationVariables,
+ })
+ .then((response) => checkForErrorsAsData(response, 'releaseDelete', ''))
+ .then(() => {
+ window.sessionStorage.setItem(
+ deleteReleaseSessionKey(state.projectPath),
+ state.originalRelease.name,
+ );
+ return dispatch('receiveSaveReleaseSuccess', state.releasesPagePath);
+ })
+ .catch((error) => {
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash({
+ message: s__('Release|Something went wrong while deleting the release.'),
+ });
+ });
+};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 0ca5eb9931a..62d6bd42d51 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -4,14 +4,6 @@ import { hasContent } from '~/lib/utils/text_utility';
import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
/**
- * @returns {Boolean} `true` if the app is editing an existing release.
- * `false` if the app is creating a new release.
- */
-export const isExistingRelease = (state) => {
- return Boolean(state.tagName);
-};
-
-/**
* @param {Object} link The link to test
* @returns {Boolean} `true` if the release link is empty, i.e. it has
* empty (or whitespace-only) values for both `url` and `name`.
@@ -138,6 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => {
projectPath: state.projectPath,
tagName: state.release.tagName,
name,
+ releasedAt: state.release.releasedAt,
description: state.includeTagNotes
? getters.formattedReleaseNotes
: state.release.description,
@@ -163,6 +156,13 @@ export const releaseCreateMutatationVariables = (state, getters) => {
};
};
+export const releaseDeleteMutationVariables = (state) => ({
+ input: {
+ projectPath: state.projectPath,
+ tagName: state.release.tagName,
+ },
+});
+
export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) =>
includeTagNotes && tagNotes
? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index daa077309a1..0ef017f4eb4 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
@@ -6,6 +6,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME';
export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM';
+export const UPDATE_SHOW_CREATE_FROM = 'UPDATE_SHOW_CREATE_FROM';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
@@ -26,3 +27,4 @@ export const RECEIVE_TAG_NOTES_SUCCESS = 'RECEIVE_TAG_NOTES_SUCCESS';
export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR';
export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES';
+export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index 6b22468bbfe..ea794f91f66 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -9,11 +9,12 @@ const findReleaseLink = (release, id) => {
export default {
[types.INITIALIZE_EMPTY_RELEASE](state) {
state.release = {
- tagName: null,
+ tagName: state.tagName,
name: '',
description: '',
milestones: [],
groupMilestones: [],
+ releasedAt: new Date(),
assets: {
links: [],
},
@@ -41,6 +42,9 @@ export default {
[types.UPDATE_CREATE_FROM](state, createFrom) {
state.createFrom = createFrom;
},
+ [types.UPDATE_SHOW_CREATE_FROM](state, showCreateFrom) {
+ state.showCreateFrom = showCreateFrom;
+ },
[types.UPDATE_RELEASE_TITLE](state, title) {
state.release.name = title;
},
@@ -113,4 +117,7 @@ export default {
[types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) {
state.includeTagNotes = includeTagNotes;
},
+ [types.UPDATE_RELEASED_AT](state, releasedAt) {
+ state.release.releasedAt = releasedAt;
+ },
};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 33cb3ee06d0..cb447cf9aaf 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -1,4 +1,5 @@
export default ({
+ isExistingRelease,
projectId,
groupId,
groupMilestonesAvailable = false,
@@ -10,10 +11,13 @@ export default ({
newMilestonePath,
releasesPagePath,
editReleaseDocsPath,
+ upcomingReleaseDocsPath,
+ deleteReleaseDocsPath = '',
tagName = null,
defaultBranch = null,
}) => ({
+ isExistingRelease,
projectId,
groupId,
groupMilestonesAvailable: Boolean(groupMilestonesAvailable),
@@ -25,12 +29,15 @@ export default ({
newMilestonePath,
releasesPagePath,
editReleaseDocsPath,
+ upcomingReleaseDocsPath,
+ deleteReleaseDocsPath,
/**
* The name of the tag associated with the release, provided by the backend.
- * When creating a new release, this value is null.
+ * When creating a new release, this is the default from the URL
*/
tagName,
+ showCreateFrom: !tagName,
defaultBranch,
createFrom: defaultBranch,
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index 22d5fb4f620..f1f5f4bca4c 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -11,10 +11,13 @@ const convertScalarProperties = (graphQLRelease) =>
'tagPath',
'description',
'descriptionHtml',
- 'releasedAt',
'upcomingRelease',
]);
+const convertDateProperties = ({ releasedAt }) => ({
+ releasedAt: new Date(releasedAt),
+});
+
const convertAssets = (graphQLRelease) => {
let sources = [];
if (graphQLRelease.assets.sources?.nodes) {
@@ -88,6 +91,7 @@ const convertMilestones = (graphQLRelease) => ({
*/
export const convertGraphQLRelease = (graphQLRelease) => ({
...convertScalarProperties(graphQLRelease),
+ ...convertDateProperties(graphQLRelease),
...convertAssets(graphQLRelease),
...convertEvidences(graphQLRelease),
...convertLinks(graphQLRelease),
@@ -129,3 +133,5 @@ export const convertOneReleaseGraphQLResponse = (response) => {
return { data: release };
};
+
+export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 92d0783749e..ee55368c829 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -84,7 +84,7 @@ export default {
</div>
</div>
<div
- v-if="$slots.default"
+ v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row"
>
<slot></slot>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 280455c3fed..bf4f19504f0 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -97,6 +97,7 @@ export default {
project: DEFAULT_BLOB_INFO.project,
gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
currentUser: DEFAULT_BLOB_INFO.currentUser,
+ useFallback: false,
};
},
computed: {
@@ -130,7 +131,7 @@ export default {
},
shouldLoadLegacyViewer() {
const isTextFile = this.viewer.fileType === TEXT_FILE_TYPE && !this.glFeatures.highlightJs;
- return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType);
+ return isTextFile || LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback;
},
legacyViewerLoaded() {
return (
@@ -173,6 +174,10 @@ export default {
},
},
methods: {
+ onError() {
+ this.useFallback = true;
+ this.loadLegacyViewer();
+ },
loadLegacyViewer() {
if (this.legacyViewerLoaded) {
return;
@@ -303,7 +308,7 @@ export default {
:loading="isLoadingLegacyViewer"
:data-loading="isRenderingLegacyTextViewer"
/>
- <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" />
+ <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" @error="onError" />
<code-intelligence
v-if="blobViewer || legacyViewerLoaded"
:code-navigation-path="blobInfo.codeNavigationPath"
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index d24d7648f1b..9f2cf8505d3 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -49,10 +49,11 @@ export default {
};
},
update: (data) => {
- const pipelines = data.project?.repository?.tree?.lastCommit?.pipelines?.edges;
+ const lastCommit = data.project?.repository?.paginatedTree?.nodes[0]?.lastCommit;
+ const pipelines = lastCommit?.pipelines?.edges;
return {
- ...data.project?.repository?.tree?.lastCommit,
+ ...lastCommit,
pipeline: pipelines?.length && pipelines[0].node,
};
},
@@ -131,7 +132,9 @@ export default {
:css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (7731 epics) */"
:size="32"
/>
- <div class="commit-detail flex-list">
+ <div
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
+ >
<div class="commit-content qa-commit-content">
<gl-link
v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 41f7a4b147f..1f6b5e98122 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -103,14 +103,12 @@ export default {
return this.rowNumbers[key];
},
- getCommit(fileName, type) {
+ getCommit(fileName) {
if (!this.glFeatures.lazyLoadCommits) {
return {};
}
- return this.commits.find(
- (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type,
- );
+ return this.commits.find((commitEntry) => commitEntry.fileName === fileName);
},
},
};
@@ -152,7 +150,7 @@ export default {
:loading-path="loadingPath"
:total-entries="totalEntries"
:row-number="generateRowNumber(entry.flatPath, entry.id, index)"
- :commit-info="getCommit(entry.name, entry.type)"
+ :commit-info="getCommit(entry.name)"
v-on="$listeners"
/>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 2b910109f7d..99b7395d6e7 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -43,7 +43,6 @@ export default {
variables() {
return {
fileName: this.name,
- type: this.type,
path: this.currentPath,
projectPath: this.projectPath,
maxOffset: this.totalEntries,
@@ -135,14 +134,11 @@ export default {
commitData() {
return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit;
},
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
routerLinkTo() {
const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` };
const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` };
- if (this.refactorBlobViewerEnabled && this.isBlob) {
+ if (this.isBlob) {
return blobRouteConfig;
}
@@ -158,7 +154,7 @@ export default {
return this.type === 'commit';
},
linkComponent() {
- return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a';
+ return this.isFolder || this.isBlob ? 'router-link' : 'a';
},
fullPath() {
return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), '');
@@ -187,10 +183,6 @@ export default {
});
},
loadBlob() {
- if (!this.refactorBlobViewerEnabled) {
- return;
- }
-
this.apolloQuery(blobInfoQuery, {
projectPath: this.projectPath,
filePath: this.path,
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 2cafeed2ef4..0e80f306638 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -93,7 +93,6 @@ export const LFS_STORAGE = 'lfs';
* These are file types that we want the legacy (backend) syntax highlighter to highlight.
*/
export const LEGACY_FILE_TYPES = [
- 'package_json',
'gemfile',
'gemspec',
'composer_json',
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 29aabe1b00f..3a59a02af01 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -9,7 +9,7 @@ Vue.use(VueApollo);
const defaultClient = createDefaultClient(
{
Query: {
- commit(_, { path, fileName, type, maxOffset }) {
+ commit(_, { path, fileName, maxOffset }) {
return new Promise((resolve) => {
fetchLogsTree(
defaultClient,
@@ -19,7 +19,6 @@ const defaultClient = createDefaultClient(
resolve,
entry: {
name: fileName,
- type,
},
},
maxOffset,
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 8f8735a6371..1d295e18332 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -91,9 +91,7 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
- if (gon.features.refactorBlobViewer) {
- initBlobControlsApp();
- }
+ initBlobControlsApp();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index ac02392d60f..9345a8406e3 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -16,9 +16,7 @@ function setNextOffset(offset) {
}
export function resolveCommit(commits, path, { resolve, entry }) {
- const commit = commits.find(
- (c) => c.filePath === `${path}/${entry.name}` && c.type === entry.type,
- );
+ const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`);
if (commit) {
resolve(commit);
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
index b046fc1f730..80dedfe3e3f 100644
--- a/app/assets/javascripts/repository/queries/commit.fragment.graphql
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -6,5 +6,4 @@ fragment TreeEntryCommit on LogTreeCommit {
commitPath
fileName
filePath
- type
}
diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql
index 7ae4a3b984a..1a01462bd19 100644
--- a/app/assets/javascripts/repository/queries/commit.query.graphql
+++ b/app/assets/javascripts/repository/queries/commit.query.graphql
@@ -1,7 +1,7 @@
#import "ee_else_ce/repository/queries/commit.fragment.graphql"
-query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Number!) {
- commit(path: $path, fileName: $fileName, type: $type, maxOffset: $maxOffset) @client {
+query getCommit($fileName: String!, $path: String!, $maxOffset: Number!) {
+ commit(path: $path, fileName: $fileName, maxOffset: $maxOffset) @client {
...TreeEntryCommit
}
}
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index a67252ec004..878b4fdd71a 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -7,7 +7,6 @@ export function normalizeData(data, path, extra = () => {}) {
commitPath: d.commit_path,
fileName: d.file_name,
filePath: `${path}/${d.file_name}`,
- type: d.type,
__typename: 'LogTreeCommit',
...extra(d),
}));
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
index 06a8eb790fc..9fa4b521ebc 100644
--- 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
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -11,7 +11,7 @@ import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import RunnerJobs from '../components/runner_jobs.vue';
-import { I18N_FETCH_ERROR } from '../constants';
+import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
@@ -20,6 +20,7 @@ export default {
name: 'AdminRunnerShowApp',
components: {
GlBadge,
+ GlTabs,
GlTab,
RunnerDeleteButton,
RunnerEditButton,
@@ -84,6 +85,7 @@ export default {
redirectTo(this.runnersPath);
},
},
+ I18N_DETAILS,
};
</script>
<template>
@@ -96,24 +98,27 @@ export default {
</template>
</runner-header>
- <runner-details :runner="runner">
- <template #jobs-tab>
- <gl-tab>
- <template #title>
- {{ s__('Runners|Jobs') }}
- <gl-badge
- v-if="jobCount"
- data-testid="job-count-badge"
- class="gl-tab-counter-badge"
- size="sm"
- >
- {{ jobCount }}
- </gl-badge>
- </template>
+ <gl-tabs>
+ <gl-tab>
+ <template #title>{{ $options.I18N_DETAILS }}</template>
- <runner-jobs v-if="runner" :runner="runner" />
- </gl-tab>
- </template>
- </runner-details>
+ <runner-details v-if="runner" :runner="runner" />
+ </gl-tab>
+ <gl-tab>
+ <template #title>
+ {{ s__('Runners|Jobs') }}
+ <gl-badge
+ v-if="jobCount"
+ data-testid="job-count-badge"
+ class="gl-tab-counter-badge"
+ size="sm"
+ >
+ {{ jobCount }}
+ </gl-badge>
+ </template>
+
+ <runner-jobs v-if="runner" :runner="runner" />
+ </gl-tab>
+ </gl-tabs>
</div>
</template>
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 a90ef2d3530..f6b7a8b46d7 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,10 +1,17 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+ isSearchFiltered,
+} from 'ee_else_ce/runner/runner_search_utils';
+import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -20,74 +27,12 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
-import {
- ADMIN_FILTERED_SEARCH_NAMESPACE,
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
- I18N_FETCH_ERROR,
-} from '../constants';
-import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql';
-import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql';
-import {
- fromUrlQueryToSearch,
- fromSearchToUrl,
- fromSearchToVariables,
- isSearchFiltered,
-} from '../runner_search_utils';
+import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: runnersAdminCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'AdminRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -119,7 +64,7 @@ export default {
},
apollo: {
runners: {
- query: runnersAdminQuery,
+ query: allRunnersQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
@@ -137,31 +82,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- instanceRunnersCount: {
- ...tabCountSmartQuery({ type: INSTANCE_TYPE }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner stats
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -186,6 +106,7 @@ export default {
...tagTokenConfig,
recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
},
+ upgradeStatusTokenConfig,
];
},
isBulkDeleteEnabled() {
@@ -214,39 +135,10 @@ export default {
this.reportToSentry(error);
},
methods: {
- tabCount({ runnerType }) {
- let count;
- switch (runnerType) {
- case null:
- count = this.allRunnersCount;
- break;
- case INSTANCE_TYPE:
- count = this.instanceRunnersCount;
- 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 '';
- },
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.instanceRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -271,18 +163,14 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.INSTANCE_TYPE"
+ :count-variables="countVariables"
class="gl-w-full"
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-w-full gl-sm-w-auto gl-mr-auto"
@@ -298,11 +186,7 @@ export default {
:namespace="$options.filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.INSTANCE_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
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 09d46ce3e66..667cb0090b3 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
@@ -8,14 +8,16 @@ import runnersRegistrationTokenResetMutation from '~/runner/graphql/list/runners
import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
+const i18n = {
+ modalAction: s__('Runners|Reset token'),
+ modalCancel: __('Cancel'),
+ modalCopy: __('Are you sure you want to reset the registration token?'),
+ modalTitle: __('Reset registration token'),
+};
+
export default {
name: 'RunnerRegistrationTokenReset',
- i18n: {
- modalAction: s__('Runners|Reset token'),
- modalCancel: __('Cancel'),
- modalCopy: __('Are you sure you want to reset the registration token?'),
- modalTitle: __('Reset registration token'),
- },
+ i18n,
components: {
GlDropdownItem,
GlLoadingIcon,
@@ -68,6 +70,18 @@ export default {
return null;
}
},
+ actionPrimary() {
+ return {
+ text: i18n.modalAction,
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ actionSecondary() {
+ return {
+ text: i18n.modalCancel,
+ attributes: [{ variant: 'default' }],
+ };
+ },
},
methods: {
handleModalPrimary() {
@@ -115,14 +129,8 @@ export default {
<gl-modal
size="sm"
:modal-id="$options.modalId"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: $options.i18n.modalAction,
- attributes: [{ variant: 'danger' }],
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: $options.i18n.modalCancel,
- attributes: [{ variant: 'default' }],
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :action-primary="actionPrimary"
+ :action-secondary="actionSecondary"
:title="$options.i18n.modalTitle"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index b1234818b7e..db67acef3db 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -41,6 +41,7 @@ export default {
<div class="gl-display-flex gl-pb-4">
<dt class="gl-mr-2">{{ label }}</dt>
<dd class="gl-mb-0">
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-if="value || $slots.value">
<slot name="value">{{ value }}</slot>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index 75ddec6c716..60469d26dd5 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
+import { 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';
@@ -11,14 +11,16 @@ import RunnerTags from './runner_tags.vue';
export default {
components: {
- GlTabs,
- GlTab,
GlIntersperse,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
RunnerGroups,
RunnerProjects,
+ RunnerUpgradeStatusBadge: () =>
+ import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
+ RunnerUpgradeStatusAlert: () =>
+ import('ee_component/runner/components/runner_upgrade_status_alert.vue'),
RunnerTags,
TimeAgo,
},
@@ -61,58 +63,57 @@ export default {
</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|Executor')" :value="runner.executorName" />
- <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
- <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
- <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>
-
- <runner-maintenance-note-detail
- class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
- :value="runner.maintenanceNoteHtml"
+ <div>
+ <runner-upgrade-status-alert class="gl-my-4" :runner="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')">
+ <template v-if="runner.version" #value>
+ {{ runner.version }}
+ <runner-upgrade-status-badge size="sm" :runner="runner" />
+ </template>
+ </runner-detail>
+ <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" />
+ <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" />
+ <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
+ <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
+ <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"
/>
- </dl>
- </div>
+ </template>
+ </runner-detail>
+
+ <runner-maintenance-note-detail
+ class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
+ :value="runner.maintenanceNoteHtml"
+ />
+ </dl>
+ </div>
- <runner-groups v-if="isGroupRunner" :runner="runner" />
- <runner-projects v-if="isProjectRunner" :runner="runner" />
- </template>
- </gl-tab>
- <slot name="jobs-tab"></slot>
- </gl-tabs>
+ <runner-groups v-if="isGroupRunner" :runner="runner" />
+ <runner-projects v-if="isProjectRunner" :runner="runner" />
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index f0f8bbdf5df..bff5ec9b238 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -45,7 +45,7 @@ export default {
},
},
data() {
- // filtered_search_bar_root.vue may mutate the inital
+ // filtered_search_bar_root.vue may mutate the initial
// filters. Use `cloneDeep` to prevent those mutations
// from affecting this component
const { filters, sort } = cloneDeep(this.value);
@@ -54,6 +54,14 @@ export default {
initialSortBy: sort,
};
},
+ computed: {
+ validTokens() {
+ // Some filters are only available in EE
+ // EE-only tokens are represented by `null` or `undefined`
+ // values when in CE
+ return this.tokens.filter(Boolean);
+ },
+ },
methods: {
onFilter(filters) {
// Apply new filters, from page 1
@@ -83,7 +91,7 @@ export default {
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
- :tokens="tokens"
+ :tokens="validTokens"
:initial-sort-by="initialSortBy"
:search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search"
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
index 25ed6600dc9..6b9e3bf91ad 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -1,6 +1,7 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { searchValidator } from '~/runner/runner_search_utils';
+import { formatNumber } from '~/locale';
import {
INSTANCE_TYPE,
GROUP_TYPE,
@@ -10,6 +11,7 @@ import {
I18N_GROUP_TYPE,
I18N_PROJECT_TYPE,
} from '../constants';
+import RunnerCount from './stat/runner_count.vue';
const I18N_TAB_TITLES = {
[INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
@@ -17,10 +19,14 @@ const I18N_TAB_TITLES = {
[PROJECT_TYPE]: I18N_PROJECT_TYPE,
};
+const TAB_COUNT_REF = 'tab-count';
+
export default {
components: {
+ GlBadge,
GlTabs,
GlTab,
+ RunnerCount,
},
props: {
runnerTypes: {
@@ -33,6 +39,14 @@ export default {
required: true,
validator: searchValidator,
},
+ countScope: {
+ type: String,
+ required: true,
+ },
+ countVariables: {
+ type: Object,
+ required: true,
+ },
},
computed: {
tabs() {
@@ -62,7 +76,25 @@ export default {
isTabActive({ runnerType }) {
return runnerType === this.value.runnerType;
},
+ tabBadgeCountVariables(runnerType) {
+ return { ...this.countVariables, type: runnerType };
+ },
+ tabCount(count) {
+ if (typeof count === 'number') {
+ return formatNumber(count);
+ }
+ return '';
+ },
+
+ // Component API
+ refetch() {
+ // Refresh all of the counts here, can be called by parent component
+ this.$refs[TAB_COUNT_REF].forEach((countComponent) => {
+ countComponent.refetch();
+ });
+ },
},
+ TAB_COUNT_REF,
};
</script>
<template>
@@ -74,7 +106,17 @@ export default {
@click="onTabSelected(tab)"
>
<template #title>
- <slot name="title" :tab="tab">{{ tab.title }}</slot>
+ {{ tab.title }}
+ <runner-count
+ #default="{ count }"
+ :ref="$options.TAB_COUNT_REF"
+ :scope="countScope"
+ :variables="tabBadgeCountVariables(tab.runnerType)"
+ >
+ <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm">
+ {{ tabCount(count) }}
+ </gl-badge>
+ </runner-count>
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
index 1bab875a8a1..c1ad5da3ab9 100644
--- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
@@ -22,7 +22,7 @@ export const pausedTokenConfig = {
// contain spaces!
// see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(' ', '\u00a0'),
+ title: title.replace(/\s/g, '\u00a0'),
})),
operators: OPERATOR_IS_ONLY,
};
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
index f28bd491ea5..9e6f63d3f7c 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -30,7 +30,7 @@ export const statusTokenConfig = {
// contain spaces!
// see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- title: title.replace(' ', '\u00a0'),
+ title: title.replace(/\s/g, '\u00a0'),
})),
operators: OPERATOR_IS_ONLY,
};
diff --git a/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js
new file mode 100644
index 00000000000..17ee7073360
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/upgrade_status_token_config.js
@@ -0,0 +1,2 @@
+// Overridden in EE
+export const upgradeStatusTokenConfig = null;
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue
new file mode 100644
index 00000000000..af18b203f90
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_count.vue
@@ -0,0 +1,103 @@
+<script>
+import { fetchPolicies } from '~/lib/graphql';
+import { captureException } from '../../sentry_utils';
+import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql';
+import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
+
+/**
+ * Renderless component that wraps a "count" query for the
+ * number of runners that follow a filter criteria.
+ *
+ * Example usage:
+ *
+ * Render the count of "online" runners in the instance in a
+ * <strong/> tag.
+ *
+ * ```vue
+ * <runner-count-stat
+ * #default="{ count }"
+ * :scope="INSTANCE_TYPE"
+ * :variables="{ status: 'ONLINE' }"
+ * >
+ * <strong>{{ count }}</strong>
+ * </runner-count-stat>
+ * ```
+ *
+ * Use `:skip="true"` to prevent data from being fetched and
+ * even rendered.
+ */
+export default {
+ name: 'RunnerCount',
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val),
+ },
+ variables: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ skip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return { count: null };
+ },
+ apollo: {
+ count: {
+ query() {
+ if (this.scope === INSTANCE_TYPE) {
+ return allRunnersCountQuery;
+ } else if (this.scope === GROUP_TYPE) {
+ return groupRunnersCountQuery;
+ }
+ return null;
+ },
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ variables() {
+ return this.variables;
+ },
+ skip() {
+ if (this.skip) {
+ // Don't show data for skipped stats
+ this.count = null;
+ }
+ return this.skip;
+ },
+ update(data) {
+ if (this.scope === INSTANCE_TYPE) {
+ return data?.runners?.count;
+ } else if (this.scope === GROUP_TYPE) {
+ return data?.group?.runners?.count;
+ }
+ return null;
+ },
+ error(error) {
+ this.reportToSentry(error);
+ },
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+
+ // Component API
+ refetch() {
+ // Parent components can use this method to refresh the count
+ this.$apollo.queries.count.refetch();
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ count: this.count,
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index d3693ee593e..9e1ca9ba4ee 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,49 +1,47 @@
<script>
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
+import RunnerCount from './runner_count.vue';
import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
+ RunnerCount,
RunnerStatusStat,
},
props: {
- onlineRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ scope: {
+ type: String,
+ required: true,
},
- offlineRunnersCount: {
- type: Number,
+ variables: {
+ type: Object,
required: false,
- default: null,
+ default: () => {},
},
- staleRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ },
+ methods: {
+ countVariables(vars) {
+ return { ...this.variables, ...vars };
+ },
+ statusCountSkip(status) {
+ // Show an empty result when we already filter by another status
+ return this.variables.status && this.variables.status !== status;
},
},
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
+ STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE],
};
</script>
<template>
<div class="gl-display-flex gl-py-6">
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_ONLINE"
- :value="onlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_OFFLINE"
- :value="offlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_STALE"
- :value="staleRunnersCount"
- />
+ <runner-count
+ v-for="status in $options.STATUS_LIST"
+ #default="{ count }"
+ :key="status"
+ :scope="scope"
+ :variables="countVariables({ status })"
+ :skip="statusCountSkip(status)"
+ >
+ <runner-status-stat class="gl-px-5" :status="status" :value="count" />
+ </runner-count>
</div>
</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index b9621c26b59..64541729701 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -81,6 +81,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
// Runner details
+export const I18N_DETAILS = s__('Runners|Details');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
index 61bfe03bf6e..6bb896dda16 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
@@ -1,7 +1,7 @@
#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getRunners(
+query getAllRunners(
$before: String
$after: String
$first: Int
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql
index 1dd258a3524..82591b88d3e 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners_count.query.graphql
@@ -1,4 +1,4 @@
-query getRunnersCount(
+query getAllRunnersCount(
$paused: Boolean
$status: CiRunnerStatus
$type: CiRunnerType
diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
index c336e091fdf..75138b1bd81 100644
--- a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
@@ -1,16 +1,13 @@
<script>
-import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import { formatJobCount } from '../utils';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
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 RunnerJobs from '../components/runner_jobs.vue';
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
@@ -19,17 +16,11 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'GroupRunnerShowApp',
components: {
- GlBadge,
- GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
- RunnerJobs,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
props: {
runnerId: {
@@ -40,6 +31,11 @@ export default {
type: String,
required: true,
},
+ editGroupRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -68,9 +64,6 @@ export default {
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
- jobCount() {
- return formatJobCount(this.runner?.jobCount);
- },
},
errorCaptured(error) {
this.reportToSentry(error);
@@ -90,25 +83,12 @@ export default {
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
- <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" />
<runner-pause-button v-if="canUpdate" :runner="runner" />
<runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
</template>
</runner-header>
- <runner-details :runner="runner">
- <template #jobs-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>
- </template>
- </runner-details>
+ <runner-details v-if="runner" :runner="runner" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js
index d1b87c8e427..62a0dab9211 100644
--- a/app/assets/javascripts/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/runner/group_runner_show/index.js
@@ -1,21 +1,18 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
-export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
- showAlertFromLocalStorage();
-
+export const initGroupRunnerShow = (selector = '#js-group-runner-show') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
- const { runnerId, runnersPath } = el.dataset;
+ const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -29,6 +26,7 @@ export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
props: {
runnerId,
runnersPath,
+ editGroupRunnerPath,
},
});
},
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 641b3a8f560..e8446dbe345 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,8 +1,7 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
@@ -21,13 +20,9 @@ import {
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
-import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -36,54 +31,9 @@ import {
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: groupRunnersCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.group?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'GroupRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -153,28 +103,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner status summary
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -221,41 +149,16 @@ 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;
- },
webUrl(runner) {
return this.runners.urlsById[runner.id]?.web;
},
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -273,18 +176,15 @@ export default {
<div>
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.GROUP_TYPE"
+ :count-variables="countVariables"
:runner-types="$options.TABS_RUNNER_TYPES"
+ class="gl-w-full"
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"
@@ -300,11 +200,7 @@ export default {
:namespace="filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 34910781247..ecde9235e93 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -206,6 +206,7 @@ export default {
<template #features>
<feature-card
v-for="feature in augmentedSecurityFeatures"
+ :id="feature.anchor"
:key="feature.type"
data-testid="security-testing-card"
:feature="feature"
@@ -254,7 +255,6 @@ export default {
</section-layout>
</gl-tab>
<gl-tab
- v-if="securityTrainingEnabled"
data-testid="vulnerability-management-tab"
:title="$options.i18n.vulnerabilityManagement"
query-param-value="vulnerability-management"
@@ -271,7 +271,7 @@ export default {
</p>
</template>
<template #features>
- <training-provider-list />
+ <training-provider-list :security-training-enabled="securityTrainingEnabled" />
</template>
</section-layout>
</gl-tab>
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index e4d2bd08f50..6efaf08a178 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -194,6 +194,7 @@ export const securityFeatures = [
helpPath: DAST_HELP_PATH,
configurationHelpPath: DAST_CONFIG_HELP_PATH,
type: REPORT_TYPE_DAST,
+ anchor: 'dast',
},
{
name: DEPENDENCY_SCANNING_NAME,
@@ -201,6 +202,7 @@ export const securityFeatures = [
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
+ anchor: 'dependency-scanning',
},
{
name: CONTAINER_SCANNING_NAME,
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 ef50d085ae8..0bcb2bb6720 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -39,6 +39,7 @@ const i18n = {
primaryTrainingDescription: s__(
'SecurityTraining|Training from this partner takes precedence when more than one training partner is enabled.',
),
+ unavailableText: s__('SecurityConfiguration|Available with Ultimate'),
};
export default {
@@ -73,6 +74,13 @@ export default {
},
},
},
+ props: {
+ securityTrainingEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
data() {
return {
errorMessage: '',
@@ -232,12 +240,13 @@ export default {
</div>
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
<li v-for="provider in securityTrainingProviders" :key="provider.id" class="gl-mb-6">
- <gl-card>
+ <gl-card :body-class="{ 'gl-bg-gray-10': !securityTrainingEnabled }">
<div class="gl-display-flex">
<gl-toggle
:value="provider.isEnabled"
:label="__('Training mode')"
label-position="hidden"
+ :disabled="!securityTrainingEnabled"
@change="toggleProvider(provider)"
/>
<div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
@@ -249,7 +258,18 @@ export default {
></div>
</div>
<div class="gl-ml-3">
- <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ provider.name }}</h3>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <h3 class="gl-font-lg gl-m-0 gl-mb-2">
+ {{ provider.name }}
+ </h3>
+ <span
+ v-if="!securityTrainingEnabled"
+ data-testid="unavailable-text"
+ class="gl-text-gray-600"
+ >
+ {{ $options.i18n.unavailableText }}
+ </span>
+ </div>
<p>
{{ provider.description }}
<gl-link
@@ -263,7 +283,7 @@ export default {
</p>
<gl-form-radio
:checked="primaryProviderId"
- :disabled="!provider.isEnabled"
+ :disabled="!securityTrainingEnabled || !provider.isEnabled"
:value="provider.id"
@change="setPrimaryProvider(provider)"
>
diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
index 891e0dda312..9fdacb4ee10 100644
--- a/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
+++ b/app/assets/javascripts/security_configuration/graphql/security_training_vulnerability.query.graphql
@@ -1,7 +1,11 @@
-query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
+query getSecurityTrainingUrls(
+ $projectFullPath: ID!
+ $identifierExternalIds: [String!]!
+ $filename: String
+) {
project(fullPath: $projectFullPath) {
id
- securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
+ securityTrainingUrls(identifierExternalIds: $identifierExternalIds, filename: $filename) {
name
status
url
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 2f31d8ef3fb..b14e816a674 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -136,7 +136,9 @@ export default {
<template>
<section class="settings no-animate js-self-monitoring-settings">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('SelfMonitoring|Self monitoring') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index eb0931c6fe2..579316f481c 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,10 +1,13 @@
<script>
import {
+ GlButton,
GlToast,
GlModal,
GlTooltipDirective,
GlIcon,
GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
GlDropdown,
GlDropdownItem,
GlSafeHtmlDirective,
@@ -38,9 +41,12 @@ const statusTimeRanges = [
export default {
components: {
+ GlButton,
GlIcon,
GlModal,
GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
GlDropdown,
GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
@@ -215,97 +221,80 @@ export default {
@primary="setStatus"
@secondary="removeStatus"
>
- <div>
- <input
- v-model="emoji"
- class="js-status-emoji-field"
- type="hidden"
- name="user[status][emoji]"
+ <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
+ <gl-form-input-group class="gl-mb-5">
+ <gl-form-input
+ ref="statusMessageField"
+ v-model="message"
+ :placeholder="s__(`SetStatusModal|What's your status?`)"
+ class="js-status-message-field"
+ name="user[status][message]"
+ @keyup="setDefaultEmoji"
+ @keyup.enter.prevent
/>
- <div ref="userStatusForm" class="form-group position-relative m-0">
- <div class="input-group gl-mb-5">
- <span class="input-group-prepend">
- <emoji-picker
- dropdown-class="gl-h-full"
- toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- boundary="viewport"
- :right="false"
- @click="setEmoji"
+ <template #prepend>
+ <emoji-picker
+ dropdown-class="gl-h-full"
+ toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ boundary="viewport"
+ :right="false"
+ @click="setEmoji"
+ >
+ <template #button-content>
+ <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
+ <span
+ v-show="noEmoji"
+ class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <template #button-content>
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </template>
- </emoji-picker>
- </span>
- <input
- ref="statusMessageField"
- v-model="message"
- :placeholder="s__('SetStatusModal|What\'s your status?')"
- type="text"
- class="form-control form-control input-lg js-status-message-field"
- name="user[status][message]"
- @keyup="setDefaultEmoji"
- @keyup.enter.prevent
- />
- <span v-show="isDirty" class="input-group-append">
- <button
- v-gl-tooltip.bottom
- :title="s__('SetStatusModal|Clear status')"
- :aria-label="s__('SetStatusModal|Clear status')"
- name="button"
- type="button"
- class="js-clear-user-status-button clear-user-status btn"
- @click="clearStatusInputs()"
- >
- <gl-icon name="close" />
- </button>
- </span>
- </div>
- <div class="form-group">
- <div class="gl-display-flex">
- <gl-form-checkbox
- v-model="availability"
- data-testid="user-availability-checkbox"
- class="gl-mb-0"
- >
- <span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span>
- </gl-form-checkbox>
- </div>
- <div class="gl-display-flex">
- <span class="gl-text-gray-600 gl-ml-5">
- {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
+ <gl-icon name="slight-smile" class="award-control-icon-neutral" />
+ <gl-icon name="smiley" class="award-control-icon-positive" />
+ <gl-icon name="smile" class="award-control-icon-super-positive" />
</span>
- </div>
- </div>
- <div class="form-group">
- <div class="gl-display-flex gl-align-items-baseline">
- <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
- <gl-dropdown-item
- v-for="after in $options.statusTimeRanges"
- :key="after.name"
- :data-testid="after.name"
- @click="setClearStatusAfter(after.label)"
- >{{ after.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </div>
- <div
- v-if="currentClearStatusAfter.length"
- class="gl-mt-3 gl-text-gray-400 gl-font-sm"
- data-testid="clear-status-at-message"
+ </template>
+ </emoji-picker>
+ </template>
+ <template v-if="isDirty" #append>
+ <gl-button
+ v-gl-tooltip.bottom
+ :title="s__('SetStatusModal|Clear status')"
+ :aria-label="s__('SetStatusModal|Clear status')"
+ icon="close"
+ class="js-clear-user-status-button"
+ @click="clearStatusInputs"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-form-checkbox
+ v-model="availability"
+ class="gl-mb-5"
+ data-testid="user-availability-checkbox"
+ >
+ {{ s__('SetStatusModal|Busy') }}
+ <template #help>
+ {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
+ </template>
+ </gl-form-checkbox>
+
+ <div class="form-group">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
+ <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown-item
+ v-for="after in $options.statusTimeRanges"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="setClearStatusAfter(after.label)"
+ >{{ after.label }}</gl-dropdown-item
>
- {{ clearStatusAfterMessage }}
- </div>
- </div>
+ </gl-dropdown>
+ </div>
+ <div
+ v-if="currentClearStatusAfter.length"
+ class="gl-mt-3 gl-text-gray-400 gl-font-sm"
+ data-testid="clear-status-at-message"
+ >
+ {{ clearStatusAfterMessage }}
</div>
</div>
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index c20dd3b677d..d17c8a123d5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -72,9 +72,12 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
cannotMerge() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
- return this.issuableType === IssuableType.MergeRequest && !canMerge;
+ return this.isMergeRequest && !canMerge;
},
tooltipTitle() {
const { name = '', availability = '' } = this.user;
@@ -86,6 +89,10 @@ export default {
});
},
tooltipOption() {
+ if (this.isMergeRequest) {
+ return null;
+ }
+
return {
container: 'body',
placement: this.tooltipPlacement,
@@ -96,6 +103,10 @@ export default {
return this.user.web_url || this.user.webUrl;
},
assigneeId() {
+ if (this.isMergeRequest) {
+ return null;
+ }
+
return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id;
},
},
@@ -105,6 +116,7 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
+ v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
:data-user-id="assigneeId"
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 699d1bebea1..5f1808ff4da 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,10 +1,11 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createFlash from '~/flash';
import eventHub from '~/sidebar/event_hub';
+import toast from '~/vue_shared/plugins/global_toast';
import editForm from './edit_form.vue';
export default {
@@ -27,6 +28,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ Outside,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
@@ -84,6 +86,11 @@ export default {
locked: !this.isLocked,
fullPath: this.fullPath,
})
+ .then(() => {
+ if (this.isMergeRequest) {
+ toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.'));
+ }
+ })
.catch(() => {
const flashMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
@@ -96,6 +103,9 @@ export default {
this.isLoading = false;
});
},
+ closeForm() {
+ this.isLockDialogOpen = false;
+ },
},
};
</script>
@@ -142,6 +152,7 @@ export default {
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="isLockDialogOpen"
+ v-outside="closeForm"
data-testid="edit-form"
:is-locked="isLocked"
:issuable-display-name="issuableDisplayName"
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 36a08482e69..c9b0a4ae2b3 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -68,10 +68,9 @@ export default {
<template>
<!-- must be `d-inline-block` or parent flex-basis causes width issues -->
<gl-link
+ v-gl-tooltip="tooltipOption"
:href="reviewerUrl"
:title="tooltipTitle"
- :data-user-id="user.id"
- data-placement="left"
class="gl-display-inline-block js-user-link"
>
<!-- use d-flex so that slot can be appropriately styled -->
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 1bafa845665..7662d645dd9 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -6,6 +6,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import toast from '~/vue_shared/plugins/global_toast';
import { subscribedQueries, Tracking } from '~/sidebar/constants';
const ICON_ON = 'notifications';
@@ -140,6 +141,10 @@ export default {
message: errors[0],
});
}
+
+ if (this.isMergeRequest) {
+ toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.'));
+ }
},
)
.catch(() => {
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index ff3fb4aae6b..127e3a3c610 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
-import { temporaryConfig } from '~/work_items/graphql/provider';
+import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider';
const resolvers = {
Mutation: {
@@ -13,6 +13,7 @@ const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ ...workItemResolvers.Mutation,
},
};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index bb40ac14438..3f82fe5ce87 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -73,12 +73,14 @@ function mountSidebarToDoWidget() {
props: {
fullPath: projectPath,
issuableId:
- isInIssuePage() || isInDesignPage()
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
? convertToGraphQLId(TYPE_ISSUE, id)
: convertToGraphQLId(TYPE_MERGE_REQUEST, id),
issuableIid: iid,
issuableType:
- isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
},
}),
});
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index ea170203576..05268a5c89c 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -33,6 +33,7 @@ export default class SidebarService {
SidebarService.singleton = this;
}
+ // eslint-disable-next-line no-constructor-return
return SidebarService.singleton;
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 7df901577b8..4df00903ab6 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -11,6 +11,8 @@ export default class SidebarMediator {
if (!SidebarMediator.singleton) {
this.initSingleton(options);
}
+
+ // eslint-disable-next-line no-constructor-return
return SidebarMediator.singleton;
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index ca85ee7fd94..971e2a15c68 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -4,6 +4,7 @@ export default class SidebarStore {
this.initSingleton(options);
}
+ // eslint-disable-next-line no-constructor-return
return SidebarStore.singleton;
}
diff --git a/app/assets/javascripts/surveys/components/satisfaction_rate.vue b/app/assets/javascripts/surveys/components/satisfaction_rate.vue
new file mode 100644
index 00000000000..d83de56169b
--- /dev/null
+++ b/app/assets/javascripts/surveys/components/satisfaction_rate.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'SatisfactionRate',
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ unhappy: s__('Surveys|Unhappy'),
+ delighted: s__('Surveys|Delighted'),
+ },
+ grades: [
+ {
+ title: s__('Surveys|Unhappy'),
+ icon: 'face-unhappy',
+ value: 1,
+ },
+ {
+ title: s__('Surveys|Sad'),
+ icon: 'slight-frown',
+ value: 2,
+ },
+ {
+ title: s__('Surveys|Neutral'),
+ icon: 'face-neutral',
+ value: 3,
+ },
+ {
+ title: s__('Surveys|Happy'),
+ icon: 'slight-smile',
+ value: 4,
+ },
+ {
+ title: s__('Surveys|Delighted'),
+ icon: 'smiley',
+ value: 5,
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <ul class="gl-list-style-none gl-display-flex gl-p-0 gl-m-0 gl-justify-content-space-between">
+ <li v-for="grade in $options.grades" :key="grade.value">
+ <gl-button
+ v-gl-tooltip="grade.title"
+ class="gl-p-2!"
+ variant="default"
+ category="tertiary"
+ :aria-label="grade.title"
+ @click="$emit('rate', grade.value)"
+ >
+ <gl-icon class="gl-vertical-align-top" :name="grade.icon" :size="24" />
+ </gl-button>
+ </li>
+ </ul>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-pt-3 gl-text-gray-500 gl-font-sm"
+ >
+ <div>{{ $options.i18n.unhappy }}</div>
+ <div>{{ $options.i18n.delighted }}</div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js
new file mode 100644
index 00000000000..ea5d8aef3c5
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
+import createDefaultClient from '~/lib/graphql';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+Vue.use(VueApollo);
+
+export const startMrSurveyApp = () => {
+ let channel = null;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const app = new Vue({
+ apolloProvider,
+ data() {
+ return {
+ hidden: false,
+ };
+ },
+ render(h) {
+ if (this.hidden) return null;
+ return h(MergeRequestExperienceSurveyApp, {
+ on: {
+ close: () => {
+ channel?.postMessage('close');
+ app.hidden = true;
+ },
+ rate: () => {
+ channel?.postMessage('close');
+ },
+ },
+ });
+ },
+ });
+
+ app.$mount('#js-mr-experience-survey');
+
+ if (window.BroadcastChannel) {
+ channel = new BroadcastChannel('mr_survey');
+ channel.addEventListener('message', ({ data }) => {
+ if (data === 'close') {
+ app.hidden = true;
+ channel.close();
+ channel = null;
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
new file mode 100644
index 00000000000..85eed6ae82a
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -0,0 +1,169 @@
+<script>
+import { GlButton, GlSprintf, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui';
+import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg';
+import { s__, __ } from '~/locale';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
+import Tracking from '~/tracking';
+
+const steps = [
+ {
+ label: 'overall',
+ question: s__('MrSurvey|Overall, how satisfied are you with merge requests?'),
+ },
+ {
+ label: 'performance',
+ question: s__(
+ 'MrSurvey|How satisfied are you with %{strongStart}speed/performance%{strongEnd} of merge requests?',
+ ),
+ },
+];
+
+export default {
+ name: 'MergeRequestExperienceSurveyApp',
+ components: {
+ UserCalloutDismisser,
+ GlSprintf,
+ GlButton,
+ SatisfactionRate,
+ },
+ directives: {
+ safeHtml: GlSafeHtmlDirective,
+ tooltip: GlTooltipDirective,
+ },
+ mixins: [Tracking.mixin()],
+ i18n: {
+ survey: s__('MrSurvey|Merge request experience survey'),
+ close: __('Close'),
+ legal: s__(
+ 'MrSurvey|By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the %{linkStart}GitLab Privacy Policy%{linkEnd}.',
+ ),
+ thanks: s__('MrSurvey|Thank you for your feedback!'),
+ },
+ gitlabLogo,
+ data() {
+ return {
+ visible: false,
+ stepIndex: 0,
+ };
+ },
+ computed: {
+ step() {
+ return steps[this.stepIndex];
+ },
+ },
+ mounted() {
+ document.addEventListener('keyup', this.handleKeyup);
+ },
+ destroyed() {
+ document.removeEventListener('keyup', this.handleKeyup);
+ },
+ methods: {
+ onQueryLoaded({ shouldShowCallout }) {
+ this.visible = shouldShowCallout;
+ if (!this.visible) this.$emit('close');
+ },
+ onRate(event) {
+ this.$emit('rate');
+ this.track('survey:mr_experience', {
+ label: this.step.label,
+ value: event,
+ });
+ this.stepIndex += 1;
+ if (!this.step) {
+ setTimeout(() => {
+ this.$emit('close');
+ }, 5000);
+ }
+ },
+ handleKeyup(e) {
+ if (e.key !== 'Escape') return;
+ this.$emit('close');
+ this.$refs.dismisser?.dismiss();
+ },
+ },
+};
+</script>
+
+<template>
+ <user-callout-dismisser
+ ref="dismisser"
+ feature-name="mr_experience_survey"
+ @queryResult.once="onQueryLoaded"
+ >
+ <template #default="{ dismiss }">
+ <aside
+ class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
+ :aria-label="$options.i18n.survey"
+ >
+ <transition name="survey-slide-up">
+ <div
+ v-if="visible"
+ class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ >
+ <gl-button
+ v-tooltip="$options.i18n.close"
+ :aria-label="$options.i18n.close"
+ variant="default"
+ category="tertiary"
+ class="gl-top-4 gl-right-3 gl-absolute"
+ icon="close"
+ @click="
+ dismiss();
+ $emit('close');
+ "
+ />
+ <div
+ v-if="stepIndex === 0"
+ class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
+ role="note"
+ >
+ <p class="gl-m-0">
+ <gl-sprintf :message="$options.i18n.legal">
+ <template #link="{ content }">
+ <a
+ class="gl-text-decoration-underline gl-text-gray-500"
+ href="https://about.gitlab.com/privacy/"
+ target="_blank"
+ rel="noreferrer nofollow"
+ v-text="content"
+ ></a>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="gl-relative">
+ <div class="gl-absolute">
+ <div
+ v-safe-html="$options.gitlabLogo"
+ aria-hidden="true"
+ class="mr-experience-survey-logo"
+ ></div>
+ </div>
+ </div>
+ <section v-if="step">
+ <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
+ <gl-sprintf :message="step.question">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <satisfaction-rate
+ aria-labelledby="mr_survey_question"
+ class="gl-mt-5"
+ @rate="
+ dismiss();
+ onRate($event);
+ "
+ />
+ </section>
+ <section v-else class="gl-px-7">
+ {{ $options.i18n.thanks }}
+ </section>
+ </div>
+ </transition>
+ </aside>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/surveys/merge_request_experience/index.js b/app/assets/javascripts/surveys/merge_request_experience/index.js
new file mode 100644
index 00000000000..6073bde56c0
--- /dev/null
+++ b/app/assets/javascripts/surveys/merge_request_experience/index.js
@@ -0,0 +1,23 @@
+import { Tracker } from '~/tracking/tracker';
+
+const MR_SURVEY_WAIT_DURATION = 10000;
+
+const broadcastNotificationVisible = () => {
+ // We don't want to clutter up the UI by displaying the survey when broadcast message(s)
+ // are visible as well.
+ return Boolean(document.querySelector('.broadcast-notification-message'));
+};
+
+export const initMrExperienceSurvey = () => {
+ if (!gon.features?.mrExperienceSurvey) return;
+ if (!gon.current_user_id) return;
+ if (!Tracker.enabled()) return;
+ if (broadcastNotificationVisible()) return;
+
+ setTimeout(() => {
+ // eslint-disable-next-line promise/catch-or-return
+ import('./app').then(({ startMrSurveyApp }) => {
+ startMrSurveyApp();
+ });
+ }, MR_SURVEY_WAIT_DURATION);
+};
diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js
index 90c9a89d652..0c227ab7afc 100644
--- a/app/assets/javascripts/tabs/constants.js
+++ b/app/assets/javascripts/tabs/constants.js
@@ -14,3 +14,6 @@ export const ATTR_ROLE = 'role';
export const ATTR_TABINDEX = 'tabindex';
export const TAB_SHOWN_EVENT = 'gl-tab-shown';
+
+export const HISTORY_TYPE_HASH = 'hash';
+export const ALLOWED_HISTORY_TYPES = [HISTORY_TYPE_HASH];
diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js
index 44937e593e0..9230b7361a5 100644
--- a/app/assets/javascripts/tabs/index.js
+++ b/app/assets/javascripts/tabs/index.js
@@ -1,4 +1,5 @@
import { uniqueId } from 'lodash';
+import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
import {
ACTIVE_TAB_CLASSES,
ATTR_ROLE,
@@ -12,9 +13,11 @@ import {
KEY_CODE_RIGHT,
KEY_CODE_DOWN,
TAB_SHOWN_EVENT,
+ HISTORY_TYPE_HASH,
+ ALLOWED_HISTORY_TYPES,
} from './constants';
-export { TAB_SHOWN_EVENT };
+export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH };
/**
* The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and
@@ -88,9 +91,13 @@ export class GlTabsBehavior {
/**
* Create a GlTabsBehavior instance.
*
- * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper.
+ * @param {HTMLElement} el - The element created by the Rails `gl_tabs_nav` helper.
+ * @param {Object} [options]
+ * @param {'hash' | null} [options.history=null] - Sets the type of routing GlTabs will use when navigating between tabs.
+ * 'hash': Updates the URL hash with the current tab ID.
+ * null: No routing mechanism will be used.
*/
- constructor(el) {
+ constructor(el, { history = null } = {}) {
if (!el) {
throw new Error('Cannot instantiate GlTabsBehavior without an element');
}
@@ -100,8 +107,11 @@ export class GlTabsBehavior {
this.tabs = this.getTabs();
this.activeTab = null;
+ this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null;
+
this.setAccessibilityAttrs();
this.bindEvents();
+ if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab();
}
setAccessibilityAttrs() {
@@ -128,6 +138,7 @@ export class GlTabsBehavior {
tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id);
}
+ tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS);
tabPanel.setAttribute(ATTR_ROLE, 'tabpanel');
tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id);
});
@@ -164,6 +175,11 @@ export class GlTabsBehavior {
});
}
+ loadInitialTab() {
+ const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`);
+ this.activateTab(tab || this.activeTab);
+ }
+
activatePreviousTab() {
const currentTabIndex = this.tabs.indexOf(this.activeTab);
@@ -216,6 +232,7 @@ export class GlTabsBehavior {
const tabPanel = this.getPanelForTab(tabToActivate);
tabPanel.classList.add(ACTIVE_PANEL_CLASS);
+ if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href'));
this.activeTab = tabToActivate;
this.dispatchTabShown(tabToActivate, tabPanel);
diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue
index aedf5b6acfe..a54a198faed 100644
--- a/app/assets/javascripts/terms/components/app.vue
+++ b/app/assets/javascripts/terms/components/app.vue
@@ -7,6 +7,7 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import '~/behaviors/markdown/render_gfm';
+import { trackTrialAcceptTerms } from '~/google_tag_manager';
export default {
name: 'TermsApp',
@@ -73,6 +74,7 @@ export default {
this.setScrollableViewportHeight();
event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
},
+ trackTrialAcceptTerms,
},
};
</script>
@@ -99,7 +101,13 @@ export default {
<gl-button type="submit">{{ $options.i18n.decline }}</gl-button>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</form>
- <form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept">
+ <form
+ v-if="permissions.canAccept"
+ class="gl-ml-3"
+ method="post"
+ :action="paths.accept"
+ @submit="trackTrialAcceptTerms"
+ >
<gl-button
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index a3615eab26f..3356cada58a 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -117,7 +117,7 @@ function launchPopover(el, mountPopover) {
mountPopover(popoverInstance);
}
-const userLinkSelector = 'a.js-user-link, a.gfm-project_member';
+const userLinkSelector = 'a.js-user-link[data-user], a.js-user-link[data-user-id]';
const getUserLinkNode = (node) => node.closest(userLinkSelector);
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index e1e5cc565c6..94b4ee77e7e 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
- const { handleClick, autoAssignToMe } = options;
+ const { handleClick } = options;
const userSelect = this;
$els.each((i, dropdown) => {
@@ -172,7 +172,10 @@ function UsersSelect(currentUser, els, options = {}) {
});
};
- const onAssignToMeClick = () => {
+ $assignToMeLink.on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
+
if ($dropdown.data('multiSelect')) {
assignYourself();
checkMaxSelect();
@@ -191,19 +194,8 @@ function UsersSelect(currentUser, els, options = {}) {
.text(gon.current_user_fullname)
.removeClass('is-default');
}
- };
-
- $assignToMeLink.on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).hide();
- onAssignToMeClick();
});
- if (autoAssignToMe) {
- $assignToMeLink.hide();
- onAssignToMeClick();
- }
-
$block.on('click', '.js-assign-yourself', (e) => {
e.preventDefault();
return assignTo(userSelect.currentUser.id);
@@ -249,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) {
)} <% } %>`,
);
assigneeTemplate = template(
- `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
+ `<% if (username) { %> <a class="author-link gl-font-weight-bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
closingTag: '</a>',
@@ -585,7 +577,7 @@ function UsersSelect(currentUser, els, options = {}) {
)}</a></li>`;
} else {
// 0 margin, because it's now handled by a wrapper
- img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
+ img = `<img src='${avatar}' class='avatar avatar-inline gl-m-0!' width='32' />`;
}
return userSelect.renderRow(
@@ -806,9 +798,9 @@ UsersSelect.prototype.renderRow = function (
: user.name;
return `
<li data-user-id=${user.id}>
- <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
+ <a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
- <span class="d-flex flex-column overflow-hidden">
+ <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
${escape(name)}
</strong>
@@ -836,7 +828,7 @@ UsersSelect.prototype.renderRowAvatar = function (issuableType, user, img) {
? spriteIcon('warning-solid', 's12 merge-icon')
: '';
- return `<span class="position-relative mr-2">
+ return `<span class="gl-relative gl-mr-3">
${img}
${mergeIcon}
</span>`;
@@ -851,7 +843,7 @@ UsersSelect.prototype.renderApprovalRules = function (elsClassName, approvalRule
const [rule] = approvalRules;
const countText = sprintf(__('(+%{count}&nbsp;rules)'), { count });
- const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : '';
+ const renderApprovalRulesCount = count > 1 ? `<span class="gl-ml-2">${countText}</span>` : '';
const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : escape(rule.name);
return `<div class="gl-display-flex gl-font-sm">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
index 655ceb5f700..b76d5d90ead 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
export default {
@@ -8,6 +8,9 @@ export default {
GlDropdown,
GlDropdownItem,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
widget: {
type: String,
@@ -19,6 +22,12 @@ export default {
default: () => [],
},
},
+ data: () => {
+ return {
+ timeout: null,
+ updatingTooltip: false,
+ };
+ },
computed: {
dropdownLabel() {
return sprintf(__('%{widget} options'), { widget: this.widget });
@@ -27,9 +36,29 @@ export default {
methods: {
onClickAction(action) {
this.$emit('clickedAction', action);
+
if (action.onClick) {
action.onClick();
}
+
+ if (action.tooltipOnClick) {
+ this.updatingTooltip = true;
+ this.$root.$emit('bv::show::tooltip', action.id);
+
+ clearTimeout(this.timeout);
+
+ this.timeout = setTimeout(() => {
+ this.updatingTooltip = false;
+ this.$root.$emit('bv::hide::tooltip', action.id);
+ }, 1000);
+ }
+ },
+ setTooltip(btn) {
+ if (this.updatingTooltip && btn.tooltipOnClick) {
+ return btn.tooltipOnClick;
+ }
+
+ return btn.tooltipText;
},
},
};
@@ -55,6 +84,7 @@ export default {
:key="index"
:href="btn.href"
:target="btn.target"
+ :data-clipboard-text="btn.dataClipboardText"
@click="onClickAction(btn)"
>
{{ btn.text }}
@@ -63,15 +93,20 @@ export default {
<template v-if="tertiaryButtons.length">
<gl-button
v-for="(btn, index) in tertiaryButtons"
+ :id="btn.id"
:key="index"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
:class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }"
+ :data-clipboard-text="btn.dataClipboardText"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
category="tertiary"
- variant="confirm"
size="small"
class="gl-display-none gl-md-display-block gl-float-left"
- data-testid="extension-actions-button"
@click="onClickAction(btn)"
>
{{ btn.text }}
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 4ba620da00a..410331004e4 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
@@ -194,6 +194,24 @@ export default {
poll.makeRequest();
},
+ initExtensionFullDataPolling() {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => this.fetchFullData(this),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => {
+ this.setFullData(data);
+ });
+ },
+ errorCallback: (e) => {
+ this.setExpandedError(e);
+ },
+ });
+
+ poll.makeRequest();
+ },
headerCheck(response, callback) {
const headers = normalizeHeaders(response.headers);
@@ -220,6 +238,10 @@ export default {
});
}
},
+ setFullData(data) {
+ this.loadingState = null;
+ this.fullData = data.map((x, i) => ({ id: i, ...x }));
+ },
setCollapsedData(data) {
this.collapsedData = data;
this.loadingState = null;
@@ -229,21 +251,26 @@ export default {
Sentry.captureException(e);
},
+ setExpandedError(e) {
+ this.loadingState = LOADING_STATES.expandedError;
+ Sentry.captureException(e);
+ },
loadAllData() {
if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
- this.fetchFullData(this)
- .then((data) => {
- this.loadingState = null;
- this.fullData = data.map((x, i) => ({ id: i, ...x }));
- })
- .catch((e) => {
- this.loadingState = LOADING_STATES.expandedError;
-
- Sentry.captureException(e);
- });
+ if (this.$options.enableExpandedPolling) {
+ this.initExtensionFullDataPolling();
+ } else {
+ this.fetchFullData(this)
+ .then((data) => {
+ this.setFullData(data);
+ })
+ .catch((e) => {
+ this.setExpandedError(e);
+ });
+ }
},
appear(index) {
if (index === this.fullData.length - 1) {
@@ -288,6 +315,7 @@ export default {
@mouseup="onRowMouseUp"
>
<status-icon
+ :level="1"
:name="$options.label || $options.name"
:is-loading="isLoadingSummary"
:icon-name="statusIconName"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index f4fcf4c9571..7e329399957 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -20,6 +20,7 @@ export const registerExtension = (extension) => {
i18n: extension.i18n,
expandEvent: extension.expandEvent,
enablePolling: extension.enablePolling,
+ enableExpandedPolling: extension.enableExpandedPolling,
modalComponent: extension.modalComponent,
computed: {
...extension.props.reduce(
@@ -35,7 +36,7 @@ export const registerExtension = (extension) => {
(acc, computedKey) => ({
...acc,
// Making the computed property a method allows us to pass in arguments
- // this allows for each computed property to recieve some data
+ // this allows for each computed property to receive some data
[computedKey]() {
return extension.computed[computedKey];
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index bb626c9adba..dc748ba44f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -9,6 +9,11 @@ export default {
GlIcon,
},
props: {
+ level: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
name: {
type: String,
required: false,
@@ -27,7 +32,7 @@ export default {
size: {
type: Number,
required: false,
- default: 16,
+ default: 12,
},
},
computed: {
@@ -44,8 +49,8 @@ export default {
<div
:class="[
$options.EXTENSION_ICON_CLASS[iconName],
- { 'mr-widget-extension-icon': !isLoading && size === 16 },
- { 'gl-p-2': isLoading || size === 16 },
+ { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 },
+ { 'gl-p-2': isLoading || level === 1 },
]"
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index aec3a35f37c..b551cd2fd60 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -65,7 +65,7 @@ function simplifyWidgetName(componentName) {
function baseRedisEventName(extensionName) {
const redisEventName = extensionName.replace(/([A-Z])/g, '_$1').toLowerCase();
- return `i_merge_request_widget_${redisEventName}`;
+ return `i_code_review_merge_request_widget_${redisEventName}`;
}
function whenable(bus) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index 701ef89304c..a45823823f0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -4,7 +4,7 @@ import StatusIcon from '../mr_widget_status_icon.vue';
export default {
i18n: {
- approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'),
+ approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'),
blockingMergeRequests: s__(
'mrWidget|Merge blocked: you can only merge after the above items are resolved.',
),
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 3511fffcfbb..59767eb2e6e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -3,6 +3,7 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -120,13 +121,15 @@ export default {
.poll()
.then((res) => res.data)
.then((res) => {
- if (res.rebase_in_progress) {
+ if (res.rebase_in_progress || res.should_be_rebased) {
continuePolling();
} else {
this.isMakingRequest = false;
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
+ } else {
+ toast(__('Rebase completed'));
}
eventHub.$emit('MRWidgetRebaseSuccess');
@@ -218,6 +221,17 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
+ <gl-button
+ v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi"
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
</div>
</div>
</template>
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
index f14e80d0be6..22e907f7e48 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -40,6 +40,9 @@ export default {
return numOfResults === 0 ? successText : warningText;
},
+ shouldCollapse() {
+ return this.collapsedData?.summary?.errored > 0;
+ },
fetchCollapsedData() {
return axios.get(this.accessibilityReportPath);
},
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 a7aaa2f4476..ca95e1b5de8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -32,7 +32,7 @@ export default {
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
- return EXTENSION_ICONS.warning;
+ return EXTENSION_ICONS.failed;
},
// Tertiary action buttons that will take the user elsewhere
// in the GitLab app
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
index 23f14bea4e1..4994a0bcbeb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/constants.js
@@ -7,6 +7,8 @@ export const TESTS_FAILED_STATUS = 'failed';
export const ERROR_STATUS = 'error';
export const i18n = {
+ copyFailedSpecs: s__('Reports|Copy failed tests'),
+ copyFailedSpecsTooltip: s__('Reports|Copy failed test names to run locally'),
label: s__('Reports|Test summary'),
loading: s__('Reports|Test summary results are loading'),
error: s__('Reports|Test summary failed to load results'),
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index 164bda33b95..c74445a5b80 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -1,4 +1,5 @@
import { uniqueId } from 'lodash';
+import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
import { EXTENSION_ICONS } from '../../constants';
@@ -19,6 +20,20 @@ export default {
props: ['testResultsPath', 'headBlobPath', 'pipeline'],
modalComponent: TestCaseDetails,
computed: {
+ failedTestNames() {
+ if (!this.collapsedData?.suites) {
+ return '';
+ }
+
+ const newFailures = this.collapsedData?.suites.flatMap((suite) => [suite.new_failures || []]);
+ const fileNames = newFailures.flatMap((newFailure) => {
+ return newFailure.map((failure) => {
+ return failure.file;
+ });
+ });
+
+ return fileNames.join(' ');
+ },
summary(data) {
if (data.parsingInProgress) {
return this.$options.i18n.loading;
@@ -32,9 +47,6 @@ export default {
};
},
statusIcon(data) {
- if (data.parsingInProgress) {
- return null;
- }
if (data.status === TESTS_FAILED_STATUS) {
return EXTENSION_ICONS.warning;
}
@@ -44,30 +56,46 @@ export default {
return EXTENSION_ICONS.success;
},
tertiaryButtons() {
- return [
- {
- text: this.$options.i18n.fullReport,
- href: `${this.pipeline.path}/test_report`,
- target: '_blank',
- fullReport: true,
- },
- ];
+ const actionButtons = [];
+
+ if (this.failedTestNames().length > 0) {
+ actionButtons.push({
+ dataClipboardText: this.failedTestNames(),
+ id: uniqueId('copy-to-clipboard'),
+ icon: 'copy-to-clipboard',
+ testId: 'copy-failed-specs-btn',
+ text: this.$options.i18n.copyFailedSpecs,
+ tooltipText: this.$options.i18n.copyFailedSpecsTooltip,
+ tooltipOnClick: __('Copied'),
+ });
+ }
+
+ actionButtons.push({
+ text: this.$options.i18n.fullReport,
+ href: `${this.pipeline.path}/test_report`,
+ target: '_blank',
+ fullReport: true,
+ testId: 'full-report-link',
+ });
+
+ return actionButtons;
},
},
methods: {
fetchCollapsedData() {
- return axios.get(this.testResultsPath).then((res) => {
- const { data = {}, status } = res;
+ return axios.get(this.testResultsPath).then((response) => {
+ const { data = {}, status } = response;
+ const { suites = [], summary = {} } = data;
return {
- ...res,
+ ...response,
data: {
- hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
+ hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS),
parsingInProgress: status === 204,
...data,
summary: {
- recentlyFailed: countRecentlyFailedTests(data.suites),
- ...data.summary,
+ recentlyFailed: countRecentlyFailedTests(suites),
+ ...summary,
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
index 7bbcb0cd04a..4ffd06de61f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { i18n } from './constants';
const textBuilder = (results, boldNumbers = false) => {
@@ -65,6 +66,11 @@ export const reportSubTextBuilder = ({ suite_errors, summary }) => {
};
export const countRecentlyFailedTests = (subject) => {
+ // return 0 count if subject is [], null, or undefined
+ if (isEmpty(subject)) {
+ return 0;
+ }
+
// handle either a single report or an array of reports
const reports = !subject.length ? [subject] : subject;
@@ -73,10 +79,10 @@ export const countRecentlyFailedTests = (subject) => {
return (
[report.new_failures, report.existing_failures, report.resolved_failures]
// only count tests which have failed more than once
- .map(
- (failureArray) =>
- failureArray.filter((failure) => failure.recent_failures?.count > 1).length,
- )
+ .map((failureArray) => {
+ if (!failureArray) return 0;
+ return failureArray.filter((failure) => failure.recent_failures?.count > 1).length;
+ })
.reduce((total, count) => total + count, 0)
);
})
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 c68437b9879..3e0ac236fdf 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
@@ -221,8 +221,11 @@ export default {
formattedHumanAccess() {
return (this.mr.humanAccess || '').toLowerCase();
},
+ hasMergeError() {
+ return this.mr.mergeError && this.state !== 'closed';
+ },
hasAlerts() {
- return this.mr.mergeError || this.showMergePipelineForkWarning;
+ return this.hasMergeError || this.showMergePipelineForkWarning;
},
shouldShowExtension() {
return (
@@ -574,7 +577,12 @@ export default {
/>
<div class="mr-section-container mr-widget-workflow">
<div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container">
- <mr-widget-alert-message v-if="mr.mergeError" type="danger" dismissible>
+ <mr-widget-alert-message
+ v-if="hasMergeError"
+ type="danger"
+ dismissible
+ data-testid="merge_error"
+ >
<span v-safe-html="mergeError"></span>
</mr-widget-alert-message>
<mr-widget-alert-message
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 25c44beaf18..981c667f27a 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
@@ -6,11 +6,13 @@ query getState($projectPath: ID!, $iid: String!) {
mergeRequest(iid: $iid) {
id
autoMergeEnabled
+ availableAutoMergeStrategies
commitCount
conflicts
diffHeadSha
mergeError
mergeStatus
+ mergeable
mergeableDiscussionsState
headPipeline {
id
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 322ea64eb7e..f2c27cf611e 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -61,7 +61,7 @@ export default {
},
});
- return document.dispatchEvent(headerTodoEvent);
+ document.dispatchEvent(headerTodoEvent);
},
addToDo() {
this.isUpdating = true;
@@ -75,9 +75,10 @@ export default {
})
.then(({ data: { errors = [] } }) => {
if (errors[0]) {
- return this.throwError(errors[0]);
+ this.throwError(errors[0]);
+ return;
}
- return this.updateToDoCount(true);
+ this.updateToDoCount(true);
})
.catch(() => {
this.throwError();
@@ -98,9 +99,10 @@ export default {
})
.then(({ data: { errors = [] } }) => {
if (errors[0]) {
- return this.throwError(errors[0]);
+ this.throwError(errors[0]);
+ return;
}
- return this.updateToDoCount(false);
+ this.updateToDoCount(false);
})
.catch(() => {
this.throwError();
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
index 92817d5fa70..70cac061ca6 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
@@ -14,12 +14,12 @@ export default {
</script>
<template>
- <div>
+ <div class="color-item">
<span
- class="dropdown-label-box gl-flex-shrink-0 gl-top-1 gl-mr-0"
+ class="dropdown-label-box color-item-color"
data-testid="color-item"
:style="{ backgroundColor: color }"
></span>
- <span class="hide-collapsed">{{ title }}</span>
+ <span class="color-item-text">{{ title }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
index 6b79883d76b..a88a4ca5cb8 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
@@ -1,4 +1,5 @@
<script>
+import { isString } from 'lodash';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
@@ -52,13 +53,23 @@ export default {
required: false,
default: s__('ColorWidget|Assign epic color'),
},
+ defaultColor: {
+ type: Object,
+ required: false,
+ validator(value) {
+ return isString(value?.color) && isString(value?.title);
+ },
+ default() {
+ return {
+ color: '',
+ title: '',
+ };
+ },
+ },
},
data() {
return {
- issuableColor: {
- color: '',
- title: '',
- },
+ issuableColor: this.defaultColor,
colorUpdateInProgress: false,
oldIid: null,
sidebarExpandedOnClick: false,
@@ -106,9 +117,9 @@ export default {
methods: {
handleDropdownClose(color) {
if (this.iid !== '') {
- this.updateSelectedColor(this.getUpdateVariables(color));
+ this.updateSelectedColor(color);
} else {
- this.$emit('updateSelectedColor', color);
+ this.$emit('updateSelectedColor', { color });
}
this.collapseEditableItem();
@@ -129,13 +140,15 @@ export default {
color: color.color,
};
},
- updateSelectedColor(inputVariables) {
+ updateSelectedColor(color) {
this.colorUpdateInProgress = true;
+ const input = this.getUpdateVariables(color);
+
this.$apollo
.mutate({
mutation: updateEpicColorMutation,
- variables: { input: inputVariables },
+ variables: { input },
})
.then(({ data }) => {
if (data.updateIssuableColor?.errors?.length) {
@@ -144,7 +157,7 @@ export default {
this.$emit('updateSelectedColor', {
id: data.updateIssuableColor?.issuable?.id,
- color: data.updateIssuableColor?.issuable?.color,
+ color,
});
})
.catch((error) =>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
index c70785abd1e..701ac71d755 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
@@ -1,4 +1,4 @@
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const COLOR_WIDGET_COLOR = s__('ColorWidget|Color');
@@ -7,7 +7,7 @@ export const DROPDOWN_VARIANT = {
Embedded: 'embedded',
};
-export const DEFAULT_COLOR = { title: __('SuggestedColors|Blue'), color: '#1068bf' };
+export const DEFAULT_COLOR = { title: s__('SuggestedColors|Blue'), color: '#1068bf' };
export const ISSUABLE_COLORS = [
DEFAULT_COLOR,
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
index 4eb1d3d08ca..84da6e1437e 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
@@ -1,11 +1,13 @@
<script>
import { GlDropdown } from '@gitlab/ui';
+import ColorItem from './color_item.vue';
import DropdownContentsColorView from './dropdown_contents_color_view.vue';
import DropdownHeader from './dropdown_header.vue';
import { isDropdownVariantSidebar } from './utils';
export default {
components: {
+ ColorItem,
DropdownContentsColorView,
DropdownHeader,
GlDropdown,
@@ -42,12 +44,15 @@ export default {
},
computed: {
buttonText() {
- if (!this.localSelectedColor?.title) {
+ if (!this.hasSelectedColor) {
return this.dropdownButtonText;
}
return this.localSelectedColor.title;
},
+ hasSelectedColor() {
+ return this.localSelectedColor?.title;
+ },
},
watch: {
localSelectedColor: {
@@ -91,7 +96,15 @@ export default {
</script>
<template>
- <gl-dropdown ref="dropdown" :text="buttonText" class="gl-w-full" @hide="handleDropdownHide">
+ <gl-dropdown ref="dropdown" class="gl-w-full" @hide="handleDropdownHide">
+ <template #button-text>
+ <color-item
+ v-if="hasSelectedColor"
+ :color="localSelectedColor.color"
+ :title="localSelectedColor.title"
+ />
+ <span v-else data-testid="fallback-button-text">{{ buttonText }}</span>
+ </template>
<template #header>
<dropdown-header
ref="header"
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
index 62f4cf59c14..91906388049 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
@@ -36,8 +36,8 @@ export default {
</script>
<template>
- <gl-dropdown-form>
- <div>
+ <gl-dropdown-form class="js-colors-list">
+ <div data-testid="dropdown-content">
<gl-dropdown-item
v-for="color in colors"
:key="color.color"
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
index 4cba66eefd2..7ae803ebf4d 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
@@ -20,6 +20,11 @@ export default {
required: true,
},
},
+ computed: {
+ hasColor() {
+ return this.selectedColor.color !== '';
+ },
+ },
};
</script>
@@ -31,13 +36,18 @@ export default {
class="sidebar-collapsed-icon"
>
<gl-icon name="appearance" />
+ <color-item :color="selectedColor.color" :title="selectedColor.title" />
+ </div>
+
+ <span v-if="!hasColor" class="no-value hide-collapsed">
+ <slot></slot>
+ </span>
+ <template v-else>
<color-item
+ class="hide-collapsed"
:color="selectedColor.color"
:title="selectedColor.title"
- class="gl-font-base gl-line-height-24"
/>
- </div>
-
- <color-item class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/deployment_instance.vue b/app/assets/javascripts/vue_shared/components/deployment_instance.vue
index 4aae86fc82b..1b907078cf9 100644
--- a/app/assets/javascripts/vue_shared/components/deployment_instance.vue
+++ b/app/assets/javascripts/vue_shared/components/deployment_instance.vue
@@ -13,8 +13,6 @@
* Mockup is https://gitlab.com/gitlab-org/gitlab/issues/35570
*/
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -23,7 +21,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
/**
* Represents the status of the pod. Each state is represented with a different
@@ -54,17 +51,11 @@ export default {
required: false,
default: '',
},
-
- logsPath: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
isLink() {
- return this.logsPath !== '' && this.podName !== '';
+ return this.podName !== '';
},
cssClass() {
@@ -74,12 +65,6 @@ export default {
link: this.isLink,
};
},
-
- computedLogPath() {
- return this.isLink && this.glFeatures.monitorLogging
- ? mergeUrlParams({ pod_name: this.podName }, this.logsPath)
- : null;
- },
},
};
</script>
@@ -88,7 +73,6 @@ export default {
v-gl-tooltip
:class="cssClass"
:title="tooltipText"
- :href="computedLogPath"
class="deployment-instance d-flex justify-content-center align-items-center"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
index ca427ed4897..b9608a26d91 100644
--- a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
+++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue
@@ -22,7 +22,7 @@ export default {
});
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index a512eb687b7..a246eadb790 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -37,6 +37,7 @@ export default {
aria-expanded="false"
>
<gl-loading-icon v-show="isLoading" size="sm" :inline="true" />
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<slot v-if="$slots.default"></slot>
<span v-else class="dropdown-toggle-text"> {{ toggleText }} </span>
<gl-icon
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
index 5d0ed8b0821..1da84df022f 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -75,7 +75,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql
new file mode 100644
index 00000000000..38222e4e8c2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql
@@ -0,0 +1,6 @@
+fragment ContactFragment on CustomerRelationsContact {
+ id
+ firstName
+ lastName
+ email
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql
new file mode 100644
index 00000000000..a7de3c7f7af
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql
@@ -0,0 +1,4 @@
+fragment OrganizationFragment on CustomerRelationsOrganization {
+ id
+ name
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql
new file mode 100644
index 00000000000..647aaa0f7f8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql
@@ -0,0 +1,28 @@
+#import "./crm_contact.fragment.graphql"
+
+query searchCrmContacts(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $searchString: String
+ $searchIds: [CustomerRelationsContactID!]
+) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
+ contacts(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...ContactFragment
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ group {
+ id
+ contacts(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...ContactFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql
new file mode 100644
index 00000000000..c4f4663de45
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql
@@ -0,0 +1,28 @@
+#import "./crm_organization.fragment.graphql"
+
+query searchCrmOrganizations(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $searchString: String
+ $searchIds: [CustomerRelationsOrganizationID!]
+) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ id
+ organizations(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...OrganizationFragment
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ id
+ group {
+ id
+ organizations(search: $searchString, ids: $searchIds) {
+ nodes {
+ ...OrganizationFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
new file mode 100644
index 00000000000..adfe0559b62
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+
+import { ITEM_TYPE } from '~/groups/constants';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import createFlash from '~/flash';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql';
+
+import { DEFAULT_NONE_ANY } from '../constants';
+
+import BaseToken from './base_token.vue';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ contacts: this.config.initialContacts || [],
+ loading: false,
+ };
+ },
+ computed: {
+ defaultContacts() {
+ return this.config.defaultContacts || DEFAULT_NONE_ANY;
+ },
+ namespace() {
+ return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ },
+ },
+ methods: {
+ getActiveContact(contacts, data) {
+ return contacts.find((contact) => {
+ return `${this.formatContactId(contact)}` === data;
+ });
+ },
+ getContactName(contact) {
+ return `${contact.firstName} ${contact.lastName}`;
+ },
+ fetchContacts(searchTerm) {
+ let searchString = null;
+ let searchId = null;
+ if (isPositiveInteger(searchTerm)) {
+ searchId = this.formatContactGraphQLId(searchTerm);
+ } else {
+ searchString = searchTerm;
+ }
+
+ this.loading = true;
+
+ this.$apollo
+ .query({
+ query: searchCrmContactsQuery,
+ variables: {
+ fullPath: this.config.fullPath,
+ searchString,
+ searchIds: searchId ? [searchId] : null,
+ isProject: this.config.isProject,
+ },
+ })
+ .then(({ data }) => {
+ this.contacts = this.config.isProject
+ ? data[this.namespace]?.group.contacts.nodes
+ : data[this.namespace]?.contacts.nodes;
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching CRM contacts.'),
+ }),
+ )
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ formatContactId(contact) {
+ return `${getIdFromGraphQLId(contact.id)}`;
+ },
+ formatContactGraphQLId(id) {
+ return convertToGraphQLId('CustomerRelations::Contact', id);
+ },
+ },
+};
+</script>
+
+<template>
+ <base-token
+ :config="config"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="contacts"
+ :get-active-token-value="getActiveContact"
+ :default-suggestions="defaultContacts"
+ v-bind="$attrs"
+ @fetch-suggestions="fetchContacts"
+ v-on="$listeners"
+ >
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? getContactName(activeTokenValue) : inputValue }}
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion
+ v-for="contact in suggestions"
+ :key="formatContactId(contact)"
+ :value="formatContactId(contact)"
+ >
+ <div>
+ <div>{{ getContactName(contact) }}</div>
+ <div class="gl-font-sm">{{ contact.email }}</div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
new file mode 100644
index 00000000000..e6ab944449e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
+
+import { ITEM_TYPE } from '~/groups/constants';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import createFlash from '~/flash';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql';
+
+import { DEFAULT_NONE_ANY } from '../constants';
+
+import BaseToken from './base_token.vue';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ organizations: this.config.initialOrganizations || [],
+ loading: false,
+ };
+ },
+ computed: {
+ defaultOrganizations() {
+ return this.config.defaultOrganizations || DEFAULT_NONE_ANY;
+ },
+ namespace() {
+ return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ },
+ },
+ methods: {
+ getActiveOrganization(organizations, data) {
+ return organizations.find((organization) => {
+ return `${this.formatOrganizationId(organization)}` === data;
+ });
+ },
+ fetchOrganizations(searchTerm) {
+ let searchString = null;
+ let searchId = null;
+ if (isPositiveInteger(searchTerm)) {
+ searchId = this.formatOrganizationGraphQLId(searchTerm);
+ } else {
+ searchString = searchTerm;
+ }
+
+ this.loading = true;
+
+ this.$apollo
+ .query({
+ query: searchCrmOrganizationsQuery,
+ variables: {
+ fullPath: this.config.fullPath,
+ searchString,
+ searchIds: searchId ? [searchId] : null,
+ isProject: this.config.isProject,
+ },
+ })
+ .then(({ data }) => {
+ this.organizations = this.config.isProject
+ ? data[this.namespace]?.group.organizations.nodes
+ : data[this.namespace]?.organizations.nodes;
+ })
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching CRM organizations.'),
+ }),
+ )
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ formatOrganizationId(organization) {
+ return `${getIdFromGraphQLId(organization.id)}`;
+ },
+ formatOrganizationGraphQLId(id) {
+ return convertToGraphQLId('CustomerRelations::Organization', id);
+ },
+ },
+};
+</script>
+
+<template>
+ <base-token
+ :config="config"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="organizations"
+ :get-active-token-value="getActiveOrganization"
+ :default-suggestions="defaultOrganizations"
+ v-bind="$attrs"
+ @fetch-suggestions="fetchOrganizations"
+ v-on="$listeners"
+ >
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? activeTokenValue.name : inputValue }}
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion
+ v-for="organization in suggestions"
+ :key="formatOrganizationId(organization)"
+ :value="formatOrganizationId(organization)"
+ >
+ {{ organization.name }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 15d858b99b9..482a2964b4c 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -139,6 +139,7 @@ export default {
/>
</template>
</gl-form-input-group>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index f2abade8036..96f7427dda1 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -163,6 +163,7 @@ export default {
</template>
</section>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
<slot></slot>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index c3f184446a8..1b89bd324c6 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -38,6 +38,7 @@ export default {
<template #default>
<div v-safe-html="options.content"></div>
</template>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index 4ece87310c7..96c779c5ce4 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -100,7 +100,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 1f309a19b14..32b3a0e22c2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -248,7 +248,7 @@ export default {
labels: this.enableAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
- contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
+ contacts: this.enableAutocomplete,
},
true,
);
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 8a1b8363f19..7646a8718d6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -139,8 +139,8 @@ export default {
</script>
<template>
- <div class="md-suggestion-header border-bottom-0 mt-2">
- <div class="js-suggestion-diff-header font-weight-bold">
+ <div class="md-suggestion-header border-bottom-0 gl-mt-3">
+ <div class="js-suggestion-diff-header gl-font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<gl-icon name="question-o" css-classes="link-highlight" />
@@ -151,13 +151,13 @@ export default {
</gl-badge>
<div
v-else-if="isApplying"
- class="d-flex align-items-center text-secondary"
+ class="gl-display-flex gl-align-items-center text-secondary"
data-qa-selector="applying_badge"
>
- <gl-loading-icon size="sm" class="d-flex-center mr-2" />
+ <gl-loading-icon size="sm" class="gl-align-items-center gl-justify-content-center gl-mr-3" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="isLoggedIn" class="d-flex align-items-center">
+ <div v-else-if="isLoggedIn" class="gl-display-flex gl-align-items-center">
<div v-if="isBatched">
<gl-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 624dbcc6d8e..0cb4a5bc39f 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -16,17 +16,17 @@
* :note="{body: 'This is a note'}"
* />
*/
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { renderMarkdown } from '~/notes/utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
name: 'PlaceholderNote',
directives: { SafeHtml },
components: {
- userAvatarLink,
+ GlAvatarLink,
+ GlAvatar,
TimelineEntryItem,
},
props: {
@@ -55,7 +55,10 @@ export default {
return 24;
}
- return 40;
+ return {
+ default: 24,
+ md: 32,
+ };
},
},
};
@@ -64,11 +67,14 @@ export default {
<template>
<timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon">
- <user-avatar-link
- :link-href="getUserData.path"
- :img-src="getUserData.avatar_url"
- :img-size="avatarSize"
- />
+ <gl-avatar-link class="gl-mr-3" :href="getUserData.path">
+ <gl-avatar
+ :src="getUserData.avatar_url"
+ :entity-name="getUserData.username"
+ :alt="getUserData.name"
+ :size="avatarSize"
+ />
+ </gl-avatar-link>
</div>
<div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
<div class="note-header">
diff --git a/app/assets/javascripts/vue_shared/components/page_size_selector.vue b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
new file mode 100644
index 00000000000..9783946b786
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/page_size_selector.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export const PAGE_SIZES = [20, 50, 100];
+
+export default {
+ components: { GlDropdown, GlDropdownItem },
+ props: {
+ value: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ emitInput(pageSize) {
+ this.$emit('input', pageSize);
+ },
+ getPageSizeText(pageSize) {
+ return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
+ },
+ },
+ PAGE_SIZES,
+};
+</script>
+
+<template>
+ <gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
+ <gl-dropdown-item
+ v-for="pageSize in $options.PAGE_SIZES"
+ :key="pageSize"
+ @click="emitInput(pageSize)"
+ >
+ <span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index a8b250f2041..5516c9943b8 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -38,6 +38,7 @@ export default {
},
},
mounted() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
this.detailsSlots = Object.keys(this.$slots).filter((k) => k.startsWith('details-'));
},
methods: {
@@ -55,7 +56,7 @@ export default {
>
<div class="gl-display-flex gl-align-items-center gl-py-3">
<div
- v-if="$slots['left-action']"
+ v-if="$slots['left-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2"
>
<slot name="left-action"></slot>
@@ -65,7 +66,9 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
- v-if="$slots['left-primary']"
+ v-if="
+ $slots['left-primary'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
>
<slot name="left-primary"></slot>
@@ -79,7 +82,11 @@ export default {
/>
</div>
<div
- v-if="$slots['left-secondary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-secondary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1"
>
<slot name="left-secondary"></slot>
@@ -89,13 +96,21 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
>
<div
- v-if="$slots['right-primary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-primary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
>
<slot name="right-primary"></slot>
</div>
<div
- v-if="$slots['right-secondary']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-secondary'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-min-h-6"
>
<slot name="right-secondary"></slot>
@@ -103,7 +118,9 @@ export default {
</div>
</div>
<div
- v-if="$slots['right-action']"
+ v-if="
+ $slots['right-action'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<slot name="right-action"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index fc0976b0792..ad979387596 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -47,6 +47,7 @@ export default {
methods: {
recalculateMetadataSlots() {
const METADATA_PREFIX = 'metadata-';
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
if (!isEqual(metadataSlots, this.metadataSlots)) {
@@ -76,7 +77,9 @@ export default {
</h2>
<div
- v-if="$slots['sub-header']"
+ v-if="
+ $slots['sub-header'] /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-3"
>
<slot name="sub-header"></slot>
@@ -107,6 +110,7 @@ export default {
</template>
</div>
</div>
+ <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
<div v-if="$slots['right-actions']" class="gl-mt-3">
<slot name="right-actions"></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/epics_select/epics_select_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/health_status_select/health_status_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
new file mode 100644
index 00000000000..1c08433ee78
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/iterations_dropdown_bundle.js
@@ -0,0 +1 @@
+// This empty file satisfies the import/no-unresolved rule for ee_else_ce imports.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 5471cda0cc5..0127df730b8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -193,7 +193,7 @@ export default {
<gl-dropdown
ref="dropdown"
:text="buttonText"
- class="gl-w-full gl-mt-2"
+ class="gl-w-full"
data-testid="labels-select-dropdown-contents"
data-qa-selector="labels_dropdown_content"
@hide="handleDropdownHide"
diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue
index 67726f01744..641b09e0982 100644
--- a/app/assets/javascripts/vue_shared/components/slot_switch.vue
+++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue
@@ -20,6 +20,7 @@ export default {
computed: {
allSlotNames() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Object.keys(this.$slots);
},
},
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 0d78530d878..3ac35abcf3a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -45,6 +45,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
haskell: 'haskell',
haxe: 'haxe',
http: 'http',
+ html: 'xml',
hylang: 'hy',
ini: 'ini',
isbl: 'isbl',
@@ -90,7 +91,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
scala: 'scala',
scheme: 'scheme',
scss: 'scss',
- shell: 'shell',
+ shell: 'sh',
smalltalk: 'smalltalk',
sml: 'sml',
sqf: 'sqf',
@@ -112,6 +113,12 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
yaml: 'yaml',
};
+export const EVENT_ACTION = 'view_source';
+
+export const EVENT_LABEL_VIEWER = 'source_viewer';
+
+export const EVENT_LABEL_FALLBACK = 'legacy_fallback';
+
export const LINES_PER_CHUNK = 70;
export const BIDI_CHARS = [
@@ -138,3 +145,5 @@ export const BIDI_CHAR_TOOLTIP = __(
export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
+
+export const NPM_URL = 'https://npmjs.com/package';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
index c9f7e5508be..5d24a3d110b 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
@@ -1,5 +1,6 @@
import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants';
import wrapComments from './wrap_comments';
+import linkDependencies from './link_dependencies';
/**
* Registers our plugins for Highlight.js
@@ -8,6 +9,9 @@ import wrapComments from './wrap_comments';
*
* @param {Object} hljs - the Highlight.js instance.
*/
-export const registerPlugins = (hljs) => {
+export const registerPlugins = (hljs, fileType, rawContent) => {
hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
+ hljs.addPlugin({
+ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent),
+ });
};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
new file mode 100644
index 00000000000..5b7650c56ae
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
@@ -0,0 +1,25 @@
+import packageJsonLinker from './utils/package_json_linker';
+
+const DEPENDENCY_LINKERS = {
+ package_json: packageJsonLinker,
+};
+
+/**
+ * Highlight.js plugin for generating links to dependencies when viewing dependency files.
+ *
+ * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
+ *
+ * @param {Object} result - an object that represents the highlighted result from Highlight.js
+ * @param {String} fileType - a string containing the file type
+ * @param {String} rawContent - raw (non-highlighted) file content
+ */
+export default (result, fileType, rawContent) => {
+ if (DEPENDENCY_LINKERS[fileType]) {
+ try {
+ // eslint-disable-next-line no-param-reassign
+ result.value = DEPENDENCY_LINKERS[fileType](result, rawContent);
+ } catch (e) {
+ // Shallowed (do nothing), in this case the original unlinked dependencies will be rendered.
+ }
+ }
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
new file mode 100644
index 00000000000..56ad55ef553
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
@@ -0,0 +1,15 @@
+import { escape } from 'lodash';
+import { setAttributes } from '~/lib/utils/dom_utils';
+
+export const createLink = (href, innerText) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const rel = 'nofollow noreferrer noopener';
+ const link = document.createElement('a');
+
+ setAttributes(link, { href: escape(href), rel });
+ link.innerText = escape(innerText);
+
+ return link.outerHTML;
+};
+
+export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">&quot;`;
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
new file mode 100644
index 00000000000..d013d077ba3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
@@ -0,0 +1,46 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+import { NPM_URL } from '../../constants';
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const attrOpenTag = generateHLJSOpenTag('attr');
+const stringOpenTag = generateHLJSOpenTag('string');
+const closeTag = '&quot;</span>';
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects dependencies inside of content that is highlighted by Highlight.js
+ * Example: <span class="hljs-attr">&quot;@babel/core&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^7.18.5&quot;</span>
+ * Group 1: @babel/core
+ * Group 2: ^7.18.5
+ */
+ `${attrOpenTag}(.*)${closeTag}.*${stringOpenTag}(.*[0-9].*)(${closeTag})`,
+ 'gm',
+);
+
+const handleReplace = (original, packageName, version, dependenciesToLink) => {
+ const href = joinPaths(NPM_URL, packageName);
+ const packageLink = createLink(href, packageName);
+ const versionLink = createLink(href, version);
+ const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
+ const dependencyToLink = dependenciesToLink[packageName];
+
+ if (dependencyToLink && dependencyToLink === version) {
+ return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
+ }
+
+ return original;
+};
+
+export default (result, raw) => {
+ const { dependencies, devDependencies, peerDependencies, optionalDependencies } = JSON.parse(raw);
+
+ const dependenciesToLink = {
+ ...dependencies,
+ ...devDependencies,
+ ...peerDependencies,
+ ...optionalDependencies,
+ };
+
+ return result.value.replace(DEPENDENCY_REGEX, (original, packageName, version) =>
+ handleReplace(original, packageName, version, dependenciesToLink),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index f819a9e5be2..1bdae40332f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -3,7 +3,14 @@ import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
+import Tracking from '~/tracking';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+ LINES_PER_CHUNK,
+} from './constants';
import Chunk from './components/chunk.vue';
import { registerPlugins } from './plugins/index';
@@ -23,6 +30,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [Tracking.mixin()],
props: {
blob: {
type: Object,
@@ -49,8 +57,22 @@ export default {
lineNumbers() {
return this.splitContent.length;
},
+ unsupportedLanguage() {
+ const supportedLanguages = Object.keys(languageLoader);
+ return (
+ !supportedLanguages.includes(this.language) &&
+ !supportedLanguages.includes(this.blob.language)
+ );
+ },
},
async created() {
+ this.trackEvent(EVENT_LABEL_VIEWER);
+
+ if (this.unsupportedLanguage) {
+ this.handleUnsupportedLanguage();
+ return;
+ }
+
this.generateFirstChunk();
this.hljs = await this.loadHighlightJS();
@@ -70,6 +92,13 @@ export default {
});
},
methods: {
+ trackEvent(label) {
+ this.track(EVENT_ACTION, { label, property: this.blob.language });
+ },
+ handleUnsupportedLanguage() {
+ this.trackEvent(EVENT_LABEL_FALLBACK);
+ this.$emit('error');
+ },
generateFirstChunk() {
const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
this.firstChunk = this.createChunk(lines);
@@ -112,7 +141,7 @@ export default {
let detectedLanguage = language;
let highlightedContent;
if (this.hljs) {
- registerPlugins(this.hljs);
+ registerPlugins(this.hljs, this.blob.fileType, this.content);
if (!detectedLanguage) {
const hljsHighlightAuto = this.hljs.highlightAuto(content);
highlightedContent = hljsHighlightAuto.value;
diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
index 20a666509a4..779a2ab5461 100644
--- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
+++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
@@ -1,7 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__ } from '~/locale';
export default {
name: 'UsageBanner',
@@ -15,13 +14,6 @@ export default {
default: false,
},
},
- i18n: {
- dependencyProxy: s__('UsageQuota|Dependency proxy'),
- storageUsed: s__('UsageQuota|Storage used'),
- dependencyProxyMessage: s__(
- 'UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}',
- ),
- },
storageUsageQuotaHelpPage: helpPagePath('user/usage_quotas'),
};
</script>
@@ -33,13 +25,21 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
- v-if="$slots['left-primary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-primary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
>
<slot name="left-primary-text"></slot>
</div>
<div
- v-if="$slots['left-secondary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'left-secondary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1 gl-w-70p gl-md-max-w-70p"
>
<slot name="left-secondary-text"></slot>
@@ -49,13 +49,21 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0"
>
<div
- v-if="$slots['right-primary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-primary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6"
>
<slot name="right-primary-text"></slot>
</div>
<div
- v-if="$slots['right-secondary-text']"
+ v-if="
+ /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ $slots[
+ 'right-secondary-text'
+ ]
+ "
class="gl-display-flex gl-align-items-center gl-min-h-6"
>
<slot v-if="!loading" name="right-secondary-text"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index c58a5357883..707b0bbec67 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -96,7 +96,10 @@ export default {
/>
<gl-tooltip
- v-if="tooltipText || $slots.default"
+ v-if="
+ tooltipText ||
+ $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
:target="() => $refs.userAvatar.$el"
:placement="tooltipPlacement"
boundary="window"
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
index 15ba8e3b39b..6e8c200d5c3 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -100,7 +100,10 @@ export default {
class="avatar"
/>
<gl-tooltip
- v-if="tooltipText || $slots.default"
+ v-if="
+ tooltipText ||
+ $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
+ "
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
index 121c3bd94ef..ab5ddbc8af8 100644
--- a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
+++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
@@ -56,7 +56,13 @@ import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.que
* - shouldShowCallout: boolean
* - A combination of the above which should cover 95% of use cases: `true`
* if the query has loaded without error, and the user is logged in, and
- * the callout has not been dismissed yet; `false` otherwise.
+ * the callout has not been dismissed yet; `false` otherwise
+ *
+ * The component emits a `queryResult` event when the GraphQL query
+ * completes. The payload is a combination of the ApolloQueryResult object and
+ * this component's `slotProps` computed property. This is useful for things
+ * like cleaning up/unmounting the component if the callout shouldn't be
+ * displayed.
*/
export default {
name: 'UserCalloutDismisser',
@@ -86,6 +92,9 @@ export default {
update(data) {
return data?.currentUser;
},
+ result(data) {
+ this.$emit('queryResult', { ...data, ...this.slotProps });
+ },
error(err) {
this.queryError = err;
},
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 768cd005727..a0d8ca117a4 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -7,13 +7,13 @@ import {
GlSafeHtmlDirective,
GlSprintf,
GlButton,
+ GlAvatarLabeled,
} from '@gitlab/ui';
import { __ } from '~/locale';
-import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
-import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { isUserBusy } from '~/set_status_modal/utils';
import { USER_POPOVER_DELAY } from './constants';
const MAX_SKELETON_LINES = 4;
@@ -22,15 +22,17 @@ export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
USER_POPOVER_DELAY,
+ i18n: {
+ busy: __('Busy'),
+ },
components: {
GlIcon,
GlLink,
GlPopover,
GlSkeletonLoader,
- UserAvatarImage,
- UserNameWithStatus,
GlSprintf,
GlButton,
+ GlAvatarLabeled,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -95,6 +97,15 @@ export default {
toggleFollowButtonVariant() {
return this.user?.isFollowed ? 'default' : 'confirm';
},
+ hasPronouns() {
+ return Boolean(this.user?.pronouns?.trim());
+ },
+ isBusy() {
+ return isUserBusy(this.availabilityStatus);
+ },
+ username() {
+ return `@${this.user?.username}`;
+ },
},
methods: {
async toggleFollow() {
@@ -149,43 +160,46 @@ export default {
:placement="placement"
boundary="viewport"
triggers="hover focus manual"
+ data-testid="user-popover"
>
- <div class="gl-py-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
- <div class="gl-mr-4 gl-flex-shrink-0">
- <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" />
+ <div class="gl-mb-3">
+ <div v-if="userIsLoading" class="gl-w-20">
+ <gl-skeleton-loader :width="160" :height="64">
+ <rect x="70" y="19" rx="3" ry="3" width="88" height="9" />
+ <rect x="70" y="36" rx="3" ry="3" width="64" height="8" />
+ <circle cx="32" cy="32" r="32" />
+ </gl-skeleton-loader>
</div>
- <div class="gl-w-full gl-word-break-word gl-display-flex gl-align-items-center">
- <template v-if="userIsLoading">
- <gl-skeleton-loader
- :lines="$options.maxSkeletonLines"
- preserve-aspect-ratio="none"
- equal-width-lines
- :height="52"
- />
- </template>
- <template v-else>
- <div>
- <h5 class="gl-m-0">
- <user-name-with-status
- :name="user.name"
- :availability="availabilityStatus"
- :pronouns="user.pronouns"
- />
- </h5>
- <span class="gl-text-gray-500">@{{ user.username }}</span>
- <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3">
- <gl-button
- :variant="toggleFollowButtonVariant"
- :loading="toggleFollowLoading"
- size="small"
- data-testid="toggle-follow-button"
- @click="toggleFollow"
- >{{ toggleFollowButtonText }}</gl-button
- >
- </div>
- </div>
+ <gl-avatar-labeled
+ v-else
+ :size="64"
+ :src="user.avatarUrl"
+ :label="user.name"
+ :sub-label="username"
+ >
+ <gl-button
+ v-if="shouldRenderToggleFollowButton"
+ class="gl-mt-3 gl-align-self-start"
+ :variant="toggleFollowButtonVariant"
+ :loading="toggleFollowLoading"
+ size="small"
+ data-testid="toggle-follow-button"
+ @click="toggleFollow"
+ >{{ toggleFollowButtonText }}</gl-button
+ >
+
+ <template #meta>
+ <span
+ v-if="hasPronouns"
+ class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
+ data-testid="user-popover-pronouns"
+ >({{ user.pronouns }})</span
+ >
+ <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
+ >({{ $options.i18n.busy }})</span
+ >
</template>
- </div>
+ </gl-avatar-labeled>
</div>
<div class="gl-mt-2 gl-w-full gl-word-break-word">
<template v-if="userIsLoading">
diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
index eff39e2fb89..4ef9bc07b1c 100644
--- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -15,7 +15,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index 89eecea5239..25799171905 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -81,7 +81,8 @@ export default {
ref="textarea"
v-model="issuableDescription"
dir="auto"
- class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ class="note-textarea rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ data-qa-selector="issuable_form_description_field"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
></textarea>
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 a9f8caa3e1f..b616b390032 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
@@ -86,7 +86,18 @@ export default {
createdAt() {
return getTimeago().format(this.issuable.createdAt);
},
- updatedAt() {
+ timestamp() {
+ if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ return this.issuable.closedAt;
+ }
+ return this.issuable.updatedAt;
+ },
+ formattedTimestamp() {
+ if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ return sprintf(__('closed %{timeago}'), {
+ timeago: getTimeago().format(this.issuable.closedAt),
+ });
+ }
return sprintf(__('updated %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
@@ -134,6 +145,7 @@ export default {
},
methods: {
hasSlotContents(slotName) {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
return Boolean(this.$slots[slotName]);
},
scopedLabel(label) {
@@ -311,10 +323,10 @@ export default {
<div
v-gl-tooltip.bottom
class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
- :title="tooltipTitle(issuable.updatedAt)"
- data-testid="issuable-updated-at"
+ :title="tooltipTitle(timestamp)"
+ data-testid="issuable-timestamp"
>
- {{ updatedAt }}
+ {{ formattedTimestamp }}
</div>
</div>
</li>
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 8fbf0bb10a0..189bbb56432 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
@@ -1,11 +1,13 @@
<script>
import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { DEFAULT_SKELETON_COUNT } from '../constants';
+import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
@@ -29,6 +31,8 @@ export default {
IssuableBulkEditSidebar,
GlPagination,
VueDraggable,
+ PageSizeSelector,
+ LocalStorageSync,
},
props: {
namespace: {
@@ -173,6 +177,11 @@ export default {
required: false,
default: false,
},
+ showPageSizeChangeControls: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -262,7 +271,11 @@ export default {
handleVueDraggableUpdate({ newIndex, oldIndex }) {
this.$emit('reorder', { newIndex, oldIndex });
},
+ handlePageSizeChange(newPageSize) {
+ this.$emit('page-size-change', newPageSize);
+ },
},
+ PAGE_SIZE_STORAGE_KEY,
};
</script>
@@ -353,24 +366,38 @@ export default {
<slot v-else name="empty-state"></slot>
</template>
- <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <div class="gl-text-center gl-mt-6 gl-relative">
<gl-keyset-pagination
+ v-if="showPaginationControls && useKeysetPagination"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@next="$emit('next-page')"
@prev="$emit('previous-page')"
/>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
+
+ <local-storage-sync
+ v-if="showPageSizeChangeControls"
+ :value="defaultPageSize"
+ :storage-key="$options.PAGE_SIZE_STORAGE_KEY"
+ @input="handlePageSizeChange"
+ >
+ <page-size-selector
+ :value="defaultPageSize"
+ class="gl-absolute gl-right-0"
+ @input="handlePageSizeChange"
+ />
+ </local-storage-sync>
</div>
- <gl-pagination
- v-else-if="showPaginationControls"
- :per-page="defaultPageSize"
- :total-items="totalItems"
- :value="currentPage"
- :prev-page="previousPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="$emit('page-change', $event)"
- />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index be9afc0610d..507f333a34e 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -56,3 +56,5 @@ export const IssuableTypes = {
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
+
+export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size';
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
index f57b5b2deb4..d4e9120ff17 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
@@ -37,7 +37,11 @@ export default {
</script>
<template>
- <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
+ <div
+ class="description"
+ :class="{ 'js-task-list-container': canEdit && enableTaskList }"
+ data-qa-selector="description_content"
+ >
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
<textarea
v-if="issuable.description && enableTaskList"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
index 33dca3e9332..2fc1f935501 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -123,7 +123,6 @@ export default {
:placeholder="__('Title')"
:aria-label="__('Title')"
:autofocus="true"
- class="qa-title-input"
@keydown="handleKeydown($event, 'title')"
/>
</gl-form-group>
@@ -149,7 +148,7 @@ export default {
:data-supports-quick-actions="enableAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
- class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
@keydown="handleKeydown($event, 'description')"
></textarea>
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 f035795a045..cdc5903b934 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
@@ -112,7 +112,7 @@ export default {
<gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" />
<span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span>
</gl-badge>
- <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div class="issuable-meta gl-display-flex! gl-align-items-center">
<div v-if="blocked || confidential" class="gl-display-inline-block">
<div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
<gl-icon name="lock" :aria-label="__('Blocked')" />
@@ -139,13 +139,15 @@ export default {
:size="24"
:src="author.avatarUrl"
:label="author.name"
- class="d-none d-sm-inline-flex gl-mx-1"
+ :class="[{ 'gl-display-none': !isAuthorExternal }, 'gl-sm-display-inline-flex gl-mx-1']"
>
<template #meta>
- <gl-icon v-if="isAuthorExternal" name="external-link" />
+ <gl-icon v-if="isAuthorExternal" name="external-link" class="gl-ml-1" />
</template>
</gl-avatar-labeled>
- <strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
+ <strong v-if="author.username" class="author gl-display-inline gl-sm-display-none!"
+ >@{{ author.username }}</strong
+ >
</gl-avatar-link>
<span
v-if="taskCompletionStatus && hasTasks"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index 3d7c71ce974..35124bd15d2 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -64,8 +64,9 @@ export default {
<div class="title-container">
<h1
v-safe-html="issuable.titleHtml || issuable.title"
- class="title qa-title gl-font-size-h-display"
+ class="title gl-font-size-h-display"
dir="auto"
+ data-qa-selector="title_content"
data-testid="title"
></h1>
<gl-button
@@ -74,7 +75,7 @@ export default {
:title="$options.i18n.editTitleAndDescription"
:aria-label="$options.i18n.editTitleAndDescription"
icon="pencil"
- class="btn-edit js-issuable-edit qa-edit-button"
+ class="btn-edit js-issuable-edit"
@click="$emit('edit-issuable', $event)"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 69670d3471c..2dc8e3a1101 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -54,7 +54,7 @@ export default {
:label-for="$options.labelId"
label-cols="3"
label-cols-lg="2"
- label-class="gl-pb-0!"
+ label-class="gl-pb-0! gl-overflow-wrap-break"
class="gl-align-items-center"
>
<gl-form-select
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index ce2fa158596..1cdc9c28f05 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -1,5 +1,4 @@
<script>
-import { escape } from 'lodash';
import { __ } from '~/locale';
export default {
@@ -21,15 +20,11 @@ export default {
},
},
methods: {
- getSanitizedTitle(inputEl) {
- const { innerText } = inputEl;
- return escape(innerText);
- },
handleBlur({ target }) {
- this.$emit('title-changed', this.getSanitizedTitle(target));
+ this.$emit('title-changed', target.innerText);
},
handleInput({ target }) {
- this.$emit('title-input', this.getSanitizedTitle(target));
+ this.$emit('title-input', target.innerText);
},
handleSubmit() {
this.$refs.titleEl.blur();
@@ -40,7 +35,7 @@ export default {
<template>
<h2
- class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full"
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full"
:class="{ 'gl-cursor-not-allowed': disabled }"
aria-labelledby="item-title"
>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 31e4a932c5a..77002eeaf55 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -5,7 +5,7 @@ import Tracking from '~/tracking';
export default {
i18n: {
- deleteWorkItem: s__('WorkItem|Delete work item'),
+ deleteTask: s__('WorkItem|Delete task'),
},
components: {
GlDropdown,
@@ -54,7 +54,7 @@ export default {
right
>
<gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
- $options.i18n.deleteWorkItem
+ $options.i18n.deleteTask
}}</gl-dropdown-item>
</gl-dropdown>
<gl-modal
@@ -66,9 +66,7 @@ export default {
@hide="handleCancelDeleteWorkItem"
>
{{
- s__(
- 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.',
- )
+ s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.')
}}
</gl-modal>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 4d1c171772e..9ff424aa20f 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -1,10 +1,35 @@
<script>
-import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui';
+import {
+ GlTokenSelector,
+ GlIcon,
+ GlAvatar,
+ GlLink,
+ GlSkeletonLoader,
+ GlButton,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { n__, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
-function isClosingIcon(el) {
- return el?.classList.contains('gl-token-close');
+function isTokenSelectorElement(el) {
+ return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item');
+}
+
+function addClass(el) {
+ return {
+ ...el,
+ class: 'gl-bg-transparent',
+ };
}
export default {
@@ -13,7 +38,15 @@ export default {
GlIcon,
GlAvatar,
GlLink,
+ GlSkeletonLoader,
+ GlButton,
+ SidebarParticipant,
+ InviteMembersTrigger,
+ GlDropdownItem,
+ GlDropdownDivider,
},
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -23,67 +56,188 @@ export default {
type: Array,
required: true,
},
+ allowsMultipleAssignees: {
+ type: Boolean,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
isEditing: false,
- localAssignees: this.assignees.map((assignee) => ({
- ...assignee,
- class: 'gl-bg-transparent!',
- })),
+ searchStarted: false,
+ localAssignees: this.assignees.map(addClass),
+ searchKey: '',
+ searchUsers: [],
+ currentUser: null,
};
},
+ apollo: {
+ searchUsers: {
+ query() {
+ return userSearchQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchKey,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user }));
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ currentUser: {
+ query: currentUserQuery,
+ },
+ },
computed: {
- assigneeIds() {
- return this.localAssignees.map((assignee) => assignee.id);
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: `type_${this.workItemType}`,
+ };
},
assigneeListEmpty() {
return this.assignees.length === 0;
},
containerClass() {
- return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : '';
+ return !this.isEditing ? 'gl-shadow-none!' : '';
+ },
+ isLoadingUsers() {
+ return this.$apollo.queries.searchUsers.loading;
+ },
+ assigneeText() {
+ return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length);
+ },
+ dropdownItems() {
+ if (this.currentUser && this.searchEmpty) {
+ if (this.searchUsers.some((user) => user.username === this.currentUser.username)) {
+ return this.moveCurrentUserToStart(this.searchUsers);
+ }
+ return [this.currentUser, ...this.searchUsers];
+ }
+ return this.searchUsers;
+ },
+ searchEmpty() {
+ return this.searchKey.length === 0;
+ },
+ addAssigneesText() {
+ return this.allowsMultipleAssignees
+ ? s__('WorkItem|Add assignees')
+ : s__('WorkItem|Add assignee');
},
},
+ watch: {
+ assignees(newVal) {
+ if (!this.isEditing) {
+ this.localAssignees = newVal.map(addClass);
+ }
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
methods: {
getUserId(id) {
return getIdFromGraphQLId(id);
},
- setAssignees(e) {
- if (isClosingIcon(e.relatedTarget) || !this.isEditing) return;
+ handleAssigneesInput(assignees) {
+ if (!this.allowsMultipleAssignees) {
+ this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : [];
+ this.isEditing = false;
+ return;
+ }
+ this.localAssignees = assignees;
+ this.focusTokenSelector();
+ },
+ handleBlur(e) {
+ if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return;
this.isEditing = false;
+ this.setAssignees(this.localAssignees);
+ },
+ setAssignees(assignees) {
this.$apollo.mutate({
mutation: localUpdateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
- assigneeIds: this.assigneeIds,
+ assignees,
},
},
});
+ this.track('updated_assignees');
},
- async focusTokenSelector() {
+ handleFocus() {
this.isEditing = true;
+ this.searchStarted = true;
+ },
+ async focusTokenSelector() {
+ this.handleFocus();
await this.$nextTick();
this.$refs.tokenSelector.focusTextInput();
},
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ moveCurrentUserToStart(users = []) {
+ if (this.currentUser) {
+ return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)];
+ }
+ return users;
+ },
+ closeDropdown() {
+ this.$refs.tokenSelector.closeDropdown();
+ },
},
};
</script>
<template>
- <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative">
- <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{
- __('Assignee(s)')
- }}</span>
+ <div class="form-row gl-mb-5 work-item-assignees gl-relative">
+ <span
+ class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ data-testid="assignees-title"
+ >{{ assigneeText }}</span
+ >
<gl-token-selector
ref="tokenSelector"
- v-model="localAssignees"
- hide-dropdown-with-no-items
+ :selected-tokens="localAssignees"
:container-class="containerClass"
- class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
- @token-remove="focusTokenSelector"
- @focus="isEditing = true"
- @blur="setAssignees"
+ class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!"
+ :class="{ 'gl-hover-border-gray-200': canUpdate }"
+ :dropdown-items="dropdownItems"
+ :loading="isLoadingUsers"
+ :view-only="!canUpdate"
+ @input="handleAssigneesInput"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @blur="handleBlur"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
>
<template #empty-placeholder>
<div
@@ -91,7 +245,15 @@ export default {
data-testid="empty-state"
>
<gl-icon name="profile" />
- <span class="gl-ml-2">{{ __('Add assignees') }}</span>
+ <span class="gl-ml-2 gl-mr-4">{{ addAssigneesText }}</span>
+ <gl-button
+ v-if="currentUser"
+ size="small"
+ class="assign-myself"
+ data-testid="assign-self"
+ @click.stop="setAssignees([currentUser])"
+ >{{ __('Assign myself') }}</gl-button
+ >
</div>
</template>
<template #token-content="{ token }">
@@ -106,6 +268,29 @@ export default {
<span class="gl-pl-2">{{ token.name }}</span>
</gl-link>
</template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <sidebar-participant :user="dropdownItem" />
+ </template>
+ <template #loading-content>
+ <gl-skeleton-loader :height="170">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="380" height="20" x="10" y="95" rx="4" />
+ <rect width="280" height="20" x="10" y="130" rx="4" />
+ </gl-skeleton-loader>
+ </template>
+ <template #dropdown-footer>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="closeDropdown">
+ <invite-members-trigger
+ :display-text="__('Invite members')"
+ trigger-element="side-nav"
+ icon="plus"
+ trigger-source="work-item-assignees-dropdown"
+ classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
+ />
+ </gl-dropdown-item>
+ </template>
</gl-token-selector>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 5a85fcdd7ac..90e3cd45cb4 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -35,7 +35,7 @@ export default {
isEditing: false,
isSubmitting: false,
isSubmittingWithKeydown: false,
- desc: '',
+ descriptionText: '',
};
},
apollo: {
@@ -71,16 +71,17 @@ export default {
descriptionHtml() {
return this.workItemDescription?.descriptionHtml;
},
- descriptionText: {
- get() {
- return this.desc;
- },
- set(desc) {
- this.desc = desc;
- },
+ descriptionEmpty() {
+ return this.descriptionHtml?.trim() === '';
},
workItemDescription() {
- return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
+ const descriptionWidget = this.workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
+ );
+ return {
+ ...descriptionWidget,
+ description: descriptionWidget?.description || '',
+ };
},
workItemType() {
return this.workItem?.workItemType?.name;
@@ -95,14 +96,14 @@ export default {
async startEditing() {
this.isEditing = true;
- this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || '';
+ this.descriptionText = getDraft(this.autosaveKey) || this.workItemDescription?.description;
await this.$nextTick();
this.$refs.textarea.focus();
},
async cancelEditing() {
- const isDirty = this.desc !== this.workItemDescription?.description;
+ const isDirty = this.descriptionText !== this.workItemDescription?.description;
if (isDirty) {
const msg = s__('WorkItem|Are you sure you want to cancel editing?');
@@ -125,7 +126,7 @@ export default {
return;
}
- updateDraft(this.autosaveKey, this.desc);
+ updateDraft(this.autosaveKey, this.descriptionText);
},
async updateWorkItem(event) {
if (event.key) {
@@ -171,25 +172,10 @@ export default {
<template>
<gl-form-group
v-if="isEditing"
- class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b"
+ class="gl-my-5"
:label="__('Description')"
label-for="work-item-description"
- label-class="gl-float-left"
>
- <div class="gl-display-flex gl-justify-content-flex-end">
- <gl-button class="gl-ml-auto" data-testid="cancel" @click="cancelEditing">{{
- __('Cancel')
- }}</gl-button>
- <gl-button
- class="js-no-auto-disable gl-ml-4"
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- data-testid="save-description"
- @click="updateWorkItem"
- >{{ __('Save') }}</gl-button
- >
- </div>
<markdown-field
can-attach-file
:textarea-value="descriptionText"
@@ -216,19 +202,35 @@ export default {
></textarea>
</template>
</markdown-field>
- </gl-form-group>
- <div v-else class="gl-pt-5 gl-mb-5 gl-border-t gl-border-b">
+
<div class="gl-display-flex">
- <h3 class="gl-font-base gl-mt-0">{{ __('Description') }}</h3>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ __('Save') }}</gl-button
+ >
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{
+ __('Cancel')
+ }}</gl-button>
+ </div>
+ </gl-form-group>
+ <div v-else class="gl-mb-5">
+ <div class="gl-display-flex gl-align-items-center gl-mb-5">
+ <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
icon="pencil"
data-testid="edit-description"
+ :aria-label="__('Edit')"
@click="startEditing"
- >{{ __('Edit') }}</gl-button
- >
+ />
</div>
- <div v-safe-html="descriptionHtml" class="md gl-mb-5"></div>
+
+ <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+ <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 5272df2d53f..ad90fe88947 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,11 +1,15 @@
<script>
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
i18n,
- WIDGET_TYPE_ASSIGNEE,
+ WIDGET_TYPE_ASSIGNEES,
+ WIDGET_TYPE_LABELS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_WEIGHT,
+ WIDGET_TYPE_HIERARCHY,
+ WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
@@ -14,22 +18,34 @@ import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemDescription from './work_item_description.vue';
import WorkItemAssignees from './work_item_assignees.vue';
+import WorkItemLabels from './work_item_labels.vue';
import WorkItemWeight from './work_item_weight.vue';
+import WorkItemInformation from './work_item_information.vue';
export default {
i18n,
components: {
GlAlert,
+ GlButton,
GlSkeletonLoader,
+ GlIcon,
WorkItemAssignees,
WorkItemActions,
WorkItemDescription,
+ WorkItemLabels,
WorkItemTitle,
WorkItemState,
WorkItemWeight,
+ WorkItemInformation,
+ LocalStorageSync,
},
mixins: [glFeatureFlagMixin()],
props: {
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
workItemId: {
type: String,
required: false,
@@ -45,6 +61,7 @@ export default {
return {
error: undefined,
workItem: {},
+ showInfoBanner: true,
};
},
apollo: {
@@ -91,17 +108,40 @@ export default {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
},
workItemAssignees() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE);
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES);
+ },
+ workItemLabels() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemWeight() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
+ workItemHierarchy() {
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
+ },
+ parentWorkItem() {
+ return this.workItemHierarchy?.parent;
+ },
+ parentUrl() {
+ return `../../issues/${this.parentWorkItem?.iid}`;
+ },
+ },
+ beforeDestroy() {
+ /** make sure that if the user has not even dismissed the alert ,
+ * should no be able to see the information next time and update the local storage * */
+ this.dismissBanner();
},
+ methods: {
+ dismissBanner() {
+ this.showInfoBanner = false;
+ },
+ },
+ WORK_ITEM_VIEWED_STORAGE_KEY,
};
</script>
<template>
- <section>
+ <section class="gl-pt-5">
<gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
{{ error }}
</gl-alert>
@@ -113,39 +153,95 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div class="gl-display-flex gl-align-items-start">
- <work-item-title
- :work-item-id="workItem.id"
- :work-item-title="workItem.title"
- :work-item-type="workItemType"
- :work-item-parent-id="workItemParentId"
- class="gl-mr-5"
- @error="error = $event"
- />
+ <div class="gl-display-flex gl-align-items-center">
+ <ul
+ v-if="parentWorkItem"
+ class="list-unstyled gl-display-flex gl-mr-auto"
+ data-testid="work-item-parent"
+ >
+ <li class="gl-ml-n4">
+ <gl-button icon="issues" category="tertiary" :href="parentUrl">{{
+ parentWorkItem.title
+ }}</gl-button>
+ <gl-icon name="chevron-right" :size="16" />
+ </li>
+ <li class="gl-px-4 gl-py-3 gl-line-height-0">
+ <gl-icon name="task-done" />
+ {{ workItemType }}
+ </li>
+ </ul>
+ <span
+ v-else
+ class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
+ data-testid="work-item-type"
+ >{{ workItemType }}</span
+ >
<work-item-actions
:work-item-id="workItem.id"
:can-delete="canDelete"
- class="gl-ml-auto gl-mt-6"
@deleteWorkItem="$emit('deleteWorkItem')"
@error="error = $event"
/>
+ <gl-button
+ v-if="isModal"
+ category="tertiary"
+ data-testid="work-item-close"
+ icon="close"
+ :aria-label="__('Close')"
+ @click="$emit('close')"
+ />
</div>
- <template v-if="workItemsMvc2Enabled">
- <work-item-assignees
- v-if="workItemAssignees"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.nodes"
+ <local-storage-sync
+ v-model="showInfoBanner"
+ :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY"
+ >
+ <work-item-information
+ v-if="showInfoBanner"
+ :show-info-banner="showInfoBanner"
+ @work-item-banner-dismissed="dismissBanner"
/>
- <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" />
- </template>
+ </local-storage-sync>
+ <work-item-title
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
+ @error="error = $event"
+ />
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"
@error="error = $event"
/>
+ <template v-if="workItemsMvc2Enabled">
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ @error="error = $event"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
+ :work-item-id="workItem.id"
+ :can-update="canUpdate"
+ @error="error = $event"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ />
+ </template>
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
+ class="gl-pt-5"
@error="error = $event"
/>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index d1c8022ac57..df7c6cab7ef 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -80,13 +80,16 @@ export default {
.catch((e) => {
this.error =
e.message ||
- s__('WorkItem|Something went wrong when deleting the work item. Please try again.');
+ s__('WorkItem|Something went wrong when deleting the task. Please try again.');
});
},
closeModal() {
this.error = '';
this.$emit('close');
},
+ hide() {
+ this.$refs.modal.hide();
+ },
setErrorMessage(message) {
this.error = message;
},
@@ -104,7 +107,6 @@ export default {
size="lg"
modal-id="work-item-detail-modal"
header-class="gl-p-0 gl-pb-2!"
- body-class="gl-pb-6!"
@hide="closeModal"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
@@ -112,9 +114,11 @@ export default {
</gl-alert>
<work-item-detail
+ is-modal
:work-item-parent-id="issueGid"
:work-item-id="workItemId"
class="gl-p-5 gl-mt-n3"
+ @close="hide"
@deleteWorkItem="deleteWorkItem"
/>
</gl-modal>
diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue
new file mode 100644
index 00000000000..2ff7ba169ea
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_information.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ i18n: {
+ learnTasksButtonText: s__('WorkItem|Learn about tasks'),
+ workItemsText: s__('WorkItem|work items'),
+ tasksInformationTitle: s__('WorkItem|Introducing tasks'),
+ tasksInformationBody: s__(
+ 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.',
+ ),
+ },
+ helpPageLinks: {
+ tasksDocLinkPath: helpPagePath('user/tasks'),
+ workItemsLinkPath: helpPagePath(`development/work_items`),
+ },
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ showInfoBanner: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ emits: ['work-item-banner-dismissed'],
+};
+</script>
+
+<template>
+ <section class="gl-display-block gl-mb-2">
+ <gl-alert
+ v-if="showInfoBanner"
+ variant="tip"
+ :title="$options.i18n.tasksInformationTitle"
+ :primary-button-link="$options.helpPageLinks.tasksDocLinkPath"
+ :primary-button-text="$options.i18n.learnTasksButtonText"
+ data-testid="work-item-information"
+ class="gl-mt-3"
+ @dismiss="$emit('work-item-banner-dismissed')"
+ >
+ <gl-sprintf :message="$options.i18n.tasksInformationBody">
+ <template #workItemsLink>
+ <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{
+ $options.i18n.workItemsText
+ }}</gl-link>
+ </template>
+ ></gl-sprintf
+ >
+ </gl-alert>
+ </section>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
new file mode 100644
index 00000000000..78ed67998d7
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -0,0 +1,246 @@
+<script>
+import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import Tracking from '~/tracking';
+import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants';
+
+function isTokenSelectorElement(el) {
+ return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item');
+}
+
+function addClass(el) {
+ return {
+ ...el,
+ class: 'gl-bg-transparent',
+ };
+}
+
+export default {
+ components: {
+ GlTokenSelector,
+ GlLabel,
+ GlSkeletonLoader,
+ LabelItem,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ searchStarted: false,
+ localLabels: [],
+ searchKey: '',
+ searchLabels: [],
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ searchLabels: {
+ query: labelSearchQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.searchKey,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label }));
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_labels',
+ property: `type_${this.workItem.workItemType?.name}`,
+ };
+ },
+ allowScopedLabels() {
+ return this.labelsWidget.allowScopedLabels;
+ },
+ listEmpty() {
+ return this.labels.length === 0;
+ },
+ containerClass() {
+ return !this.isEditing ? 'gl-shadow-none!' : '';
+ },
+ isLoading() {
+ return this.$apollo.queries.searchLabels.loading;
+ },
+ labelsWidget() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
+ },
+ labels() {
+ return this.labelsWidget?.nodes || [];
+ },
+ },
+ watch: {
+ labels(newVal) {
+ if (!this.isEditing) {
+ this.localLabels = newVal.map(addClass);
+ }
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ methods: {
+ getId(id) {
+ return getIdFromGraphQLId(id);
+ },
+ removeLabel({ id }) {
+ this.localLabels = this.localLabels.filter((label) => label.id !== id);
+ },
+ setLabels(event) {
+ this.searchKey = '';
+ if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return;
+ this.isEditing = false;
+ this.$apollo
+ .mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ labels: this.localLabels,
+ },
+ },
+ })
+ .catch((e) => {
+ this.$emit('error', e);
+ });
+ this.track('updated_labels');
+ },
+ handleFocus() {
+ this.isEditing = true;
+ this.searchStarted = true;
+ },
+ async focusTokenSelector(labels) {
+ if (this.allowScopedLabels) {
+ const newLabel = labels[labels.length - 1];
+ const existingLabels = labels.slice(0, labels.length - 1);
+
+ const newLabelKey = scopedLabelKey(newLabel);
+
+ const removeLabelsWithSameScope = existingLabels.filter((label) => {
+ const sameKey = newLabelKey === scopedLabelKey(label);
+ return !sameKey;
+ });
+
+ this.localLabels = [...removeLabelsWithSameScope, newLabel];
+ }
+ this.handleFocus();
+ await this.$nextTick();
+ this.$refs.tokenSelector.focusTextInput();
+ },
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="form-row gl-mb-5 work-item-labels gl-relative">
+ <span
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ data-testid="labels-title"
+ >{{ __('Labels') }}</span
+ >
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="localLabels"
+ :container-class="containerClass"
+ :dropdown-items="searchLabels"
+ :loading="isLoading"
+ :view-only="!canUpdate"
+ class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ @input="focusTokenSelector"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @blur="setLabels"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
+ >
+ <template #empty-placeholder>
+ <div
+ class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2"
+ data-testid="empty-state"
+ >
+ <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span>
+ <span v-else class="gl-ml-2">{{ __('None') }}</span>
+ </div>
+ </template>
+ <template #token-content="{ token }">
+ <gl-label
+ :data-qa-label-name="token.title"
+ :title="token.title"
+ :description="token.description"
+ :background-color="token.color"
+ :scoped="scopedLabel(token)"
+ :show-close-button="canUpdate"
+ @close="removeLabel(token)"
+ />
+ </template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <label-item :label="dropdownItem" />
+ </template>
+ <template #loading-content>
+ <gl-skeleton-loader :height="170">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="380" height="20" x="10" y="95" rx="4" />
+ <rect width="280" height="20" x="10" y="130" rx="4" />
+ </gl-skeleton-loader>
+ </template>
+ </gl-token-selector>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 320a4a213e3..176f84f6c1a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -1,9 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import WorkItemLinks from './work_item_links.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -19,6 +21,7 @@ export default function initWorkItemLinks() {
if (!workItemLinksRoot) {
return;
}
+
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
@@ -27,6 +30,9 @@ export default function initWorkItemLinks() {
components: {
workItemLinks: WorkItemLinks,
},
+ provide: {
+ projectPath: workItemLinksRoot.dataset.projectPath,
+ },
render: (createElement) =>
createElement('work-item-links', {
props: {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index bdfff100333..89f086cfca5 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -11,6 +11,7 @@ import {
} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
import WorkItemLinksForm from './work_item_links_form.vue';
+import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
@@ -19,6 +20,7 @@ export default {
GlIcon,
GlLoadingIcon,
WorkItemLinksForm,
+ WorkItemLinksMenu,
},
props: {
workItemId: {
@@ -77,6 +79,9 @@ export default {
isLoading() {
return this.$apollo.queries.children.loading;
},
+ childrenIds() {
+ return this.children.map((c) => c.id);
+ },
},
methods: {
badgeVariant(state) {
@@ -88,13 +93,16 @@ export default {
toggleAddForm() {
this.isShownAddForm = !this.isShownAddForm;
},
+ addChild(child) {
+ this.children = [child, ...this.children];
+ },
},
i18n: {
title: s__('WorkItem|Child items'),
emptyStateMessage: s__(
'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
),
- addChildButtonLabel: s__('WorkItem|Add a child'),
+ addChildButtonLabel: s__('WorkItem|Add a task'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
@@ -107,8 +115,16 @@ export default {
class="gl-p-4 gl-display-flex gl-justify-content-space-between"
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
>
- <h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4">
+ <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5>
+ <gl-button
+ v-if="!isShownAddForm"
+ category="secondary"
+ data-testid="toggle-add-form"
+ @click="toggleAddForm"
+ >
+ {{ $options.i18n.addChildButtonLabel }}
+ </gl-button>
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3">
<gl-button
category="tertiary"
:icon="toggleIcon"
@@ -126,37 +142,38 @@ export default {
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
<template v-else>
- <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty">
- <p>
+ <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty">
+ <p class="gl-my-3">
{{ $options.i18n.emptyStateMessage }}
</p>
- <gl-button
- v-if="!isShownAddForm"
- category="secondary"
- variant="confirm"
- data-testid="toggle-add-form"
- @click="toggleAddForm"
- >
- {{ $options.i18n.addChildButtonLabel }}
- </gl-button>
- <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" />
</div>
+ <work-item-links-form
+ v-if="isShownAddForm"
+ data-testid="add-links-form"
+ :issuable-gid="issuableGid"
+ :children-ids="childrenIds"
+ @cancel="toggleAddForm"
+ @addWorkItemChild="addChild"
+ />
<div
v-for="child in children"
:key="child.id"
- class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
data-testid="links-child"
>
<div>
<gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
<span class="gl-word-break-all">{{ child.title }}</span>
</div>
- <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0">
+ <div
+ class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center"
+ >
<gl-badge :variant="badgeVariant(child.state)">
<span class="gl-sm-display-block">{{
$options.WORK_ITEM_STATUS_TEXT[child.state]
}}</span>
</gl-badge>
+ <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 22728f58026..fadba0753db 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,27 +1,127 @@
<script>
-import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { __, s__ } from '~/locale';
+import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
export default {
components: {
+ GlAlert,
GlForm,
- GlFormInput,
+ GlFormCombobox,
GlButton,
},
+ inject: ['projectPath'],
+ props: {
+ issuableGid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ childrenIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ apollo: {
+ availableWorkItems: {
+ query: projectWorkItemsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ searchTerm: this.search?.title || this.search,
+ types: ['TASK'],
+ };
+ },
+ skip() {
+ return this.search.length === 0;
+ },
+ update(data) {
+ return data.workspace.workItems.edges
+ .filter((wi) => !this.childrenIds.includes(wi.node.id))
+ .map((wi) => wi.node);
+ },
+ },
+ },
data() {
return {
- relatedWorkItem: '',
+ availableWorkItems: [],
+ search: '',
+ error: null,
};
},
+ methods: {
+ getIdFromGraphQLId,
+ unsetError() {
+ this.error = null;
+ },
+ addChild() {
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.issuableGid,
+ hierarchyWidget: {
+ childrenIds: [this.search.id],
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate?.errors?.length) {
+ [this.error] = data.workItemUpdate.errors;
+ } else {
+ this.unsetError();
+ this.$emit('addWorkItemChild', this.search);
+ }
+ })
+ .catch(() => {
+ this.error = this.$options.i18n.errorMessage;
+ })
+ .finally(() => {
+ this.search = '';
+ });
+ },
+ },
+ i18n: {
+ inputLabel: __('Children'),
+ errorMessage: s__(
+ 'WorkItem|Something went wrong when trying to add a child. Please try again.',
+ ),
+ },
};
</script>
<template>
- <gl-form @submit.prevent>
- <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" />
- <gl-button type="submit" category="secondary" variant="confirm">
- {{ s__('WorkItem|Add') }}
+ <gl-form
+ class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ >
+ <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
+ {{ error }}
+ </gl-alert>
+ <gl-form-combobox
+ v-model="search"
+ :token-list="availableWorkItems"
+ match-value-to-attr="title"
+ class="gl-mb-4"
+ :label-text="$options.i18n.inputLabel"
+ label-sr-only
+ autofocus
+ >
+ <template #result="{ item }">
+ <div class="gl-display-flex">
+ <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div>
+ <div>{{ item.title }}</div>
+ </div>
+ </template>
+ </gl-form-combobox>
+ <gl-button category="secondary" data-testid="add-child-button" @click="addChild">
+ {{ s__('WorkItem|Add task') }}
</gl-button>
- <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')">
+ <gl-button category="tertiary" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
</gl-button>
</gl-form>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
new file mode 100644
index 00000000000..6deb87c5dca
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { produce } from 'immer';
+import { s__ } from '~/locale';
+import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql';
+import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import { WIDGET_TYPE_HIERARCHY } from '../../constants';
+
+export default {
+ components: {
+ GlDropdownItem,
+ GlDropdown,
+ GlIcon,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ parentWorkItemId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ activeToast: null,
+ };
+ },
+ methods: {
+ toggleChildFromCache(data, store) {
+ const sourceData = store.readQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.parentWorkItemId },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ const widgetHierarchy = draftState.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
+ );
+
+ const index = widgetHierarchy.children.nodes.findIndex(
+ (child) => child.id === this.workItemId,
+ );
+
+ if (index >= 0) {
+ widgetHierarchy.children.nodes.splice(index, 1);
+ } else {
+ widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem);
+ }
+ });
+
+ store.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.parentWorkItemId },
+ data: newData,
+ });
+ },
+ async addChild(data) {
+ const { data: resp } = await this.$apollo.mutate({
+ mutation: changeWorkItemParentMutation,
+ variables: { id: this.workItemId, parentId: this.parentWorkItemId },
+ update: this.toggleChildFromCache.bind(this, data),
+ });
+
+ if (resp.workItemUpdate.errors.length === 0) {
+ this.activeToast?.hide();
+ }
+ },
+ async removeChild() {
+ const { data } = await this.$apollo.mutate({
+ mutation: changeWorkItemParentMutation,
+ variables: { id: this.workItemId, parentId: null },
+ update: this.toggleChildFromCache.bind(this, null),
+ });
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.addChild.bind(this, data),
+ },
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="gl-ml-2">
+ <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true">
+ <template #button-content>
+ <gl-icon name="ellipsis_v" :size="14" />
+ </template>
+ <gl-dropdown-item @click="removeChild">
+ {{ s__('WorkItem|Remove') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </span>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index b0f2b3aa14a..30e2c1e56b8 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,26 +1,142 @@
<script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORY_SHOW } from '../constants';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const allowedKeys = [
+ 'Alt',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'ArrowUp',
+ 'Backspace',
+ 'Control',
+ 'Delete',
+ 'End',
+ 'Enter',
+ 'Home',
+ 'Meta',
+ 'PageDown',
+ 'PageUp',
+ 'Tab',
+ '0',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ '6',
+ '7',
+ '8',
+ '9',
+];
+/* eslint-enable @gitlab/require-i18n-strings */
export default {
+ inputId: 'weight-widget-input',
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ },
+ mixins: [Tracking.mixin()],
inject: ['hasIssueWeightsFeature'],
props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
weight: {
type: Number,
required: false,
default: undefined,
},
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ };
},
computed: {
- weightText() {
- return this.weight ?? __('None');
+ placeholder() {
+ return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_weight',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ type() {
+ return this.canUpdate && this.isEditing ? 'number' : 'text';
+ },
+ },
+ methods: {
+ blurInput() {
+ this.$refs.input.$el.blur();
+ },
+ handleFocus() {
+ this.isEditing = true;
+ },
+ handleKeydown(event) {
+ if (!allowedKeys.includes(event.key)) {
+ event.preventDefault();
+ }
+ },
+ updateWeight(event) {
+ this.isEditing = false;
+ this.track('updated_weight');
+ this.$apollo.mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ weight: event.target.value === '' ? null : Number(event.target.value),
+ },
+ },
+ });
},
},
};
</script>
<template>
- <div v-if="hasIssueWeightsFeature" class="gl-mb-5">
- <span class="gl-display-inline-block gl-font-weight-bold gl-w-15">{{ __('Weight') }}</span>
- {{ weightText }}
- </div>
+ <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
+ <gl-form-group
+ class="gl-align-items-center"
+ :label="__('Weight')"
+ :label-for="$options.inputId"
+ label-class="gl-pb-0! gl-overflow-wrap-break"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <gl-form-input
+ :id="$options.inputId"
+ ref="input"
+ min="0"
+ :placeholder="placeholder"
+ :readonly="!canUpdate"
+ size="sm"
+ :type="type"
+ :value="weight"
+ @blur="updateWeight"
+ @focus="handleFocus"
+ @keydown="handleKeydown"
+ @keydown.exact.esc.stop="blurInput"
+ />
+ </gl-form-group>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 2df4978a319..2140b418e6d 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -13,12 +13,14 @@ export const i18n = {
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
};
-export const DEFAULT_MODAL_TYPE = 'Task';
+export const TASK_TYPE_NAME = 'Task';
-export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES';
+export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
+export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
export const WIDGET_TYPE_TASK_ICON = 'task-done';
diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
new file mode 100644
index 00000000000..dc5286174d8
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
@@ -0,0 +1,13 @@
+mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) {
+ workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
+ workItem {
+ id
+ workItemType {
+ id
+ }
+ title
+ state
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
index b25210f5c74..ccfe62cc585 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -1,8 +1,12 @@
+#import "./work_item.fragment.graphql"
+
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
workItem {
- id
- descriptionHtml
+ ...WorkItem
+ }
+ newWorkItem {
+ ...WorkItem
}
errors
}
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 0d31ecef6f8..43c92cf89ec 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,6 +1,6 @@
#import "./work_item.fragment.graphql"
-mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) {
+mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
workItem {
...WorkItem
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
new file mode 100644
index 00000000000..7d38d203b84
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -0,0 +1,14 @@
+query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) {
+ workspace: project(fullPath: $projectPath) {
+ id
+ workItems(search: $searchTerm, types: $types) {
+ edges {
+ node {
+ id
+ title
+ state
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 09d929faae2..8788ad21e7b 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEE } from '../constants';
+import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants';
import typeDefs from './typedefs.graphql';
import workItemQuery from './work_item.query.graphql';
@@ -10,7 +10,7 @@ export const temporaryConfig = {
typeDefs,
cacheConfig: {
possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemAssignees'],
+ LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'],
},
typePolicies: {
WorkItem: {
@@ -20,33 +20,15 @@ export const temporaryConfig = {
return (
widgets || [
{
- __typename: 'LocalWorkItemAssignees',
- type: 'ASSIGNEES',
- nodes: [
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/1',
- avatarUrl: '',
- webUrl: '',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'John Doe',
- username: 'doe_I',
- },
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/2',
- avatarUrl: '',
- webUrl: '',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'Marcus Rutherford',
- username: 'ruthfull',
- },
- ],
+ __typename: 'LocalWorkItemLabels',
+ type: WIDGET_TYPE_LABELS,
+ allowScopedLabels: true,
+ nodes: [],
},
{
__typename: 'LocalWorkItemWeight',
type: 'WEIGHT',
- weight: 0,
+ weight: null,
},
]
);
@@ -67,12 +49,26 @@ export const resolvers = {
});
const data = produce(sourceData, (draftData) => {
- const assigneesWidget = draftData.workItem.mockWidgets.find(
- (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
- );
- assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) =>
- input.assigneeIds.includes(assignee.id),
- );
+ if (input.assignees) {
+ const assigneesWidget = draftData.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_ASSIGNEES,
+ );
+ assigneesWidget.assignees.nodes = [...input.assignees];
+ }
+
+ if (input.weight != null) {
+ const weightWidget = draftData.workItem.mockWidgets.find(
+ (widget) => widget.type === WIDGET_TYPE_WEIGHT,
+ );
+ weightWidget.weight = input.weight;
+ }
+
+ if (input.labels) {
+ const labelsWidget = draftData.workItem.mockWidgets.find(
+ (widget) => widget.type === WIDGET_TYPE_LABELS,
+ );
+ labelsWidget.nodes = [...input.labels];
+ }
});
cache.writeQuery({
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index bfe2f0fe0ce..48228b15a53 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,5 +1,6 @@
enum LocalWidgetType {
ASSIGNEES
+ LABELS
WEIGHT
}
@@ -12,6 +13,12 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget {
nodes: [UserCore]
}
+type LocalWorkItemLabels implements LocalWorkItemWidget {
+ type: LocalWidgetType!
+ allowScopedLabels: Boolean!
+ nodes: [Label!]
+}
+
type LocalWorkItemWeight implements LocalWorkItemWidget {
type: LocalWidgetType!
weight: Int
@@ -21,9 +28,11 @@ extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
-type LocalWorkItemAssigneesInput {
+input LocalUpdateWorkItemInput {
id: WorkItemID!
- assigneeIds: [ID!]
+ assignees: [UserCore!]
+ labels: [Label]
+ weight: Int
}
type LocalWorkItemPayload {
@@ -32,5 +41,5 @@ type LocalWorkItemPayload {
}
extend type Mutation {
- localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload
+ localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index c0b6e856411..25eb8099251 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItem {
...WorkItem
}
+ errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
index 470de060ee3..ad861a60d15 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
@@ -1,8 +1,13 @@
+#import "./work_item.fragment.graphql"
+
mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
workItemUpdate: workItemUpdateTask(input: $input) {
workItem {
id
descriptionHtml
}
+ task {
+ ...WorkItem
+ }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index 04701f6899e..5f64eda96aa 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
fragment WorkItem on WorkItem {
id
title
@@ -17,5 +19,29 @@ fragment WorkItem on WorkItem {
description
descriptionHtml
}
+ ... on WorkItemWidgetAssignees {
+ type
+ allowsMultipleAssignees
+ assignees {
+ nodes {
+ ...User
+ }
+ }
+ }
+ ... on WorkItemWidgetHierarchy {
+ type
+ parent {
+ id
+ iid
+ title
+ }
+ children {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 30bc61f5c59..61cb8802187 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,17 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "./work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
mockWidgets @client {
- ... on LocalWorkItemAssignees {
+ ... on LocalWorkItemLabels {
type
+ allowScopedLabels
nodes {
- id
- avatarUrl
- name
- username
- webUrl
+ ...Label
}
}
... on LocalWorkItemWeight {
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 33e28831b54..6437df597b4 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => {
return new Vue({
el,
+ name: 'WorkItemsRoot',
router: createRouter(el.dataset.fullPath),
apolloProvider: createApolloProvider(),
provide: {
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 04c6a61689c..482da5419c6 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -6,12 +6,11 @@ import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
-import { DEFAULT_MODAL_TYPE } from '../constants';
import ItemTitle from '../components/item_title.vue';
export default {
- createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'),
fetchTypesErrorText: s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
),
@@ -24,11 +23,6 @@ export default {
},
inject: ['fullPath'],
props: {
- isModal: {
- type: Boolean,
- required: false,
- default: false,
- },
initialTitle: {
type: String,
required: false,
@@ -78,13 +72,6 @@ export default {
text: node.name,
}));
},
- result() {
- if (!this.selectedWorkItemType && this.isModal) {
- this.selectedWorkItemType = this.formOptions.find(
- (options) => options.text === DEFAULT_MODAL_TYPE,
- )?.value;
- }
- },
error() {
this.error = this.$options.fetchTypesErrorText;
},
@@ -104,11 +91,7 @@ export default {
methods: {
async createWorkItem() {
this.loading = true;
- if (this.isModal) {
- await this.createWorkItemFromTask();
- } else {
- await this.createStandaloneWorkItem();
- }
+ await this.createStandaloneWorkItem();
this.loading = false;
},
async createStandaloneWorkItem() {
@@ -174,11 +157,7 @@ export default {
this.title = title;
},
handleCancelClick() {
- if (!this.isModal) {
- this.$router.go(-1);
- return;
- }
- this.$emit('closeModal');
+ this.$router.go(-1);
},
},
};
@@ -187,7 +166,7 @@ export default {
<template>
<form @submit.prevent="createWorkItem">
<gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert>
- <div :class="{ 'gl-px-5': isModal }" data-testid="content">
+ <div data-testid="content">
<item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" />
<div>
<gl-loading-icon
@@ -203,14 +182,11 @@ export default {
/>
</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 }"
- >
+ <div class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4">
<gl-button
variant="confirm"
:disabled="isButtonDisabled"
- :class="{ 'gl-mr-3': !isModal }"
+ class="gl-mr-3"
:loading="loading"
data-testid="create-button"
type="submit"
@@ -221,7 +197,6 @@ export default {
type="button"
data-testid="cancel-button"
class="gl-order-n1"
- :class="{ 'gl-mr-3': isModal }"
@click="handleCancelClick"
>
{{ __('Cancel') }}
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 092cf643e0f..be72ec33465 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,5 +1,6 @@
@import './pages/branches';
@import './pages/clusters';
+@import './pages/colors';
@import './pages/commits';
@import './pages/deploy_keys';
@import './pages/detail_page';
@@ -8,6 +9,7 @@
@import './pages/events';
@import './pages/groups';
@import './pages/help';
+@import './pages/hierarchy';
@import './pages/issuable';
@import './pages/issues';
@import './pages/labels';
@@ -25,9 +27,8 @@
@import './pages/registry';
@import './pages/search';
@import './pages/service_desk';
-@import './pages/settings';
@import './pages/settings_ci_cd';
+@import './pages/settings';
@import './pages/storage_quota';
@import './pages/tree';
@import './pages/users';
-@import './pages/hierarchy';
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 870ed50c6eb..1b6a0208ca7 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -106,11 +106,14 @@
}
}
-.table-dropdown .dropdown-menu {
+.content-editor-dropdown .dropdown-menu {
+ width: auto !important;
+
@include gl-min-w-0;
- @include gl-w-auto;
- @include gl-white-space-nowrap;
+ button {
+ @include gl-white-space-nowrap;
+ }
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ced62926218..37f92d3cf3d 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -506,8 +506,7 @@
max-width: unset;
}
- .no-emoji-placeholder,
- .clear-user-status {
+ .no-emoji-placeholder {
svg {
fill: var(--gray-500, $gray-500);
}
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index a8e740525e2..f27a36d1966 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -60,6 +60,13 @@
@include icon-styles($gray-500, $gray-100);
}
+.password-status-icon-success {
+ svg {
+ vertical-align: middle;
+ fill: $green-500;
+ }
+}
+
.icon-link {
&:hover {
text-decoration: none;
@@ -67,6 +74,7 @@
}
.user-avatar-link {
+ display: inline-block;
text-decoration: none;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index eeffc4fc21b..1e921b4234e 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -432,7 +432,6 @@ $gl-input-padding: 10px;
$gl-vert-padding: 6px;
$gl-padding-top: 10px;
$gl-sidebar-padding: 22px;
-$gl-bar-padding: 3px;
$input-horizontal-padding: 12px;
$browser-scrollbar-size: 10px;
diff --git a/app/assets/stylesheets/highlight/hljs.scss b/app/assets/stylesheets/highlight/hljs.scss
index 2e31e7c1f6d..e1bc23852a4 100644
--- a/app/assets/stylesheets/highlight/hljs.scss
+++ b/app/assets/stylesheets/highlight/hljs.scss
@@ -37,6 +37,10 @@
&.class_ {
color: var(--color-hljs-class);
+
+ &.inherited__ {
+ color: var(--color-hljs-variable);
+ }
}
&.function_ {
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index f4d9909d81f..709e7f5ae18 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -188,7 +188,11 @@ $dark-il: #de935f;
.diff-line-num.new,
.line-coverage.new,
.line-codequality.new,
- .line_content.new {
+ .line_content.new,
+ .diff-line-num.new-nomappinginraw,
+ .line-coverage.new-nomappinginraw,
+ .line-codequality.new-nomappinginraw,
+ .line_content.new-nomappinginraw {
@include diff-background($dark-new-bg, $dark-new-idiff, $dark-border);
&::before,
@@ -200,7 +204,11 @@ $dark-il: #de935f;
.diff-line-num.old,
.line-coverage.old,
.line-codequality.old,
- .line_content.old {
+ .line_content.old,
+ .diff-line-num.old-nomappinginraw,
+ .line-coverage.old-nomappinginraw,
+ .line-codequality.old-nomappinginraw,
+ .line_content.old-nomappinginraw {
@include diff-background($dark-old-bg, $dark-old-idiff, $dark-border);
&::before,
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index dfa32d4b773..0ed9c209417 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -100,6 +100,8 @@ $monokai-gh: #75715e;
// We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251)
@include hljs-override('string', $monokai-s);
@include hljs-override('attr', $monokai-na);
+ @include hljs-override('attribute', $monokai-n);
+ @include hljs-override('selector-tag', $monokai-nt);
@include hljs-override('keyword', $monokai-k);
@include hljs-override('variable', $monokai-nv);
@include hljs-override('variable.language_', $monokai-k);
@@ -113,7 +115,8 @@ $monokai-gh: #75715e;
@include hljs-override('section', $monokai-gh);
@include hljs-override('bullet', $monokai-n);
@include hljs-override('subst', $monokai-p);
- @include hljs-override('symbol', $monokai-ni);
+ @include hljs-override('symbol', $monokai-ss);
+ @include hljs-override('title.class_.inherited__', $monokai-no);
// Line numbers
.file-line-num {
@@ -178,7 +181,11 @@ $monokai-gh: #75715e;
.diff-line-num.new,
.line-coverage.new,
.line-codequality.new,
- .line_content.new {
+ .line_content.new,
+ .diff-line-num.new-nomappinginraw,
+ .line-coverage.new-nomappinginraw,
+ .line-codequality.new-nomappinginraw,
+ .line_content.new-nomappinginraw {
@include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
&::before,
@@ -190,7 +197,11 @@ $monokai-gh: #75715e;
.diff-line-num.old,
.line-coverage.old,
.line-codequality.old,
- .line_content.old {
+ .line_content.old,
+ .diff-line-num.old-nomappinginraw,
+ .line-coverage.old-nomappinginraw,
+ .line-codequality.old-nomappinginraw,
+ .line_content.old-nomappinginraw {
@include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
&::before,
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index f70c53c9eaa..868e466b1f8 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -75,7 +75,9 @@
.line-coverage,
.line-codequality {
&.old,
- &.new {
+ &.new,
+ &.new-nomappinginraw,
+ &.old-nomappinginraw {
background-color: $white-normal;
}
}
@@ -131,7 +133,7 @@
}
.line_content {
- &.old {
+ &.old, &.old-nomappinginraw {
background-color: $white-normal;
&::before {
@@ -144,7 +146,7 @@
}
}
- &.new {
+ &.new, &.new-nomappinginraw {
background-color: $white-normal;
&::before {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 73aa6275d17..6260339a48d 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -103,6 +103,8 @@ $solarized-dark-il: #2aa198;
// We should be able to remove the overrides once the upstream issue is fixed (https://github.com/sourcegraph/sourcegraph/issues/23251)
@include hljs-override('string', $solarized-dark-s);
@include hljs-override('attr', $solarized-dark-na);
+ @include hljs-override('attribute', $solarized-dark-n);
+ @include hljs-override('selector-tag', $solarized-dark-nt);
@include hljs-override('keyword', $solarized-dark-k);
@include hljs-override('variable', $solarized-dark-nv);
@include hljs-override('variable.language_', $solarized-dark-k);
@@ -117,7 +119,8 @@ $solarized-dark-il: #2aa198;
@include hljs-override('bullet', $solarized-dark-n);
@include hljs-override('subst', $solarized-dark-p);
@include hljs-override('symbol', $solarized-dark-ni);
-
+ @include hljs-override('title.class_.inherited__', $solarized-dark-no);
+
// Line numbers
.file-line-num {
@include line-number-link($solarized-dark-line-color);
@@ -189,7 +192,11 @@ $solarized-dark-il: #2aa198;
.diff-line-num.new,
.line-coverage.new,
.line-codequality.new,
- .line_content.new {
+ .line_content.new,
+ .diff-line-num.new-nomappinginraw,
+ .line-coverage.new-nomappinginraw,
+ .line-codequality.new-nomappinginraw,
+ .line_content.new-nomappinginraw {
@include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
&::before,
@@ -201,7 +208,11 @@ $solarized-dark-il: #2aa198;
.diff-line-num.old,
.line-coverage.old,
.line-codequality.old,
- .line_content.old {
+ .line_content.old,
+ .diff-line-num.old-nomappinginraw,
+ .line-coverage.old-nomappinginraw,
+ .line-codequality.old-nomappinginraw,
+ .line_content.old-nomappinginraw {
@include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
&::before,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 74448317270..e6f098f4cdf 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -106,6 +106,7 @@ $solarized-light-il: #2aa198;
}
.code.solarized-light {
+ @include hljs-override('title.class_.inherited__', $solarized-light-no);
// Line numbers
.file-line-num {
@include line-number-link($solarized-light-line-color);
@@ -169,7 +170,11 @@ $solarized-light-il: #2aa198;
.diff-line-num.new,
.line-coverage.new,
.line-codequality.new,
- .line_content.new {
+ .line_content.new,
+ .diff-line-num.new-nomappinginraw,
+ .line-coverage.new-nomappinginraw,
+ .line-codequality.new-nomappinginraw,
+ .line_content.new-nomappinginraw {
@include diff-background($solarized-light-new-bg,
$solarized-light-new-idiff, $solarized-light-border);
@@ -190,7 +195,11 @@ $solarized-light-il: #2aa198;
.diff-line-num.old,
.line-coverage.old,
.line-codequality.old,
- .line_content.old {
+ .line_content.old,
+ .diff-line-num.old-nomappinginraw,
+ .line-coverage.old-nomappinginraw,
+ .line-codequality.old-nomappinginraw,
+ .line_content.old-nomappinginraw {
@include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
&::before,
diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss
index 8698e448c94..b0f6595feff 100644
--- a/app/assets/stylesheets/highlight/themes/white.scss
+++ b/app/assets/stylesheets/highlight/themes/white.scss
@@ -2,6 +2,9 @@
@import '../white_base';
@include conflict-colors('white');
+ @include hljs-override('variable', $white-nv);
+ @include hljs-override('symbol', $white-ss);
+ @include hljs-override('title.class_.inherited__', $white-no);
}
:root {
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index aac8ccde96e..770a90bbc57 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -158,7 +158,8 @@ pre.code,
}
.diff-line-num {
- &.old {
+ &.old,
+ &.old-nomappinginraw {
background-color: $line-number-old;
a {
@@ -166,7 +167,8 @@ pre.code,
}
}
- &.new {
+ &.new,
+ &.new-nomappinginraw {
background-color: $line-number-new;
a {
@@ -204,7 +206,8 @@ pre.code,
}
.line_content {
- &.old {
+ &.old,
+ &.old-nomappinginraw {
background-color: $line-removed;
&::before {
@@ -216,7 +219,8 @@ pre.code,
}
}
- &.new {
+ &.new,
+ &.new-nomappinginraw {
background-color: $line-added;
&::before {
@@ -243,11 +247,13 @@ pre.code,
.line-coverage,
.line-codequality {
- &.old {
+ &.old,
+ &.old-nomappinginraw {
background-color: $line-removed;
}
- &.new {
+ &.new,
+ &.new-nomappinginraw {
background-color: $line-added;
}
diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss
index b8cbe64df38..da120b5fb4e 100644
--- a/app/assets/stylesheets/mailer.scss
+++ b/app/assets/stylesheets/mailer.scss
@@ -2,8 +2,6 @@
// Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails
//
-// stylelint-disable color-hex-length
-
$mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif;
$mailer-text-color: #333;
$mailer-bg-color: #fafafa;
diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
index 57053c7f0cb..d93b4f75d77 100644
--- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss
@@ -1,9 +1,3 @@
-
-// stylelint-disable selector-class-pattern
-// stylelint-disable selector-max-compound-selectors
-// stylelint-disable stylelint-gitlab/duplicate-selectors
-// stylelint-disable stylelint-gitlab/utility-classes
-
.blob-editor-container {
flex: 1;
height: 0;
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 25a565ce2ba..c584bbaac09 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -64,10 +64,6 @@
border-color: var(--ide-input-border, $gray-darkest);
}
}
-
- a.gl-tab-nav-item-active {
- box-shadow: inset 0 -2px 0 0 var(--ide-input-border, $gray-darkest);
- }
}
.drag-handle:hover {
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 14873c54cd7..1b27e51e793 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -46,9 +46,7 @@ $tabs-holder-z-index: 250;
position: -webkit-sticky;
position: sticky;
// Unitless zero values are not allowed in calculations
- // stylelint-disable-next-line length-zero-no-unit
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));
.drag-handle {
@@ -632,6 +630,24 @@ $tabs-holder-z-index: 250;
height: 24px;
}
+.mr-widget-extension-icon::after {
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-rounded-full;
+
+ top: 4px;
+ left: 4px;
+ width: 16px;
+ height: 16px;
+ border: 4px solid currentColor;
+}
+
+.mr-widget-extension-icon svg {
+ position: relative;
+ top: 2px;
+ left: 2px;
+}
+
.mr-widget-heading {
position: relative;
border: 1px solid var(--border-color, $border-color);
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index ddc638197ca..91fd2d42657 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -70,7 +70,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.timeline-header-blank,
.timeline-header-item {
- @include float-left;
+ @include gl-float-left;
height: $header-item-height;
border-bottom: $border-style;
background-color: var(--white, $white);
@@ -150,7 +150,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.details-cell,
.timeline-cell {
- @include float-left;
+ @include gl-float-left;
height: $item-height;
}
diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss
index 0bc3cc6678c..eec5ebdb383 100644
--- a/app/assets/stylesheets/page_bundles/project.scss
+++ b/app/assets/stylesheets/page_bundles/project.scss
@@ -128,10 +128,6 @@
> li {
display: inline-block;
- &:not(:last-child) {
- margin-right: $gl-padding;
- }
-
&.right {
vertical-align: top;
margin-top: 0;
@@ -179,7 +175,6 @@
}
.btn {
- margin-bottom: $gl-padding-8;
padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;
@@ -190,12 +185,6 @@
}
}
-.project-buttons {
- .nav > li:not(:last-child) {
- margin-right: $gl-padding-8;
- }
-}
-
.git-empty {
margin-bottom: 7px;
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
new file mode 100644
index 00000000000..9220fa82b46
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -0,0 +1,35 @@
+@import 'mixins_and_variables_and_functions';
+
+.gl-token-selector-token-container {
+ display: flex;
+ align-items: center;
+}
+
+#weight-widget-input:not(:hover, :focus),
+#weight-widget-input[readonly] {
+ box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
+}
+
+#weight-widget-input[readonly] {
+ background-color: var(--white, $white);
+}
+
+.work-item-assignees {
+ .assign-myself {
+ display: none;
+ }
+
+ .assignees-selector:hover .assign-myself {
+ display: block;
+ }
+}
+
+.work-item-labels {
+ .gl-token {
+ padding-left: $gl-spacing-scale-1;
+ }
+
+ .gl-token-close {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss
index d34d309eea3..18158fab75f 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/pages/branches.scss
@@ -1,9 +1,3 @@
-.content-list > .branch-item,
-.branch-title {
- display: flex;
- align-items: center;
-}
-
.branch-info {
flex: auto;
min-width: 0;
diff --git a/app/assets/stylesheets/pages/colors.scss b/app/assets/stylesheets/pages/colors.scss
new file mode 100644
index 00000000000..20e072b9903
--- /dev/null
+++ b/app/assets/stylesheets/pages/colors.scss
@@ -0,0 +1,24 @@
+.color-item {
+ @include gl-align-items-center;
+ @include gl-display-flex;
+}
+
+.color-item-color {
+ @include gl-flex-shrink-0;
+ @include gl-mr-3;
+ @include gl-top-0;
+}
+
+.right-sidebar-collapsed {
+ .color-item {
+ @include gl-pt-3;
+ }
+
+ .color-item-color {
+ margin: 0;
+ }
+
+ .color-item-text {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 80b9e378252..c96d8ecc782 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -95,8 +95,14 @@
}
}
-.commits-row + .commits-row {
- border-top: 1px solid $white-normal;
+.commits-row {
+ + .commits-row {
+ border-top: 1px solid $white-normal;
+ }
+
+ + .commits-empty {
+ display: none;
+ }
}
.text-expander {
@@ -133,18 +139,6 @@
}
}
-.commit-detail {
- display: flex;
- justify-content: space-between;
- align-items: start;
- flex-grow: 1;
- min-width: 0;
-
- .project-namespace {
- color: $gl-text-color-tertiary;
- }
-}
-
.commit-content {
padding-right: 10px;
white-space: normal;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 96ca9fbcb43..2e1bb9b9eac 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -209,7 +209,6 @@ table.pipeline-project-metrics tr td {
}
.title {
- margin-top: -$gl-padding-8; // negative margin required for flex-wrap
font-size: $gl-font-size;
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index f3182af3047..51f964a4b70 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -176,11 +176,16 @@
}
.gutter-toggle {
+ display: flex;
+ align-items: center;
margin-left: 20px;
- padding-left: 10px;
+ padding: 4px;
+ border-radius: 4px;
+ height: 24px;
&:hover {
color: $gl-text-color;
+ background: $gray-50;
}
&:hover,
@@ -291,7 +296,7 @@
@include media-breakpoint-up(lg) {
padding: 0;
- form {
+ .issuable-context-form {
--initial-top: calc(#{$header-height} + #{$mr-tabs-height});
--top: var(--initial-top);
@@ -338,7 +343,6 @@
}
.gutter-toggle {
- border-left: 1px solid $border-gray-normal;
text-align: center;
}
@@ -405,8 +409,8 @@
width: 100%;
height: $sidebar-toggle-height;
margin-left: 0;
- padding-left: 0;
border-bottom: 1px solid $border-white-normal;
+ border-radius: 0;
}
a.gutter-toggle {
@@ -709,10 +713,6 @@
line-height: 20px;
padding: 0;
}
-
- .issue-updated-at {
- line-height: 20px;
- }
}
@include media-breakpoint-down(xs) {
@@ -736,7 +736,7 @@
.issuable-milestone,
.issuable-info,
.task-status,
- .issuable-updated-at {
+ .issuable-timestamp {
font-weight: $gl-font-weight-normal;
color: $gl-text-color-secondary;
@@ -991,4 +991,19 @@
bottom: -10%;
}
}
+
+ &.timeline-event-note-form {
+ &::before {
+ top: -15% !important; // Override default positioning
+ height: 20%;
+ }
+
+ &::after {
+ content: none;
+ }
+ }
+}
+
+.timeline-event-note-form {
+ padding-left: 20px;
}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 04e0ef6631e..c0a283ec643 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -124,8 +124,16 @@ ul.related-merge-requests > li gl-emoji {
.new-branch-col {
.discussion-filter-container {
- &:not(:only-child) {
- margin-right: $gl-padding-8;
+ &:not(:last-child) {
+ margin-right: $gl-spacing-scale-3;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ width: 100%;
+
+ > div:not(:last-child) {
+ margin-bottom: $gl-spacing-scale-3;
}
}
}
@@ -147,6 +155,16 @@ ul.related-merge-requests > li gl-emoji {
.btn-group:not(.hidden) {
display: flex;
+
+ @include media-breakpoint-down(xs) {
+ .btn.btn-confirm {
+ @include gl-justify-content-start;
+
+ &.dropdown-toggle {
+ @include gl-flex-grow-0;
+ }
+ }
+ }
}
.js-create-merge-request {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index a3fbedd87a9..96fe6caeea2 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -349,3 +349,36 @@ $comparison-empty-state-height: 62px;
}
}
}
+
+.mr-experience-survey-wrapper {
+ // setting this explicitly because:
+ // diff-files-holder has z-index 203
+ // z-index 9999 utility class breaks tooltips
+ z-index: 210;
+}
+
+.mr-experience-survey-body {
+ width: 300px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.mr-experience-survey-legal {
+ order: 1;
+}
+
+.mr-experience-survey-logo {
+ width: 16px;
+
+ svg {
+ max-width: 100%;
+ }
+}
+
+.survey-slide-up-enter {
+ transform: translateY(10px);
+ opacity: 0;
+}
+
+.survey-slide-up-enter-active {
+ @include gl-transition-slow;
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 82e96dee4c6..4d0cf30a3b2 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -70,41 +70,6 @@ $system-note-svg-size: 16px;
}
}
- .replies-toggle {
- background-color: $gray-light;
- padding: $gl-padding-8 $gl-padding;
- border-top: 1px solid $gray-100;
- border-bottom: 1px solid $gray-100;
-
- .collapse-replies-btn:hover {
- color: $blue-600;
- }
-
- &.collapsed {
- color: $gl-text-color-secondary;
- border-radius: 0 0 $border-radius-default $border-radius-default;
-
- img {
- margin: -2px 4px 0 0;
- }
-
- .author-link {
- color: $gl-text-color;
- }
- }
-
- .user-avatar-link {
- &:last-child img {
- margin-right: $gl-padding-8;
- }
- }
-
- .btn-link {
- border: 0;
- vertical-align: baseline;
- }
- }
-
.discussion-toggle-replies {
border-top: 0;
border-radius: 4px 4px 0 0;
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index 518ec181e5e..c7d7aacceec 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -1,6 +1,6 @@
.application-theme {
- $ui-gray-bg: #2e2e2e;
- $ui-light-gray-bg: #dfdfdf;
+ $ui-gray-bg: #303030;
+ $ui-light-gray-bg: #f0f0f0;
$ui-dark-mode-bg: #1f1f1f;
.preview {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 3b76130dd1a..0d45beab983 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -548,14 +548,6 @@ pre.light-well {
}
}
-.new-protected-branch,
-.new-protected-tag {
- label {
- margin-top: 6px;
- font-weight: $gl-font-weight-normal;
- }
-}
-
.protected-branches-list,
.protected-tags-list {
margin-bottom: 30px;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 8755db83d35..f1865a7dc40 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -4,7 +4,8 @@ $search-sidebar-min-width: 240px;
$search-sidebar-max-width: 300px;
$search-input-field-x-min-width: 200px;
$search-input-field-min-width: 320px;
-$search-input-field-max-width: 600px;
+$search-input-field-max-width: 640px;
+$search-keyboard-shortcut: '/';
$border-radius-medium: 3px;
@@ -67,54 +68,53 @@ input[type='checkbox']:hover {
}
}
-// This is a temporary workaround!
-// the button in GitLab UI Search components need to be updated to not be the small size
-// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
-.header-search .gl-search-box-by-type-clear.btn-sm {
- padding: 0.5rem !important;
-}
-
.header-search {
min-width: $search-input-field-min-width;
+ // This is a temporary workaround!
+ // the button in GitLab UI Search components need to be updated to not be the small size
+ // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
+ .gl-search-box-by-type-clear.btn-sm {
+ padding: 0.5rem !important;
+ }
+
@include media-breakpoint-between(md, lg) {
min-width: $search-input-field-x-min-width;
}
- input,
- svg {
- transition: border-color ease-in-out $default-transition-duration,
- background-color ease-in-out $default-transition-duration;
+ &.is-active {
+ &.is-searching {
+ .in-search-scope-help {
+ position: absolute;
+ top: $gl-spacing-scale-2;
+ right: 2.125rem;
+ z-index: 2;
+ }
+ }
}
- &.is-not-active {
- .btn.gl-clear-icon-button {
+ &.is-not-searching {
+ .in-search-scope-help {
+ display: none;
+ }
+ }
+
+ .keyboard-shortcut-helper {
+ transform: translateY(calc(50% - 2px));
+ box-shadow: none;
+ border-color: transparent;
+ }
+
+ &.is-active {
+ .keyboard-shortcut-helper {
display: none;
}
+ }
- &::after {
- content: '/';
- display: inline-block;
- position: absolute;
- top: 0;
- right: 8px;
- transform: translateY(calc(50% - 4px));
- padding: 4px 5px;
- font-size: $gl-font-size-small;
- font-family: $monospace-font;
- line-height: 1;
- vertical-align: middle;
- border-width: 0;
- border-style: solid;
- border-image: none;
- border-radius: $border-radius-medium;
- box-shadow: none;
- white-space: pre-wrap;
- box-sizing: border-box;
- // Safari
- word-wrap: break-word;
- overflow-wrap: break-word;
- word-break: keep-all;
+ &.is-not-active {
+ .btn.gl-clear-icon-button,
+ .in-search-scope-help {
+ display: none;
}
}
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 0c7b74684cc..935595d1b3b 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -28,6 +28,7 @@
&:first-of-type {
margin-top: 10px;
+ padding-top: 0;
border: 0;
}
@@ -156,39 +157,33 @@
}
.visibility-level-setting {
- .form-check {
- margin-bottom: 10px;
-
- .option-title {
- font-weight: $gl-font-weight-normal;
- display: inline-block;
- color: $gl-text-color;
- vertical-align: top;
- }
+ .option-title {
+ font-weight: $gl-font-weight-normal;
+ display: inline-block;
+ color: $gl-text-color;
+ vertical-align: top;
+ }
- .option-description,
- .option-disabled-reason {
- margin-left: 20px;
- color: $project-option-descr-color;
- margin-top: -5px;
+ .option-description,
+ .option-disabled-reason {
+ color: $project-option-descr-color;
+ }
+
+ .option-disabled-reason {
+ display: none;
+ }
+
+ .disabled {
+ svg {
+ opacity: 0.5;
}
- .option-disabled-reason {
+ .option-description {
display: none;
}
- &.disabled {
- svg {
- opacity: 0.5;
- }
-
- .option-description {
- display: none;
- }
-
- .option-disabled-reason {
- display: block;
- }
+ .option-disabled-reason {
+ display: block;
}
}
}
@@ -320,7 +315,8 @@
}
.ci-variable-table,
-.deploy-freeze-table {
+.deploy-freeze-table,
+.ci-secure-files-table {
table {
thead {
border-bottom: 1px solid $white-normal;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 4cefa60b12a..801c9ea828f 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -2,10 +2,16 @@
// Please see the feedback issue for more details and help:
// https://gitlab.com/gitlab-org/gitlab/-/issues/331812
@charset "UTF-8";
+:root {
+ color-scheme: dark;
+}
body.gl-dark {
+ --gray-10: #1f1f1f;
--gray-50: #303030;
--gray-100: #404040;
+ --gray-200: #525252;
--gray-600: #bfbfbf;
+ --gray-700: #dbdbdb;
--gray-900: #fafafa;
--green-100: #0d532a;
--green-700: #91d4a8;
@@ -61,6 +67,11 @@ a:not([href]):not([class]) {
color: inherit;
text-decoration: none;
}
+kbd {
+ font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
+ "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
+ font-size: 1em;
+}
img {
vertical-align: middle;
border-style: none;
@@ -105,6 +116,18 @@ button::-moz-focus-inner,
padding-left: 0;
list-style: none;
}
+kbd {
+ padding: 0.2rem 0.4rem;
+ font-size: 90%;
+ color: #333;
+ background-color: #fafafa;
+ border-radius: 0.2rem;
+}
+kbd kbd {
+ padding: 0;
+ font-size: 100%;
+ font-weight: 600;
+}
.container-fluid {
width: 100%;
padding-right: 15px;
@@ -740,6 +763,22 @@ input {
.form-control::placeholder {
color: #868686;
}
+kbd {
+ display: inline-block;
+ padding: 3px 5px;
+ font-size: 0.6875rem;
+ line-height: 10px;
+ color: var(--gray-700, #dbdbdb);
+ vertical-align: middle;
+ background-color: var(--gray-10, #1f1f1f);
+ border-width: 1px;
+ border-style: solid;
+ border-color: var(--gray-100, #404040) var(--gray-100, #404040)
+ var(--gray-200, #525252);
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: 0 -1px 0 var(--gray-200, #525252) inset;
+}
.navbar-gitlab {
padding: 0 16px;
z-index: 1000;
@@ -1504,7 +1543,7 @@ svg.s16 {
vertical-align: -3px;
}
.header-content .header-search-new {
- max-width: 600px;
+ max-width: 640px;
}
.header-search {
min-width: 320px;
@@ -1514,29 +1553,10 @@ svg.s16 {
min-width: 200px;
}
}
-.header-search.is-not-active::after {
- content: "/";
- display: inline-block;
- position: absolute;
- top: 0;
- right: 8px;
- transform: translateY(calc(50% - 4px));
- padding: 4px 5px;
- font-size: 12px;
- font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
- "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
- line-height: 1;
- vertical-align: middle;
- border-width: 0;
- border-style: solid;
- border-image: none;
- border-radius: 3px;
+.header-search .keyboard-shortcut-helper {
+ transform: translateY(calc(50% - 2px));
box-shadow: none;
- white-space: pre-wrap;
- box-sizing: border-box;
- word-wrap: break-word;
- overflow-wrap: break-word;
- word-break: keep-all;
+ border-color: transparent;
}
.search {
margin: 0 8px;
@@ -1697,6 +1717,9 @@ svg.s16 {
.rect-avatar.s32 {
border-radius: 4px;
}
+:root {
+ color-scheme: dark;
+}
body.gl-dark {
--gray-10: #1f1f1f;
--gray-50: #303030;
@@ -1884,7 +1907,7 @@ body.gl-dark .header-search input::placeholder {
body.gl-dark .header-search input:active::placeholder {
color: #868686;
}
-body.gl-dark .header-search.is-not-active::after {
+body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper {
color: #fafafa;
background-color: rgba(250, 250, 250, 0.2);
}
@@ -1938,6 +1961,9 @@ body.gl-dark .navbar-gitlab .search form .search-input {
color: var(--gl-text-color);
}
+:root {
+ color-scheme: dark;
+}
body.gl-dark {
--gray-10: #1f1f1f;
--gray-50: #303030;
@@ -2030,7 +2056,6 @@ body.gl-dark {
--nav-active-bg: rgba(255, 255, 255, 0.08);
}
.tab-width-8 {
- -moz-tab-size: 8;
tab-size: 8;
}
.gl-sr-only {
@@ -2084,6 +2109,12 @@ body.gl-dark {
.gl-absolute {
position: absolute;
}
+.gl-top-0 {
+ top: 0;
+}
+.gl-right-3 {
+ right: 0.5rem;
+}
.gl-w-full {
width: 100%;
}
@@ -2119,6 +2150,9 @@ body.gl-dark {
.gl-font-weight-bold {
font-weight: 600;
}
+.gl-z-index-1 {
+ z-index: 1;
+}
@import "startup/cloaking";
@include cloak-startup-scss(none);
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index cb3c97f18a3..43ca5a512d5 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -46,6 +46,11 @@ a:not([href]):not([class]) {
color: inherit;
text-decoration: none;
}
+kbd {
+ font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
+ "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
+ font-size: 1em;
+}
img {
vertical-align: middle;
border-style: none;
@@ -90,6 +95,18 @@ button::-moz-focus-inner,
padding-left: 0;
list-style: none;
}
+kbd {
+ padding: 0.2rem 0.4rem;
+ font-size: 90%;
+ color: #fff;
+ background-color: #303030;
+ border-radius: 0.2rem;
+}
+kbd kbd {
+ padding: 0;
+ font-size: 100%;
+ font-weight: 600;
+}
.container-fluid {
width: 100%;
padding-right: 15px;
@@ -725,6 +742,22 @@ input {
.form-control::placeholder {
color: #868686;
}
+kbd {
+ display: inline-block;
+ padding: 3px 5px;
+ font-size: 0.6875rem;
+ line-height: 10px;
+ color: var(--gray-700, #525252);
+ vertical-align: middle;
+ background-color: var(--gray-10, #f5f5f5);
+ border-width: 1px;
+ border-style: solid;
+ border-color: var(--gray-100, #dbdbdb) var(--gray-100, #dbdbdb)
+ var(--gray-200, #bfbfbf);
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: 0 -1px 0 var(--gray-200, #bfbfbf) inset;
+}
.navbar-gitlab {
padding: 0 16px;
z-index: 1000;
@@ -1489,7 +1522,7 @@ svg.s16 {
vertical-align: -3px;
}
.header-content .header-search-new {
- max-width: 600px;
+ max-width: 640px;
}
.header-search {
min-width: 320px;
@@ -1499,29 +1532,10 @@ svg.s16 {
min-width: 200px;
}
}
-.header-search.is-not-active::after {
- content: "/";
- display: inline-block;
- position: absolute;
- top: 0;
- right: 8px;
- transform: translateY(calc(50% - 4px));
- padding: 4px 5px;
- font-size: 12px;
- font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
- "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
- line-height: 1;
- vertical-align: middle;
- border-width: 0;
- border-style: solid;
- border-image: none;
- border-radius: 3px;
+.header-search .keyboard-shortcut-helper {
+ transform: translateY(calc(50% - 2px));
box-shadow: none;
- white-space: pre-wrap;
- box-sizing: border-box;
- word-wrap: break-word;
- overflow-wrap: break-word;
- word-break: keep-all;
+ border-color: transparent;
}
.search {
margin: 0 8px;
@@ -1684,7 +1698,6 @@ svg.s16 {
}
.tab-width-8 {
- -moz-tab-size: 8;
tab-size: 8;
}
.gl-sr-only {
@@ -1738,6 +1751,12 @@ svg.s16 {
.gl-absolute {
position: absolute;
}
+.gl-top-0 {
+ top: 0;
+}
+.gl-right-3 {
+ right: 0.5rem;
+}
.gl-w-full {
width: 100%;
}
@@ -1773,6 +1792,9 @@ svg.s16 {
.gl-font-weight-bold {
font-weight: 600;
}
+.gl-z-index-1 {
+ z-index: 1;
+}
@import "startup/cloaking";
@include cloak-startup-scss(none);
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index fe8a5aec1b3..e6e736ef47c 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -101,6 +101,10 @@ $white-dark: #444;
$border-color: #4f4f4f;
$nav-active-bg: rgba(255, 255, 255, 0.08);
+:root {
+ color-scheme: dark;
+}
+
body.gl-dark {
--gray-10: #{$gray-10};
--gray-50: #{$gray-50};
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index ad352f0022b..2b6221a6c87 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -159,7 +159,6 @@
color: rgba($search-and-nav-links, 0.8);
}
-
input {
background-color: transparent;
color: rgba($search-and-nav-links, 0.8);
@@ -177,9 +176,11 @@
}
}
- &.is-not-active::after {
- color: $search-and-nav-links;
- background-color: rgba($search-and-nav-links, 0.2);
+ &.is-not-active {
+ .keyboard-shortcut-helper {
+ color: $search-and-nav-links;
+ background-color: rgba($search-and-nav-links, 0.2);
+ }
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 27fcade548f..6bd05f90f26 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -367,50 +367,6 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
-webkit-backdrop-filter: blur(2px); // still required by Safari
}
-/*
- * The styles from here to END-#1825 will be moved to @gitlab/ui by
- * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1825
- */
-.gl-lg-mx-12 {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-spacing-scale-12;
- margin-right: $gl-spacing-scale-12;
- }
-}
-
-.gl-lg-ml-12 {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-spacing-scale-12;
- }
-}
-
-.gl-lg-mr-12 {
- @include media-breakpoint-up(lg) {
- margin-right: $gl-spacing-scale-12;
- }
-}
-
-.gl-lg-ml-10 {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-spacing-scale-10;
- }
-}
-
-.gl-lg-mr-10 {
- @include media-breakpoint-up(lg) {
- margin-right: $gl-spacing-scale-10;
- }
-}
-
-.gl-lg-w-30p {
- @include gl-media-breakpoint-up(lg) {
- width: 30%;
- }
-}
-
-.gl-lg-w-40p {
- @include gl-media-breakpoint-up(lg) {
- width: 40%;
- }
+.gl-flex-flow-row-wrap {
+ flex-flow: row wrap;
}
-/* END-#1825 */
diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb
new file mode 100644
index 00000000000..554e057ca83
--- /dev/null
+++ b/app/channels/awareness_channel.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass
+ REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60)
+ private_constant :REFRESH_INTERVAL
+
+ # Produces a refresh interval value, based of the
+ # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given
+ # default. Makes sure, that the interval after a jitter is applied, is never
+ # less than half the predefined interval.
+ def self.refresh_interval(range: -10..10)
+ min = REFRESH_INTERVAL / 2.to_f
+ [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds
+ end
+ private_class_method :refresh_interval
+
+ # keep clients updated about session membership
+ periodically every: self.refresh_interval do
+ transmit payload
+ end
+
+ def subscribed
+ reject unless valid_subscription?
+ return if subscription_rejected?
+
+ stream_for session, coder: ActiveSupport::JSON
+
+ session.join(current_user)
+ AwarenessChannel.broadcast_to(session, payload)
+ end
+
+ def unsubscribed
+ return if subscription_rejected?
+
+ session.leave(current_user)
+ AwarenessChannel.broadcast_to(session, payload)
+ end
+
+ # Allows a client to let the server know they are still around. This is not
+ # like a heartbeat mechanism. This can be triggered by any action that results
+ # in a meaningful "presence" update. Like scrolling the screen (debounce),
+ # window becoming active, user starting to type in a text field, etc.
+ def touch
+ session.touch!(current_user)
+
+ transmit payload
+ end
+
+ private
+
+ def valid_subscription?
+ current_user.present? && path.present?
+ end
+
+ def payload
+ { collaborators: collaborators }
+ end
+
+ def collaborators
+ session.online_users_with_last_activity.map do |user, last_activity|
+ collaborator(user, last_activity)
+ end
+ end
+
+ def collaborator(user, last_activity)
+ {
+ id: user.id,
+ name: user.name,
+ avatar_url: user.avatar_url(size: 36),
+ last_activity: last_activity,
+ last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words(
+ Time.zone.now, last_activity
+ )
+ }
+ end
+
+ def session
+ @session ||= AwarenessSession.for(path)
+ end
+
+ def path
+ params[:path]
+ end
+end
diff --git a/app/components/pajamas/spinner_component.html.haml b/app/components/pajamas/spinner_component.html.haml
new file mode 100644
index 00000000000..aab9c5fdbf7
--- /dev/null
+++ b/app/components/pajamas/spinner_component.html.haml
@@ -0,0 +1,5 @@
+.gl-spinner-container{ class: @class }
+ - if @inline
+ %span{ class: spinner_class, aria: {label: @label} }
+ - else
+ %div{ class: spinner_class, aria: {label: @label} }
diff --git a/app/components/pajamas/spinner_component.rb b/app/components/pajamas/spinner_component.rb
new file mode 100644
index 00000000000..c7ffc1ec3da
--- /dev/null
+++ b/app/components/pajamas/spinner_component.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Pajamas
+ class SpinnerComponent < Pajamas::Component
+ # @param [String] class
+ # @param [Symbol] color
+ # @param [Boolean] inline
+ # @param [String] label
+ # @param [Symbol] size
+ def initialize(class: '', color: :dark, inline: false, label: _("Loading"), size: :sm)
+ @class = binding.local_variable_get(:class)
+ @color = filter_attribute(color.to_sym, COLOR_OPTIONS)
+ @inline = inline
+ @label = label.presence
+ @size = filter_attribute(size.to_sym, SIZE_OPTIONS)
+ end
+
+ private
+
+ def spinner_class
+ ["gl-spinner", "gl-spinner-#{@size}", "gl-spinner-#{@color}"]
+ end
+
+ COLOR_OPTIONS = [:light, :dark].freeze
+ SIZE_OPTIONS = [:sm, :md, :lg, :xl].freeze
+ end
+end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 7f95b136e4e..e05e87ffd89 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -28,7 +28,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:create_self_monitoring_project,
:status_create_self_monitoring_project,
:delete_self_monitoring_project,
- :status_delete_self_monitoring_project
+ :status_delete_self_monitoring_project,
+ :reset_error_tracking_access_token
]
feature_category :source_code_management, [:repository, :clear_repository_check_states]
@@ -37,6 +38,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
feature_category :service_ping, [:usage_data, :service_usage_data]
feature_category :integrations, [:integrations]
feature_category :pages, [:lets_encrypt_terms_of_service]
+ feature_category :error_tracking, [:reset_error_tracking_access_token]
VALID_SETTING_PANELS = %w(general repository
ci_cd reporting metrics_and_profiling
@@ -96,6 +98,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_back_or_default
end
+ def reset_error_tracking_access_token
+ @application_setting.reset_error_tracking_access_token!
+
+ redirect_to general_admin_application_settings_path,
+ notice: _('New error tracking access token has been generated!')
+ end
+
def clear_repository_check_states
RepositoryCheck::ClearWorker.perform_async # rubocop:disable CodeReuse/Worker
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 865af244773..bf573d45852 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -5,7 +5,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
before_action :finder, only: [:edit, :update, :destroy]
- feature_category :navigation
+ feature_category :onboarding
urgency :low
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 6f5475a4a78..810801d4209 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -8,40 +8,6 @@ class Admin::HooksController < Admin::ApplicationController
feature_category :integrations
urgency :low, [:test]
- def index
- @hooks = SystemHook.all.load
- @hook = SystemHook.new
- end
-
- def create
- @hook = SystemHook.new(hook_params.to_h)
-
- if @hook.save
- redirect_to admin_hooks_path, notice: _('Hook was successfully created.')
- else
- @hooks = SystemHook.all
- render :index
- end
- end
-
- def edit
- end
-
- def update
- if hook.update(hook_params)
- flash[:notice] = _('System hook was successfully updated.')
- redirect_to admin_hooks_path
- else
- render 'edit'
- end
- end
-
- def destroy
- destroy_hook(hook)
-
- redirect_to admin_hooks_path, status: :found
- end
-
def test
result = TestHooks::SystemService.new(hook, current_user, params[:trigger]).execute
@@ -52,6 +18,10 @@ class Admin::HooksController < Admin::ApplicationController
private
+ def relation
+ SystemHook
+ end
+
def hook
@hook ||= SystemHook.find(params[:id])
end
@@ -60,12 +30,11 @@ class Admin::HooksController < Admin::ApplicationController
@hook_logs ||= hook.web_hook_logs.recent.page(params[:page])
end
- def hook_params
- params.require(:hook).permit(
- :enable_ssl_verification,
- :token,
- :url,
- *SystemHook.triggers.values
- )
+ def hook_param_names
+ %i[enable_ssl_verification token url]
+ end
+
+ def trigger_values
+ SystemHook.triggers.values
end
end
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index 7ae930abb84..f81b02ad31f 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -12,7 +12,10 @@ class Admin::SystemInfoController < Admin::ApplicationController
EXCLUDED_MOUNT_TYPES = [
'autofs',
'binfmt_misc',
+ 'bpf',
'cgroup',
+ 'cgroup2',
+ 'configfs',
'debugfs',
'devfs',
'devpts',
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index 908313bdb83..b451928e591 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -4,7 +4,7 @@ class Admin::TopicsController < Admin::ApplicationController
include SendFileUpload
include PreviewMarkdown
- before_action :topic, only: [:edit, :update]
+ before_action :topic, only: [:edit, :update, :destroy]
feature_category :projects
@@ -37,6 +37,14 @@ class Admin::TopicsController < Admin::ApplicationController
end
end
+ def destroy
+ @topic.destroy!
+
+ redirect_to admin_topics_path,
+ status: :found,
+ notice: _('Topic %{topic_name} was successfully removed.') % { topic_name: @topic.title_or_name }
+ end
+
private
def topic
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 30760d472a4..71d9910b4b8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -179,6 +179,10 @@ class ApplicationController < ActionController::Base
payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
+ if Feature.enabled?(:log_response_length)
+ payload[:response_bytes] = response.body_parts.sum(&:bytesize)
+ end
+
store_cloudflare_headers!(payload, request)
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index a04fd09aa22..51150700860 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -50,7 +50,6 @@ class Clusters::ClustersController < Clusters::BaseController
def show
if params[:tab] == 'integrations'
@prometheus_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_prometheus)
- @elastic_stack_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_elastic_stack)
end
end
diff --git a/app/controllers/concerns/google_analytics_csp.rb b/app/controllers/concerns/google_analytics_csp.rb
new file mode 100644
index 00000000000..1a8e405928d
--- /dev/null
+++ b/app/controllers/concerns/google_analytics_csp.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module GoogleAnalyticsCSP
+ extend ActiveSupport::Concern
+
+ included do
+ content_security_policy do |policy|
+ next unless helpers.google_tag_manager_enabled? || policy.directives.present?
+
+ default_script_src = policy.directives['script-src'] || policy.directives['default-src']
+ script_src_values = Array.wrap(default_script_src) | ['*.googletagmanager.com']
+ policy.script_src(*script_src_values)
+
+ default_img_src = policy.directives['img-src'] || policy.directives['default-src']
+ img_src_values = Array.wrap(default_img_src) | ['*.google-analytics.com', '*.googletagmanager.com']
+ policy.img_src(*img_src_values)
+
+ default_connect_src = policy.directives['connect-src'] || policy.directives['default-src']
+ connect_src_values =
+ Array.wrap(default_connect_src) | ['*.google-analytics.com', '*.analytics.google.com', '*.googletagmanager.com']
+ policy.connect_src(*connect_src_values)
+ end
+ end
+end
diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb
new file mode 100644
index 00000000000..70de72f15fc
--- /dev/null
+++ b/app/controllers/concerns/harbor/access.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Harbor
+ module Access
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :harbor_registry_enabled!
+ before_action :authorize_read_harbor_registry!
+ before_action do
+ push_frontend_feature_flag(:harbor_registry_integration)
+ end
+
+ feature_category :integrations
+ end
+
+ private
+
+ def harbor_registry_enabled!
+ render_404 unless Feature.enabled?(:harbor_registry_integration)
+ end
+
+ def authorize_read_harbor_registry!
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/controllers/concerns/harbor/artifact.rb b/app/controllers/concerns/harbor/artifact.rb
new file mode 100644
index 00000000000..c9d7d26fbb9
--- /dev/null
+++ b/app/controllers/concerns/harbor/artifact.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Harbor
+ module Artifact
+ def index
+ respond_to do |format|
+ format.json do
+ artifacts
+ end
+ end
+ end
+
+ private
+
+ def query_params
+ params.permit(:repository_id, :search, :sort, :page, :limit)
+ end
+
+ def query
+ Gitlab::Harbor::Query.new(container.harbor_integration, query_params)
+ end
+
+ def artifacts
+ unless query.valid?
+ return render(
+ json: { message: 'Invalid parameters', errors: query.errors },
+ status: :unprocessable_entity
+ )
+ end
+
+ artifacts_json = ::Integrations::HarborSerializers::ArtifactSerializer.new
+ .with_pagination(request, response)
+ .represent(query.artifacts)
+ render json: artifacts_json
+ end
+
+ def container
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/controllers/concerns/harbor/repository.rb b/app/controllers/concerns/harbor/repository.rb
new file mode 100644
index 00000000000..0e541e2172e
--- /dev/null
+++ b/app/controllers/concerns/harbor/repository.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Harbor
+ module Repository
+ def index
+ respond_to do |format|
+ format.html
+ format.json do
+ repositories
+ end
+ end
+ end
+
+ # The show action renders index to allow frontend routing to work on page refresh
+ def show
+ render :index
+ end
+
+ private
+
+ def query_params
+ params.permit(:search, :sort, :page, :limit)
+ end
+
+ def query
+ Gitlab::Harbor::Query.new(container.harbor_integration, query_params)
+ end
+
+ def repositories
+ unless query.valid?
+ return render(
+ json: { message: 'Invalid parameters', errors: query.errors },
+ status: :unprocessable_entity
+ )
+ end
+
+ repositories_json = ::Integrations::HarborSerializers::RepositorySerializer.new
+ .with_pagination(request, response)
+ .represent(
+ query.repositories,
+ url: container.harbor_integration.url,
+ project_name: container.harbor_integration.project_name
+ )
+ render json: repositories_json
+ end
+
+ def container
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/controllers/concerns/harbor/tag.rb b/app/controllers/concerns/harbor/tag.rb
new file mode 100644
index 00000000000..e0c00d1155a
--- /dev/null
+++ b/app/controllers/concerns/harbor/tag.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Harbor
+ module Tag
+ def index
+ respond_to do |format|
+ format.json do
+ tags
+ end
+ end
+ end
+
+ private
+
+ def query_params
+ params.permit(:repository_id, :artifact_id, :sort, :page, :limit)
+ end
+
+ def query
+ Gitlab::Harbor::Query.new(container.harbor_integration, query_params)
+ end
+
+ def tags
+ unless query.valid?
+ return render(
+ json: { message: 'Invalid parameters', errors: query.errors },
+ status: :unprocessable_entity
+ )
+ end
+
+ tags_json = ::Integrations::HarborSerializers::TagSerializer.new
+ .with_pagination(request, response)
+ .represent(query.tags)
+ render json: tags_json
+ end
+
+ def container
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb
index 6a9d3d51f9b..fb26840168f 100644
--- a/app/controllers/concerns/integrations/hooks_execution.rb
+++ b/app/controllers/concerns/integrations/hooks_execution.rb
@@ -3,8 +3,68 @@
module Integrations::HooksExecution
extend ActiveSupport::Concern
+ included do
+ attr_writer :hooks, :hook
+ end
+
+ def index
+ self.hooks = relation.select(&:persisted?)
+ self.hook = relation.new
+ end
+
+ def create
+ self.hook = relation.new(hook_params)
+ hook.save
+
+ unless hook.valid?
+ self.hooks = relation.select(&:persisted?)
+ flash[:alert] = hook.errors.full_messages.join.html_safe
+ end
+
+ redirect_to action: :index
+ end
+
+ def update
+ if hook.update(hook_params)
+ flash[:notice] = _('Hook was successfully updated.')
+ redirect_to action: :index
+ else
+ render 'edit'
+ end
+ end
+
+ def destroy
+ destroy_hook(hook)
+
+ redirect_to action: :index, status: :found
+ end
+
+ def edit
+ redirect_to(action: :index) unless hook
+ end
+
private
+ def hook_params
+ permitted = hook_param_names + trigger_values
+ permitted << { url_variables: [:key, :value] }
+
+ ps = params.require(:hook).permit(*permitted).to_h
+
+ ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables)
+
+ if action_name == 'update' && ps.key?(:url_variables)
+ supplied = ps[:url_variables]
+ ps[:url_variables] = hook.url_variables.merge(supplied).compact
+ end
+
+ ps
+ end
+
+ def hook_param_names
+ %i[enable_ssl_verification token url push_events_branch_filter]
+ end
+
def destroy_hook(hook)
result = WebHooks::DestroyService.new(current_user).execute(hook)
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index d256b331174..c3ad9d3dff3 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -6,7 +6,6 @@ module Integrations
ALLOWED_PARAMS_CE = [
:active,
- :add_pusher,
:alert_events,
:api_key,
:api_token,
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 07850acd23d..a5e49b1b16a 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -184,7 +184,6 @@ module IssuableActions
def paginated_discussions
return if params[:per_page].blank?
- return if issuable.instance_of?(Issue) && Feature.disabled?(:paginated_issue_discussions, project)
return if issuable.instance_of?(MergeRequest) && Feature.disabled?(:paginated_mr_discussions, project)
strong_memoize(:paginated_discussions) do
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 4841225de08..de38d26e3fe 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -24,12 +24,9 @@ module IssuableCollections
show_alert_if_search_is_disabled
@issuables = issuables_collection
+ set_pagination
- unless pagination_disabled?
- set_pagination
-
- return if redirect_out_of_range(@issuables, @total_pages)
- end
+ return if redirect_out_of_range(@issuables, @total_pages)
if params[:label_name].present? && @project
labels_params = { project_id: @project.id, title: params[:label_name] }
@@ -59,10 +56,6 @@ module IssuableCollections
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
- def pagination_disabled?
- false
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def issuables_collection
finder.execute.preload(preload_for_collection)
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 0b51b3dd380..dc7ba8295b9 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -28,7 +28,10 @@ module ProductAnalyticsTracking
def event_enabled?(event)
events_to_ff = {
g_analytics_valuestream: :route_hll_to_snowplow,
- i_search_paid: :route_hll_to_snowplow_phase2
+
+ i_search_paid: :route_hll_to_snowplow_phase2,
+ i_search_total: :route_hll_to_snowplow_phase2,
+ i_search_advanced: :route_hll_to_snowplow_phase2
}
Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source)
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
new file mode 100644
index 00000000000..1a3e7136481
--- /dev/null
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+# == VerifiesWithEmail
+#
+# Controller concern to handle verification by email
+module VerifiesWithEmail
+ extend ActiveSupport::Concern
+ include ActionView::Helpers::DateHelper
+
+ TOKEN_LENGTH = 6
+ TOKEN_VALID_FOR_MINUTES = 60
+
+ included do
+ prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? }
+ end
+
+ def verify_with_email
+ return unless user = find_user || find_verification_user
+
+ if session[:verification_user_id] && token = verification_params[:verification_token].presence
+ # The verification token is submitted, verify it
+ verify_token(user, token)
+ elsif require_email_verification_enabled?(user)
+ # Limit the amount of password guesses, since we now display the email verification page
+ # when the password is correct, which could be a giveaway when brute-forced.
+ return render_sign_in_rate_limited if check_rate_limit!(:user_sign_in, scope: user) { true }
+
+ if user.valid_password?(user_params[:password])
+ # The user has logged in successfully.
+ if user.unlock_token
+ # Prompt for the token if it already has been set
+ prompt_for_email_verification(user)
+ elsif user.access_locked? || !AuthenticationEvent.initial_login_or_known_ip_address?(user, request.ip)
+ # require email verification if:
+ # - their account has been locked because of too many failed login attempts, or
+ # - they have logged in before, but never from the current ip address
+ send_verification_instructions(user)
+ prompt_for_email_verification(user)
+ end
+ end
+ end
+ end
+
+ def resend_verification_code
+ return unless user = find_verification_user
+
+ send_verification_instructions(user)
+ prompt_for_email_verification(user)
+ end
+
+ def successful_verification
+ session.delete(:verification_user_id)
+ @redirect_url = after_sign_in_path_for(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ render layout: 'minimal'
+ end
+
+ private
+
+ def find_verification_user
+ return unless session[:verification_user_id]
+
+ User.find_by_id(session[:verification_user_id])
+ end
+
+ # After successful verification and calling sign_in, devise redirects the
+ # user to this path. Override it to show the successful verified page.
+ def after_sign_in_path_for(resource)
+ if action_name == 'create' && session[:verification_user_id]
+ return users_successful_verification_path
+ end
+
+ super
+ end
+
+ def send_verification_instructions(user)
+ return if send_rate_limited?(user)
+
+ raw_token, encrypted_token = generate_token
+ user.unlock_token = encrypted_token
+ user.lock_access!({ send_instructions: false })
+ send_verification_instructions_email(user, raw_token)
+ end
+
+ def send_verification_instructions_email(user, token)
+ return unless user.can?(:receive_notifications)
+
+ Notify.verification_instructions_email(
+ user.id,
+ token: token,
+ expires_in: TOKEN_VALID_FOR_MINUTES).deliver_later
+
+ log_verification(user, :instructions_sent)
+ end
+
+ def verify_token(user, token)
+ return handle_verification_failure(user, :rate_limited) if verification_rate_limited?(user)
+ return handle_verification_failure(user, :invalid) unless valid_token?(user, token)
+ return handle_verification_failure(user, :expired) if expired_token?(user)
+
+ handle_verification_success(user)
+ end
+
+ def generate_token
+ raw_token = SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0')
+ encrypted_token = digest_token(raw_token)
+ [raw_token, encrypted_token]
+ end
+
+ def digest_token(token)
+ Devise.token_generator.digest(User, :unlock_token, token)
+ end
+
+ def render_sign_in_rate_limited
+ message = s_('IdentityVerification|Maximum login attempts exceeded. '\
+ 'Wait %{interval} and try again.') % { interval: user_sign_in_interval }
+ redirect_to new_user_session_path, alert: message
+ end
+
+ def user_sign_in_interval
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:user_sign_in][:interval]
+ distance_of_time_in_words(interval_in_seconds)
+ end
+
+ def verification_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user.unlock_token)
+ end
+
+ def send_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.throttled?(:email_verification_code_send, scope: user)
+ end
+
+ def expired_token?(user)
+ user.locked_at < (Time.current - TOKEN_VALID_FOR_MINUTES.minutes)
+ end
+
+ def valid_token?(user, token)
+ user.unlock_token == digest_token(token)
+ end
+
+ def handle_verification_failure(user, reason)
+ message = case reason
+ when :rate_limited
+ s_("IdentityVerification|You've reached the maximum amount of tries. "\
+ 'Wait %{interval} or resend a new code and try again.') % { interval: email_verification_interval }
+ when :expired
+ s_('IdentityVerification|The code has expired. Resend a new code and try again.')
+ when :invalid
+ s_('IdentityVerification|The code is incorrect. Enter it again, or resend a new code.')
+ end
+
+ user.errors.add(:base, message)
+ log_verification(user, :failed_attempt, reason)
+
+ prompt_for_email_verification(user)
+ end
+
+ def email_verification_interval
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval]
+ distance_of_time_in_words(interval_in_seconds)
+ end
+
+ def handle_verification_success(user)
+ user.unlock_access!
+ log_verification(user, :successful)
+
+ sign_in(user)
+ end
+
+ def prompt_for_email_verification(user)
+ session[:verification_user_id] = user.id
+ self.resource = user
+
+ render 'devise/sessions/email_verification'
+ end
+
+ def verification_params
+ params.require(:user).permit(:verification_token)
+ end
+
+ def log_verification(user, event, reason = nil)
+ Gitlab::AppLogger.info(
+ message: 'Email Verification',
+ event: event.to_s.titlecase,
+ username: user.username,
+ ip: request.ip,
+ reason: reason.to_s
+ )
+ end
+
+ def require_email_verification_enabled?(user)
+ Feature.enabled?(:require_email_verification, user)
+ end
+end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 704453fbf44..713231cbc6f 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -4,6 +4,7 @@ class ConfirmationsController < Devise::ConfirmationsController
include AcceptsPendingInvitations
include GitlabRecaptcha
include OneTrustCSP
+ include GoogleAnalyticsCSP
prepend_before_action :check_recaptcha, only: :create
before_action :load_recaptcha, only: :new
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index c71c101b434..67eeb43d5a2 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -82,6 +82,13 @@ class GraphqlController < ApplicationController
render_error(exception.message, status: :unprocessable_entity)
end
+ rescue_from ActiveRecord::QueryAborted do |exception|
+ log_exception(exception)
+
+ error = "Request timed out. Please try a less complex query or a smaller set of records."
+ render_error(error, status: :service_unavailable)
+ end
+
override :feature_category
def feature_category
::Gitlab::FeatureCategories.default.from_request(request) || super
diff --git a/app/controllers/groups/harbor/application_controller.rb b/app/controllers/groups/harbor/application_controller.rb
new file mode 100644
index 00000000000..cff767c8efd
--- /dev/null
+++ b/app/controllers/groups/harbor/application_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Groups
+ module Harbor
+ class ApplicationController < Groups::ApplicationController
+ layout 'group'
+ include ::Harbor::Access
+
+ private
+
+ def authorize_read_harbor_registry!
+ render_404 unless can?(current_user, :read_harbor_registry, @group)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/harbor/artifacts_controller.rb b/app/controllers/groups/harbor/artifacts_controller.rb
new file mode 100644
index 00000000000..b7570b44a2c
--- /dev/null
+++ b/app/controllers/groups/harbor/artifacts_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Groups
+ module Harbor
+ class ArtifactsController < ::Groups::Harbor::ApplicationController
+ include ::Harbor::Artifact
+
+ private
+
+ def container
+ @group
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/harbor/repositories_controller.rb b/app/controllers/groups/harbor/repositories_controller.rb
index 364607f9b20..1ad38bd7103 100644
--- a/app/controllers/groups/harbor/repositories_controller.rb
+++ b/app/controllers/groups/harbor/repositories_controller.rb
@@ -2,22 +2,13 @@
module Groups
module Harbor
- class RepositoriesController < Groups::ApplicationController
- feature_category :integrations
-
- before_action :harbor_registry_enabled!
- before_action do
- push_frontend_feature_flag(:harbor_registry_integration)
- end
-
- def show
- render :index
- end
+ class RepositoriesController < ::Groups::Harbor::ApplicationController
+ include ::Harbor::Repository
private
- def harbor_registry_enabled!
- render_404 unless Feature.enabled?(:harbor_registry_integration)
+ def container
+ @group
end
end
end
diff --git a/app/controllers/groups/harbor/tags_controller.rb b/app/controllers/groups/harbor/tags_controller.rb
new file mode 100644
index 00000000000..da43cb3f64c
--- /dev/null
+++ b/app/controllers/groups/harbor/tags_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Groups
+ module Harbor
+ class TagsController < ::Groups::Harbor::ApplicationController
+ include ::Harbor::Tag
+
+ private
+
+ def container
+ @group
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index cb7bf001918..bb2d08e487a 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -8,6 +8,10 @@ module Groups
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
+ before_action do
+ push_frontend_feature_flag(:container_registry_show_shortened_path, group)
+ end
+
feature_category :container_registry
urgency :low
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 55707000cf8..75193309a4e 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -18,14 +18,14 @@ class Import::BitbucketController < Import::BaseController
if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state])
go_to_bitbucket_for_permissions
else
- response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url)
+ response = oauth_client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url(namespace_id: params[:namespace_id]))
session[:bitbucket_token] = response.token
session[:bitbucket_expires_at] = response.expires_at
session[:bitbucket_expires_in] = response.expires_in
session[:bitbucket_refresh_token] = response.refresh_token
- redirect_to status_import_bitbucket_url
+ redirect_to status_import_bitbucket_url(namespace_id: params[:namespace_id])
end
end
@@ -78,16 +78,15 @@ class Import::BitbucketController < Import::BaseController
bitbucket_repos.reject { |repo| repo.valid? }
end
+ def provider_url
+ nil
+ end
+
override :provider_name
def provider_name
:bitbucket
end
- override :provider_url
- def provider_url
- provider.url
- end
-
private
def oauth_client
@@ -121,7 +120,7 @@ class Import::BitbucketController < Import::BaseController
def go_to_bitbucket_for_permissions
state = SecureRandom.base64(64)
session[:bitbucket_auth_state] = state
- redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url, state: state)
+ redirect_to oauth_client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url(namespace_id: params[:namespace_id]), state: state)
end
def bitbucket_unauthorized(exception)
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 00f3f0b08b2..12147196749 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -49,7 +49,7 @@ class Import::BitbucketServerController < Import::BaseController
session[bitbucket_server_username_key] = params[:bitbucket_server_username]
session[bitbucket_server_url_key] = params[:bitbucket_server_url]
- redirect_to status_import_bitbucket_server_path
+ redirect_to status_import_bitbucket_server_path(namespace_id: params[:namespace_id])
end
# We need to re-expose controller's internal method 'status' as action.
@@ -115,7 +115,7 @@ class Import::BitbucketServerController < Import::BaseController
unless session[bitbucket_server_url_key].present? &&
session[bitbucket_server_username_key].present? &&
session[personal_access_token_key].present?
- redirect_to new_import_bitbucket_server_path
+ redirect_to new_import_bitbucket_server_path(namespace_id: params[:namespace_id])
end
end
@@ -170,9 +170,6 @@ class Import::BitbucketServerController < Import::BaseController
}
}, status: :unprocessable_entity
end
- format.html do
- redirect_to new_import_bitbucket_server_path
- end
end
end
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index 34f12aebb91..2d607fb7ff7 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -17,7 +17,7 @@ class Import::BulkImportsController < ApplicationController
session[access_token_key] = configure_params[access_token_key]&.strip
session[url_key] = configure_params[url_key]
- redirect_to status_import_bulk_imports_url
+ redirect_to status_import_bulk_imports_url(namespace_id: params[:namespace_id])
end
def status
@@ -35,6 +35,12 @@ class Import::BulkImportsController < ApplicationController
render json: json_response
end
format.html do
+ if params[:namespace_id]
+ @namespace = Namespace.find_by_id(params[:namespace_id])
+
+ render_404 unless current_user.can?(:create_subgroup, @namespace)
+ end
+
@source_url = session[url_key]
end
end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index b949a99c250..7b580234227 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -17,12 +17,12 @@ class Import::FogbugzController < Import::BaseController
res = Gitlab::FogbugzImport::Client.new(import_params.to_h.symbolize_keys)
rescue StandardError
# If the URI is invalid various errors can occur
- return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL')
+ return redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]), alert: _('Could not connect to FogBugz, check your URL')
end
session[:fogbugz_token] = res.get_token
session[:fogbugz_uri] = params[:uri]
- redirect_to new_user_map_import_fogbugz_path
+ redirect_to new_user_map_import_fogbugz_path(namespace_id: params[:namespace_id])
end
def new_user_map
@@ -41,12 +41,12 @@ class Import::FogbugzController < Import::BaseController
flash[:notice] = _('The user map has been saved. Continue by selecting the projects you want to import.')
- redirect_to status_import_fogbugz_path
+ redirect_to status_import_fogbugz_path(namespace_id: params[:namespace_id])
end
def status
unless client.valid?
- return redirect_to new_import_fogbugz_path
+ return redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id])
end
super
@@ -106,7 +106,7 @@ class Import::FogbugzController < Import::BaseController
end
def fogbugz_unauthorized(exception)
- redirect_to new_import_fogbugz_path, alert: exception.message
+ redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]), alert: exception.message
end
def import_params
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 399a92c59e0..4b4ac07b389 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -7,7 +7,7 @@ class Import::GiteaController < Import::GithubController
def new
if session[access_token_key].present? && provider_url.present?
- redirect_to status_import_url(namespace_id: params[:namespace_id])
+ redirect_to status_import_url
end
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 8dd40b6254e..9cc58ce542c 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -23,25 +23,24 @@ class Import::GithubController < Import::BaseController
if !ci_cd_only? && github_import_configured? && logged_in_with_provider?
go_to_provider_for_permissions
elsif session[access_token_key]
- redirect_to status_import_url(namespace_id: params[:namespace_id])
+ redirect_to status_import_url
end
end
def callback
auth_state = session.delete(auth_state_key)
- namespace_id = session.delete(:namespace_id)
if auth_state.blank? || !ActiveSupport::SecurityUtils.secure_compare(auth_state, params[:state])
provider_unauthorized
else
session[access_token_key] = get_token(params[:code])
- redirect_to status_import_url(namespace_id: namespace_id)
+ redirect_to status_import_url
end
end
def personal_access_token
session[access_token_key] = params[:personal_access_token]&.strip
- redirect_to status_import_url(namespace_id: params[:namespace_id].presence)
+ redirect_to status_import_url
end
def status
@@ -205,15 +204,15 @@ class Import::GithubController < Import::BaseController
end
def new_import_url
- public_send("new_import_#{provider_name}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("new_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend
end
- def status_import_url(namespace_id: nil)
- public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: namespace_id })) # rubocop:disable GitlabSecurity/PublicSend
+ def status_import_url
+ public_send("status_import_#{provider_name}_url", extra_import_params.merge({ namespace_id: params[:namespace_id].presence })) # rubocop:disable GitlabSecurity/PublicSend
end
def callback_import_url
- public_send("users_import_#{provider_name}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("users_import_#{provider_name}_callback_url", extra_import_params.merge({ namespace_id: params[:namespace_id] })) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
@@ -255,7 +254,6 @@ class Import::GithubController < Import::BaseController
def provider_auth
if !ci_cd_only? && session[access_token_key].blank?
- session[:namespace_id] = params[:namespace_id]
go_to_provider_for_permissions
end
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index c846d9d225a..dd25698d0a9 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -12,8 +12,8 @@ class Import::GitlabController < Import::BaseController
rescue_from OAuth2::Error, with: :gitlab_unauthorized
def callback
- session[:gitlab_access_token] = client.get_token(params[:code], callback_import_gitlab_url)
- redirect_to status_import_gitlab_url
+ session[:gitlab_access_token] = client.get_token(params[:code], callback_import_gitlab_url(namespace_id: params[:namespace_id]))
+ redirect_to status_import_gitlab_url(namespace_id: params[:namespace_id])
end
# We need to re-expose controller's internal method 'status' as action.
@@ -79,7 +79,7 @@ class Import::GitlabController < Import::BaseController
end
def go_to_gitlab_for_permissions
- redirect_to client.authorize_url(callback_import_gitlab_url)
+ redirect_to client.authorize_url(callback_import_gitlab_url(namespace_id: params[:namespace_id]))
end
def gitlab_unauthorized
diff --git a/app/controllers/jira_connect/oauth_application_ids_controller.rb b/app/controllers/jira_connect/oauth_application_ids_controller.rb
index 05c23210da2..a84b47f4c8b 100644
--- a/app/controllers/jira_connect/oauth_application_ids_controller.rb
+++ b/app/controllers/jira_connect/oauth_application_ids_controller.rb
@@ -5,9 +5,10 @@ module JiraConnect
feature_category :integrations
skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
def show
- if Feature.enabled?(:jira_connect_oauth_self_managed) && jira_connect_application_key.present?
+ if show_application_id?
render json: { application_id: jira_connect_application_key }
else
head :not_found
@@ -16,6 +17,12 @@ module JiraConnect
private
+ def show_application_id?
+ return if Gitlab.com?
+
+ Feature.enabled?(:jira_connect_oauth_self_managed) && jira_connect_application_key.present?
+ end
+
def jira_connect_application_key
Gitlab::CurrentSettings.jira_connect_application_key.presence
end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index 2ba9f8264e1..623113f8413 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -25,6 +25,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
+ before_action :allow_self_managed_content_security_policy, only: :index
before_action :authenticate_user!, only: :create
def index
@@ -62,6 +63,13 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
private
+ def allow_self_managed_content_security_policy
+ return unless current_jira_installation.instance_url?
+
+ request.content_security_policy.directives['connect-src'] ||= []
+ request.content_security_policy.directives['connect-src'] << Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/oauth_application_ids')
+ end
+
def create_service
JiraConnectSubscriptions::CreateService.new(current_jira_installation, current_user, namespace_path: params['namespace_path'], jira_user: jira_user)
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 6aa46b8e4c3..955dfe58449 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -3,10 +3,12 @@
class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
extend ::Gitlab::Utils::Override
+ before_action :check_action_name_in_available_providers
+
def self.define_providers!
return unless Gitlab::Auth::Ldap::Config.sign_in_enabled?
- Gitlab::Auth::Ldap::Config.available_servers.each do |server|
+ Gitlab::Auth::Ldap::Config.servers.each do |server|
alias_method server['provider_name'], :ldap
end
end
@@ -36,6 +38,18 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
redirect_to new_user_session_path
end
+
+ private
+
+ def check_action_name_in_available_providers
+ render_404 unless available_providers.include?(action_name)
+ end
+
+ def available_providers
+ Gitlab::Auth::Ldap::Config.available_servers.map do |server|
+ server['provider_name']
+ end
+ end
end
Ldap::OmniauthCallbacksController.prepend_mod_with('Ldap::OmniauthCallbacksController')
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 3724bb0d925..a996bad3fac 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -52,10 +52,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
end
def set_index_vars
- @applications = current_user.oauth_applications
+ @applications = current_user.oauth_applications.load
@authorized_tokens = current_user.oauth_authorized_tokens
- @authorized_anonymous_tokens = @authorized_tokens.reject(&:application)
- @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?)
+ .latest_per_application
+ .preload_application
# Don't overwrite a value possibly set by `create`
@application ||= Doorkeeper::Application.new
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index c9c51289d3a..2e9fbb1d0d9 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -54,8 +54,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
# limit scopes when signing in with GitLab
def downgrade_scopes!
- return unless Feature.enabled?(:omniauth_login_minimal_scopes, current_user)
-
auth_type = params.delete('gl_auth_type')
return unless auth_type == 'login'
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 265fa505b2a..1a8908e8571 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -14,6 +14,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
name: params[:name],
scopes: scopes
)
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @active_personal_access_tokens
+ end
+ end
end
def create
@@ -56,6 +63,28 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def active_personal_access_tokens
tokens = finder(state: 'active', sort: 'expires_at_asc').execute
+
+ if Feature.enabled?('access_token_pagination')
+ tokens = tokens.page(page)
+ add_pagination_headers(tokens)
+ end
+
::API::Entities::PersonalAccessTokenWithDetails.represent(tokens)
end
+
+ def add_pagination_headers(relation)
+ Gitlab::Pagination::OffsetHeaderBuilder.new(
+ request_context: self,
+ per_page: relation.limit_value,
+ page: relation.current_page,
+ next_page: relation.next_page,
+ prev_page: relation.prev_page,
+ total: relation.total_count,
+ params: params.permit(:page)
+ ).execute
+ end
+
+ def page
+ (params[:page] || 1).to_i
+ end
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index 64ced43311a..5bfda526fb0 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -25,7 +25,7 @@ class Projects::BlameController < Projects::ApplicationController
blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page))
- @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path).fabricate!
+ @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
render locals: { blame_pagination: blame_service.pagination }
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index a9561fb9312..97aae56c4ec 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -42,7 +42,6 @@ class Projects::BlobController < Projects::ApplicationController
urgency :low, [:create, :show, :edit, :update, :diff]
before_action do
- push_frontend_feature_flag(:refactor_blob_viewer, @project)
push_frontend_feature_flag(:highlight_js, @project)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index ac3c85f3b40..7ef9fd9daed 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -24,9 +24,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
- before_action do
- push_frontend_feature_flag(:monitor_logging, project)
- end
after_action :expire_etag_cache, only: [:cancel_auto_stop]
feature_category :continuous_delivery
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index 980e9bdcdad..050b26a40c7 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -12,7 +12,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
def admin_project_google_cloud!
unless can?(current_user, :admin_project_google_cloud, project)
- track_event('admin_project_google_cloud!', 'access_denied', 'invalid_user')
+ track_event('admin_project_google_cloud!', 'error_access_denied', 'invalid_user')
access_denied!
end
end
@@ -20,7 +20,11 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
def google_oauth2_enabled!
config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
if config.app_id.blank? || config.app_secret.blank?
- track_event('google_oauth2_enabled!', 'access_denied', { reason: 'google_oauth2_not_configured', config: config })
+ track_event(
+ 'google_oauth2_enabled!',
+ 'error_access_denied',
+ { reason: 'google_oauth2_not_configured', config: config }
+ )
access_denied! 'This GitLab instance not configured for Google Oauth2.'
end
end
@@ -31,7 +35,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project)
feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project
unless feature_is_enabled
- track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled')
+ track_event('feature_flag_enabled!', 'error_access_denied', 'feature_flag_not_enabled')
access_denied!
end
end
@@ -42,7 +46,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
return if is_token_valid
- return_url = project_google_cloud_index_path(project)
+ return_url = project_google_cloud_configuration_path(project)
state = generate_session_key_redirect(request.url, return_url)
@authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
callback_google_api_auth_url,
@@ -65,12 +69,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
- def handle_gcp_error(action, error)
- track_event(action, 'gcp_error', error)
- @js_data = { screen: 'gcp_error', error: error.to_s }.to_json
- render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
- end
-
def track_event(action, label, property)
options = { label: label, project: project, user: current_user }
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
new file mode 100644
index 00000000000..fa672058247
--- /dev/null
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Projects
+ module GoogleCloud
+ class ConfigurationController < Projects::GoogleCloud::BaseController
+ def index
+ @google_cloud_path = project_google_cloud_configuration_path(project)
+ js_data = {
+ configurationUrl: project_google_cloud_configuration_path(project),
+ deploymentsUrl: project_google_cloud_deployments_path(project),
+ databasesUrl: project_google_cloud_databases_path(project),
+ serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project,
+ createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
+ emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'),
+ configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project),
+ gcpRegions: gcp_regions,
+ revokeOauthUrl: revoke_oauth_url
+ }
+ @js_data = js_data.to_json
+ track_event('configuration#index', 'success', js_data)
+ end
+
+ private
+
+ def gcp_regions
+ params = { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY }
+ list = ::Ci::VariablesFinder.new(project, params).execute
+ list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } }
+ end
+
+ def revoke_oauth_url
+ google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ .validate_token(expires_at_in_session)
+ google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
new file mode 100644
index 00000000000..711409e7550
--- /dev/null
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Projects
+ module GoogleCloud
+ class DatabasesController < Projects::GoogleCloud::BaseController
+ def index
+ @google_cloud_path = project_google_cloud_configuration_path(project)
+ js_data = {
+ configurationUrl: project_google_cloud_configuration_path(project),
+ deploymentsUrl: project_google_cloud_deployments_path(project),
+ databasesUrl: project_google_cloud_databases_path(project)
+ }
+ @js_data = js_data.to_json
+ track_event('databases#index', 'success', js_data)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb
index 4867d344c5a..4aa17b36fad 100644
--- a/app/controllers/projects/google_cloud/deployments_controller.rb
+++ b/app/controllers/projects/google_cloud/deployments_controller.rb
@@ -3,32 +3,47 @@
class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::BaseController
before_action :validate_gcp_token!
+ def index
+ @google_cloud_path = project_google_cloud_configuration_path(project)
+ js_data = {
+ configurationUrl: project_google_cloud_configuration_path(project),
+ deploymentsUrl: project_google_cloud_deployments_path(project),
+ databasesUrl: project_google_cloud_databases_path(project),
+ enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project),
+ enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project)
+ }
+ @js_data = js_data.to_json
+ track_event('deployments#index', 'success', js_data)
+ end
+
def cloud_run
- params = { token_in_session: token_in_session }
+ params = { google_oauth2_token: token_in_session }
enable_cloud_run_response = GoogleCloud::EnableCloudRunService
.new(project, current_user, params).execute
if enable_cloud_run_response[:status] == :error
- track_event('deployments#cloud_run', 'enable_cloud_run_error', enable_cloud_run_response)
+ track_event('deployments#cloud_run', 'error_enable_cloud_run', enable_cloud_run_response)
flash[:error] = enable_cloud_run_response[:message]
- redirect_to project_google_cloud_index_path(project)
+ redirect_to project_google_cloud_deployments_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
- track_event('deployments#cloud_run', 'generate_pipeline_error', generate_pipeline_response)
+ track_event('deployments#cloud_run', 'error_generate_pipeline', generate_pipeline_response)
flash[:error] = 'Failed to generate pipeline'
- redirect_to project_google_cloud_index_path(project)
+ redirect_to project_google_cloud_deployments_path(project)
else
cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name])
- track_event('deployments#cloud_run', 'cloud_run_success', cloud_run_mr_params)
+ track_event('deployments#cloud_run', 'success', cloud_run_mr_params)
redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params)
end
end
- rescue Google::Apis::ClientError => error
- handle_gcp_error('deployments#cloud_run', error)
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
+ track_event('deployments#cloud_run', 'error_gcp', error)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ redirect_to project_google_cloud_deployments_path(project)
end
def cloud_storage
diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
index beeb91cfd80..3fbe9a96284 100644
--- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb
+++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb
@@ -6,8 +6,10 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
# Source https://cloud.google.com/run/docs/locations 2022-01-30
AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze
+ GCP_REGION_CI_VAR_KEY = 'GCP_REGION'
+
def index
- @google_cloud_path = project_google_cloud_index_path(project)
+ @google_cloud_path = project_google_cloud_configuration_path(project)
params = { per_page: 50 }
branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true)
@@ -16,16 +18,16 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC
screen: 'gcp_regions_form',
availableRegions: AVAILABLE_REGIONS,
refs: refs,
- cancelPath: project_google_cloud_index_path(project)
+ cancelPath: project_google_cloud_configuration_path(project)
}
@js_data = js_data.to_json
- track_event('gcp_regions#index', 'form_render', js_data)
+ track_event('gcp_regions#index', 'success', js_data)
end
def create
permitted_params = params.permit(:ref, :gcp_region)
response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region])
- track_event('gcp_regions#create', 'form_submit', response)
- redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured')
+ track_event('gcp_regions#create', 'success', response)
+ redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured')
end
end
diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
index 03d1474707b..1a9a2daf4f2 100644
--- a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
+++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb
@@ -8,16 +8,15 @@ class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::Base
response = google_api_client.revoke_authorizations
if response.success?
- status = 'success'
redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') }
+ track_event('revoke_oauth#create', 'success', response.to_json)
else
- status = 'failed'
redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') }
+ track_event('revoke_oauth#create', 'error', response.to_json)
end
session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token)
- track_event('revoke_oauth#create', 'create', status)
- redirect_to project_google_cloud_index_path(project), redirect_message
+ redirect_to project_google_cloud_configuration_path(project), redirect_message
end
end
diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb
index 5d8b2030d5c..dbd83be19db 100644
--- a/app/controllers/projects/google_cloud/service_accounts_controller.rb
+++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb
@@ -4,14 +4,15 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
before_action :validate_gcp_token!
def index
- @google_cloud_path = project_google_cloud_index_path(project)
+ @google_cloud_path = project_google_cloud_configuration_path(project)
google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
gcp_projects = google_api_client.list_projects
if gcp_projects.empty?
@js_data = { screen: 'no_gcp_projects' }.to_json
- track_event('service_accounts#index', 'form_error', 'no_gcp_projects')
- render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects'
+ track_event('service_accounts#index', 'error_form', 'no_gcp_projects')
+ flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project')
+ redirect_to project_google_cloud_configuration_path(project)
else
params = { per_page: 50 }
branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true)
@@ -21,14 +22,16 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
screen: 'service_accounts_form',
gcpProjects: gcp_projects,
refs: refs,
- cancelPath: project_google_cloud_index_path(project)
+ cancelPath: project_google_cloud_configuration_path(project)
}
@js_data = js_data.to_json
- track_event('service_accounts#index', 'form_success', js_data)
+ track_event('service_accounts#index', 'success', js_data)
end
- rescue Google::Apis::ClientError => error
- handle_gcp_error('service_accounts#index', error)
+ rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
+ track_event('service_accounts#index', 'error_gcp', error)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ redirect_to project_google_cloud_configuration_path(project)
end
def create
@@ -42,9 +45,11 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
environment_name: permitted_params[:ref]
).execute
- track_event('service_accounts#create', 'form_submit', response)
- redirect_to project_google_cloud_index_path(project), notice: response.message
+ track_event('service_accounts#create', 'success', response)
+ redirect_to project_google_cloud_configuration_path(project), notice: response.message
rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
- handle_gcp_error('service_accounts#create', error)
+ track_event('service_accounts#create', 'error_gcp', error)
+ flash[:warning] = _('Google Cloud Error - %{error}') % { error: error }
+ redirect_to project_google_cloud_configuration_path(project)
end
end
diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb
deleted file mode 100644
index 49bb4bec859..00000000000
--- a/app/controllers/projects/google_cloud_controller.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
- GCP_REGION_CI_VAR_KEY = 'GCP_REGION'
-
- def index
- js_data = {
- 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'),
- configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project),
- gcpRegions: gcp_regions,
- revokeOauthUrl: revoke_oauth_url
- }
- @js_data = js_data.to_json
- track_event('google_cloud#index', 'index', js_data)
- end
-
- private
-
- def gcp_regions
- list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute
- list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } }
- end
-
- def revoke_oauth_url
- google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
- .validate_token(expires_at_in_session)
- google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil
- end
-end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 6007e09f109..08eebfa0e4b 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -9,7 +9,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
def update
group_link = @project.project_group_links.find(params[:id])
- Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params)
+ Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params)
if group_link.expires?
render json: {
diff --git a/app/controllers/projects/harbor/application_controller.rb b/app/controllers/projects/harbor/application_controller.rb
index e6e694783fa..9271ec560dc 100644
--- a/app/controllers/projects/harbor/application_controller.rb
+++ b/app/controllers/projects/harbor/application_controller.rb
@@ -4,18 +4,12 @@ module Projects
module Harbor
class ApplicationController < Projects::ApplicationController
layout 'project'
-
- before_action :harbor_registry_enabled!
- before_action do
- push_frontend_feature_flag(:harbor_registry_integration)
- end
-
- feature_category :integrations
+ include ::Harbor::Access
private
- def harbor_registry_enabled!
- render_404 unless Feature.enabled?(:harbor_registry_integration)
+ def authorize_read_harbor_registry!
+ render_404 unless can?(current_user, :read_harbor_registry, @project)
end
end
end
diff --git a/app/controllers/projects/harbor/artifacts_controller.rb b/app/controllers/projects/harbor/artifacts_controller.rb
new file mode 100644
index 00000000000..ce36f181b42
--- /dev/null
+++ b/app/controllers/projects/harbor/artifacts_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Projects
+ module Harbor
+ class ArtifactsController < ::Projects::Harbor::ApplicationController
+ include ::Harbor::Artifact
+
+ private
+
+ def container
+ @project
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/harbor/repositories_controller.rb b/app/controllers/projects/harbor/repositories_controller.rb
index dd3e3dc1978..4db13331bf0 100644
--- a/app/controllers/projects/harbor/repositories_controller.rb
+++ b/app/controllers/projects/harbor/repositories_controller.rb
@@ -3,8 +3,12 @@
module Projects
module Harbor
class RepositoriesController < ::Projects::Harbor::ApplicationController
- def show
- render :index
+ include ::Harbor::Repository
+
+ private
+
+ def container
+ @project
end
end
end
diff --git a/app/controllers/projects/harbor/tags_controller.rb b/app/controllers/projects/harbor/tags_controller.rb
new file mode 100644
index 00000000000..f49c5ac7768
--- /dev/null
+++ b/app/controllers/projects/harbor/tags_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Projects
+ module Harbor
+ class TagsController < ::Projects::Harbor::ApplicationController
+ include ::Harbor::Tag
+
+ private
+
+ def container
+ @project
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 99eba32e00f..50f388324f1 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -15,52 +15,21 @@ class Projects::HooksController < Projects::ApplicationController
feature_category :integrations
urgency :low, [:test]
- def index
- @hooks = @project.hooks.load
- @hook = ProjectHook.new
- end
-
- def create
- @hook = @project.hooks.new(hook_params)
- @hook.save
-
- unless @hook.valid?
- @hooks = @project.hooks.select(&:persisted?)
- flash[:alert] = @hook.errors.full_messages.join.html_safe
- end
-
- redirect_to action: :index
- end
-
- def edit
- redirect_to(action: :index) unless hook
- end
-
- def update
- if hook.update(hook_params)
- flash[:notice] = _('Hook was successfully updated.')
- redirect_to action: :index
- else
- render 'edit'
- end
- end
-
def test
- result = TestHooks::ProjectService.new(hook, current_user, params[:trigger]).execute
+ trigger = params.fetch(:trigger, ::ProjectHook.triggers.each_value.first.to_s)
+ result = TestHooks::ProjectService.new(hook, current_user, trigger).execute
set_hook_execution_notice(result)
redirect_back_or_default(default: { action: :index })
end
- def destroy
- destroy_hook(hook)
+ private
- redirect_to action: :index, status: :found
+ def relation
+ @project.hooks
end
- private
-
def hook
@hook ||= @project.hooks.find(params[:id])
end
@@ -69,13 +38,7 @@ class Projects::HooksController < Projects::ApplicationController
@hook_logs ||= hook.web_hook_logs.recent.page(params[:page])
end
- def hook_params
- params.require(:hook).permit(
- :enable_ssl_verification,
- :token,
- :url,
- :push_events_branch_filter,
- *ProjectHook.triggers.values
- )
+ def trigger_values
+ ProjectHook.triggers.values
end
end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 70eab792b40..f9fa8046962 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -8,6 +8,9 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action :load_incident, only: [:show]
before_action do
push_frontend_feature_flag(:incident_timeline, @project)
+ push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
+ push_frontend_feature_flag(:work_items_mvc_2)
+ push_frontend_feature_flag(:work_items_hierarchy, @project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index f974b16468c..f1c9e2b2653 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -22,7 +22,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
before_action :redirect_if_task, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
- after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
+ after_action :log_issue_show, only: :show
before_action :set_issuables_index, if: ->(c) {
SET_ISSUABLES_INDEX_ONLY_ACTIONS.include?(c.action_name.to_sym) && !index_html_request?
@@ -41,14 +41,11 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_download_code!, only: [:related_branches]
before_action do
- push_frontend_feature_flag(:contacts_autocomplete, project&.group)
push_frontend_feature_flag(:incident_timeline, project)
end
before_action only: :show do
- push_frontend_feature_flag(:confidential_notes, project&.group)
push_frontend_feature_flag(:issue_assignees_widget, project)
- push_frontend_feature_flag(:paginated_issue_discussions, project)
push_frontend_feature_flag(:realtime_labels, project)
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
push_frontend_feature_flag(:work_items_mvc_2)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 9574c5d5849..ad59f421c06 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -5,28 +5,22 @@ class Projects::JobsController < Projects::ApplicationController
include ContinueParams
include ProjectStatsRefreshConflictsGuard
- urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :status, :erase, :raw]
+ urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw]
before_action :find_job_as_build, except: [:index, :play, :show]
before_action :find_job_as_processable, only: [:play, :show]
before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build!
before_action :authorize_update_build!,
- except: [:index, :show, :status, :raw, :trace, :erase, :cancel, :unschedule]
+ except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule]
before_action :authorize_erase_build!, only: [:erase]
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
- before_action :push_jobs_table_vue, only: [:index]
- before_action :push_jobs_table_vue_search, only: [:index]
+ before_action :push_job_log_search, only: [:show]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
- before_action do
- push_frontend_feature_flag(:infinitely_collapsible_sections, @project)
- push_frontend_feature_flag(:trigger_job_retry_action, @project)
- end
-
layout 'project'
feature_category :continuous_integration
@@ -125,12 +119,6 @@ class Projects::JobsController < Projects::ApplicationController
end
end
- def status
- render json: Ci::JobSerializer
- .new(project: @project, current_user: @current_user)
- .represent_status(@build.present(current_user: current_user))
- end
-
def erase
if @build.erase(erased_by: current_user)
redirect_to project_job_path(project, @build),
@@ -261,11 +249,7 @@ class Projects::JobsController < Projects::ApplicationController
::Gitlab::Workhorse.channel_websocket(service)
end
- def push_jobs_table_vue
- push_frontend_feature_flag(:jobs_table_vue, @project)
- end
-
- def push_jobs_table_vue_search
- push_frontend_feature_flag(:jobs_table_vue_search, @project)
+ def push_job_log_search
+ push_frontend_feature_flag(:job_log_search, @project)
end
end
diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb
deleted file mode 100644
index 0f751db2064..00000000000
--- a/app/controllers/projects/logs_controller.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- class LogsController < Projects::ApplicationController
- include ::Gitlab::Utils::StrongMemoize
-
- before_action :authorize_read_pod_logs!
- before_action :ensure_deployments, only: %i(k8s elasticsearch)
-
- feature_category :logging
- urgency :low
-
- def index
- return render_404 unless Feature.enabled?(:monitor_logging, project)
-
- if environment || cluster
- render :index
- else
- render :empty_logs
- end
- end
-
- def k8s
- render_logs(::PodLogs::KubernetesService, k8s_params)
- end
-
- def elasticsearch
- render_logs(::PodLogs::ElasticsearchService, elasticsearch_params)
- end
-
- private
-
- def render_logs(service, permitted_params)
- ::Gitlab::PollingInterval.set_header(response, interval: 3_000)
-
- result = service.new(cluster, namespace, params: permitted_params).execute
-
- if result.nil?
- head :accepted
- elsif result[:status] == :success
- render json: result
- else
- render status: :bad_request, json: result
- end
- end
-
- # cluster is selected either via environment or directly by id
- def cluster_params
- params.permit(:environment_name, :cluster_id)
- end
-
- def k8s_params
- params.permit(:container_name, :pod_name)
- end
-
- def elasticsearch_params
- params.permit(:container_name, :pod_name, :search, :start_time, :end_time, :cursor)
- end
-
- def environment
- strong_memoize(:environment) do
- if cluster_params.key?(:environment_name)
- ::Environments::EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).execute.first
- else
- project.default_environment
- end
- end
- end
-
- def cluster
- strong_memoize(:cluster) do
- if gitlab_managed_apps_logs?
- clusters = ClusterAncestorsFinder.new(project, current_user).execute
- clusters.find { |cluster| cluster.id == cluster_params[:cluster_id].to_i }
- else
- environment&.deployment_platform&.cluster
- end
- end
- end
-
- def namespace
- if gitlab_managed_apps_logs?
- Gitlab::Kubernetes::Helm::NAMESPACE
- else
- environment.deployment_namespace
- end
- end
-
- def ensure_deployments
- return if gitlab_managed_apps_logs?
- return if cluster && namespace.present?
-
- render status: :bad_request, json: {
- status: :error,
- message: _('Environment does not have deployments')
- }
- end
-
- def gitlab_managed_apps_logs?
- cluster_params.key?(:cluster_id)
- end
- end
-end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index d420e136316..a2f018c013b 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -32,10 +32,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
- push_frontend_feature_flag(:file_identifier_hash)
push_frontend_feature_flag(:merge_request_widget_graphql, project)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
- push_frontend_feature_flag(:confidential_notes, project)
push_frontend_feature_flag(:restructured_mr_widget, project)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project)
push_frontend_feature_flag(:refactor_code_quality_extension, project)
@@ -44,10 +42,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:realtime_labels, project)
push_frontend_feature_flag(:refactor_security_extension, @project)
+ push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
push_frontend_feature_flag(:mr_attention_requests, current_user)
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:paginated_mr_discussions, project)
push_frontend_feature_flag(:mr_review_submit_comment, project)
+ push_frontend_feature_flag(:mr_experience_survey, project)
end
before_action do
@@ -86,6 +86,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:rebase,
:discussions,
:pipelines,
+ :coverage_reports,
:test_reports,
:codequality_mr_diff_reports,
:codequality_reports,
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
index f2f276071a0..b78ee6ca917 100644
--- a/app/controllers/projects/metrics_dashboard_controller.rb
+++ b/app/controllers/projects/metrics_dashboard_controller.rb
@@ -12,7 +12,6 @@ module Projects
before_action do
push_frontend_feature_flag(:prometheus_computed_alerts)
push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate)
- push_frontend_feature_flag(:monitor_logging, project)
end
feature_category :metrics
diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb
index e5b2dd14f69..8ac370b1bd4 100644
--- a/app/controllers/projects/pipelines/tests_controller.rb
+++ b/app/controllers/projects/pipelines/tests_controller.rb
@@ -7,6 +7,7 @@ module Projects
before_action :authorize_read_build!
before_action :builds, only: [:show]
+ before_action :validate_test_reports!, only: [:show]
feature_category :code_testing
@@ -23,19 +24,21 @@ module Projects
def show
respond_to do |format|
format.json do
- if pipeline.has_expired_test_reports?
- render json: { errors: 'Test report artifacts have expired' }, status: :not_found
- else
- render json: TestSuiteSerializer
- .new(project: project, current_user: @current_user)
- .represent(test_suite, details: true)
- end
+ render json: TestSuiteSerializer
+ .new(project: project, current_user: @current_user)
+ .represent(test_suite, details: true)
end
end
end
private
+ def validate_test_reports!
+ unless pipeline.has_test_reports?
+ render json: { errors: 'Test report artifacts not found' }, status: :not_found
+ end
+ end
+
def builds
@builds ||= pipeline.latest_builds.id_in(build_ids).presence || render_404
end
@@ -48,7 +51,7 @@ module Projects
def test_suite
suite = builds.sum do |build|
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
end
Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index adc3a912a91..b2aa1d9f4ca 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -38,6 +38,8 @@ class Projects::PipelinesController < Projects::ApplicationController
track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? }
track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? }
track_redis_hll_event :charts, name: 'p_analytics_ci_cd_lead_time', if: -> { should_track_ci_cd_lead_time? }
+ track_redis_hll_event :charts, name: 'p_analytics_ci_cd_time_to_restore_service', if: -> { should_track_ci_cd_time_to_restore_service? }
+ track_redis_hll_event :charts, name: 'p_analytics_ci_cd_change_failure_rate', if: -> { should_track_ci_cd_change_failure_rate? }
wrap_parameters Ci::Pipeline
@@ -174,7 +176,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def stage
- @stage = pipeline.legacy_stage(params[:stage])
+ @stage = pipeline.stage(params[:stage])
return not_found unless @stage
render json: StageSerializer
@@ -361,6 +363,14 @@ class Projects::PipelinesController < Projects::ApplicationController
def should_track_ci_cd_lead_time?
params[:chart] == 'lead-time'
end
+
+ def should_track_ci_cd_time_to_restore_service?
+ params[:chart] == 'time-to-restore-service'
+ end
+
+ def should_track_ci_cd_change_failure_rate?
+ params[:chart] == 'change-failure-rate'
+ end
end
Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController')
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 168e703c87d..cd9c6efb106 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -13,9 +13,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
-
- @group_links = @project.project_group_links
- @group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present?
+ @include_relations ||= requested_relations(:groups_with_inherited_permissions)
if can?(current_user, :admin_project_member, @project)
@invited_members = present_members(invited_members)
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index ad3b2bc98e7..87cb8e4781f 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -8,6 +8,10 @@ module Projects
before_action :authorize_update_container_image!, only: [:destroy]
+ before_action do
+ push_frontend_feature_flag(:container_registry_show_shortened_path, project)
+ end
+
def index
respond_to do |format|
format.html { ensure_root_container_repository! }
diff --git a/app/controllers/projects/service_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb
index d8f1785d95e..43c249afd8e 100644
--- a/app/controllers/projects/service_ping_controller.rb
+++ b/app/controllers/projects/service_ping_controller.rb
@@ -17,7 +17,8 @@ class Projects::ServicePingController < Projects::ApplicationController
return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_success_count
- Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user)
+ Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user,
+ project: project)
head(200)
end
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index 3365da65de8..cee9e9feb7b 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -11,6 +11,7 @@ module Projects
before_action :integration, only: [:edit, :update, :test]
before_action :default_integration, only: [:edit, :update]
before_action :web_hook_logs, only: [:edit, :update]
+ before_action -> { check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) }, only: :test
respond_to :html
@@ -88,7 +89,7 @@ module Projects
unless result[:success]
return {
error: true,
- message: s_('Integrations|Connection failed. Please check your settings.'),
+ message: s_('Integrations|Connection failed. Check your integration settings.'),
service_response: result[:message].to_s,
test_failed: true
}
@@ -98,7 +99,7 @@ module Projects
rescue *Gitlab::HTTP::HTTP_ERRORS => e
{
error: true,
- message: s_('Integrations|Connection failed. Please check your settings.'),
+ message: s_('Integrations|Connection failed. Check your integration settings.'),
service_response: e.message,
test_failed: true
}
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 77d7f3570f3..478d9f0b38e 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -14,7 +14,6 @@ module Projects
respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
helper_method :error_tracking_setting
- helper_method :tracing_setting
feature_category :incident_management
urgency :low
@@ -60,19 +59,9 @@ module Projects
::Gitlab::Tracking::IncidentManagement.track_from_params(
update_params[:incident_management_setting_attributes]
)
- track_tracing_external_url
end
end
- def track_tracing_external_url
- external_url_previous_change = project&.tracing_setting&.external_url_previous_change
-
- return unless external_url_previous_change
- return unless external_url_previous_change[0].blank? && external_url_previous_change[1].present?
-
- ::Gitlab::Tracking.event('project:operations:tracing', 'external_url_populated', user: current_user, project: project, namespace: project.namespace)
- end
-
def alerting_params
{ alerting_setting_attributes: { regenerate_token: true } }
end
@@ -124,10 +113,6 @@ module Projects
project.build_error_tracking_setting
end
- def tracing_setting
- @tracing_setting ||= project.tracing_setting || project.build_tracing_setting
- end
-
def update_params
params.require(:project).permit(permitted_project_params)
end
@@ -147,8 +132,7 @@ module Projects
project: [:slug, :name, :organization_slug, :organization_name, :sentry_project_id]
],
- grafana_integration_attributes: [:token, :grafana_url, :enabled],
- tracing_setting_attributes: [:external_url]
+ grafana_integration_attributes: [:token, :grafana_url, :enabled]
}
end
end
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
index b852673d82a..adeadf2133e 100644
--- a/app/controllers/projects/tags/releases_controller.rb
+++ b/app/controllers/projects/tags/releases_controller.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# TODO: remove this file together with FF https://gitlab.com/gitlab-org/gitlab/-/issues/366244
+# also delete view/routes
class Projects::Tags::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 432497850f2..847b1baca10 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -53,7 +53,7 @@ class Projects::TagsController < Projects::ApplicationController
return render_404 unless @tag
- @release = @project.releases.find_or_initialize_by(tag: @tag.name)
+ @release = @project.releases.find_by(tag: @tag.name)
@commit = @repository.commit(@tag.dereferenced_target)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/tracings_controller.rb b/app/controllers/projects/tracings_controller.rb
deleted file mode 100644
index b5c1354c4a9..00000000000
--- a/app/controllers/projects/tracings_controller.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- class TracingsController < Projects::ApplicationController
- content_security_policy do |p|
- next if p.directives.blank?
-
- global_frame_src = p.frame_src
-
- p.frame_src -> { frame_src_csp_policy(global_frame_src) }
- end
-
- before_action :authorize_update_environment!
-
- feature_category :tracing
- urgency :low
-
- def show
- render_404 unless Feature.enabled?(:monitor_tracing, @project)
- end
-
- private
-
- def frame_src_csp_policy(global_frame_src)
- external_url = @project&.tracing_setting&.external_url
-
- external_url.presence || global_frame_src
- end
- end
-end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index ed14f66847c..ce51cbb6677 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project)
- push_frontend_feature_flag(:refactor_blob_viewer, @project)
push_frontend_feature_flag(:highlight_js, @project)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 1e0ef1ad337..37e472050a0 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -36,7 +36,6 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project)
- push_frontend_feature_flag(:refactor_blob_viewer, @project)
push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:increase_page_size_exponentially, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index a2b25acae64..4e18e6a3b20 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -3,6 +3,7 @@
module Registrations
class WelcomeController < ApplicationController
include OneTrustCSP
+ include GoogleAnalyticsCSP
layout 'minimal'
skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 206580d205a..bb16c2d2098 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -7,6 +7,7 @@ class RegistrationsController < Devise::RegistrationsController
include InvisibleCaptchaOnSignup
include OneTrustCSP
include BizibleCSP
+ include GoogleAnalyticsCSP
layout 'devise'
@@ -220,7 +221,7 @@ class RegistrationsController < Devise::RegistrationsController
return unless member
- Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
+ Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s, user: resource)
end
def context_user
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 7d251ba555c..7a7e63f5fc4 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -9,7 +9,7 @@ class SearchController < ApplicationController
RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete].freeze
- track_redis_hll_event :show, name: 'i_search_total'
+ track_event :show, name: 'i_search_total', destinations: [:redis_hll, :snowplow]
around_action :allow_gitaly_ref_name_caching
@@ -42,13 +42,19 @@ class SearchController < ApplicationController
@sort = params[:sort] || default_sort
@search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate!
- @scope = @search_service.scope
- @without_count = @search_service.without_count?
- @show_snippets = @search_service.show_snippets?
- @search_results = @search_service.search_results
- @search_objects = @search_service.search_objects
- @search_highlight = @search_service.search_highlight
- @aggregations = @search_service.search_aggregations
+
+ @search_level = @search_service.level
+ @search_type = search_type
+
+ @global_search_duration_s = Benchmark.realtime do
+ @scope = @search_service.scope
+ @without_count = @search_service.without_count?
+ @show_snippets = @search_service.show_snippets?
+ @search_results = @search_service.search_results
+ @search_objects = @search_service.search_objects
+ @search_highlight = @search_service.search_highlight
+ @aggregations = @search_service.search_aggregations
+ end
increment_search_counters
end
@@ -144,7 +150,9 @@ 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]
+ payload[:metadata]['meta.search.type'] = @search_type if @search_type.present?
+ payload[:metadata]['meta.search.level'] = @search_level if @search_level.present?
+ payload[:metadata][:global_search_duration_s] = @global_search_duration_s if @global_search_duration_s.present?
if search_service.abuse_detected?
payload[:metadata]['abuse.confidence'] = Gitlab::Abuse.confidence(:certain)
@@ -203,6 +211,14 @@ class SearchController < ApplicationController
render status: :request_timeout
end
end
+
+ def tracking_namespace_source
+ search_service.project&.namespace || search_service.group
+ end
+
+ def search_type
+ 'basic'
+ end
end
SearchController.prepend_mod_with('SearchController')
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 9000e9c39de..6195d152f00 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -11,6 +11,8 @@ class SessionsController < Devise::SessionsController
include Gitlab::Utils::StrongMemoize
include OneTrustCSP
include BizibleCSP
+ include VerifiesWithEmail
+ include GoogleAnalyticsCSP
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb
index f7eb2aad9dc..f36b140f3a2 100644
--- a/app/controllers/users/terms_controller.rb
+++ b/app/controllers/users/terms_controller.rb
@@ -4,6 +4,7 @@ module Users
class TermsController < ApplicationController
include InternalRedirect
include OneTrustCSP
+ include GoogleAnalyticsCSP
skip_before_action :authenticate_user!, only: [:index]
skip_before_action :enforce_terms!
@@ -13,6 +14,10 @@ module Users
before_action :terms
+ before_action only: [:index] do
+ push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)
+ end
+
layout 'terms'
feature_category :user_management
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 2799479d922..eaf08cd421b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -58,7 +58,9 @@ class UsersController < ApplicationController
# Get all keys of a user(params[:username]) in a text format
# Helpful for sysadmins to put in respective servers
def ssh_keys
- render plain: user.all_ssh_keys.join("\n")
+ keys = user.all_ssh_keys.join("\n")
+ keys << "\n" unless keys.empty?
+ render plain: keys
end
def activity
@@ -74,7 +76,9 @@ class UsersController < ApplicationController
# Get all gpg keys of a user(params[:username]) in a text format
def gpg_keys
- render plain: user.gpg_keys.select(&:verified?).map(&:key).join("\n")
+ keys = user.gpg_keys.filter_map { |gpg_key| gpg_key.key if gpg_key.verified? }.join("\n")
+ keys << "\n" unless keys.empty?
+ render plain: keys
end
def groups
diff --git a/app/events/pages/page_deleted_event.rb b/app/events/pages/page_deleted_event.rb
index b1ea14a6ec5..5787506121a 100644
--- a/app/events/pages/page_deleted_event.rb
+++ b/app/events/pages/page_deleted_event.rb
@@ -7,7 +7,8 @@ module Pages
'type' => 'object',
'properties' => {
'project_id' => { 'type' => 'integer' },
- 'namespace_id' => { 'type' => 'integer' }
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
},
'required' => %w[project_id namespace_id]
}
diff --git a/app/events/pages/page_deployed_event.rb b/app/events/pages/page_deployed_event.rb
new file mode 100644
index 00000000000..52e53772a51
--- /dev/null
+++ b/app/events/pages/page_deployed_event.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Pages
+ class PageDeployedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[project_id namespace_id root_namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/events/projects/project_created_event.rb b/app/events/projects/project_created_event.rb
new file mode 100644
index 00000000000..abac772dfc9
--- /dev/null
+++ b/app/events/projects/project_created_event.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectCreatedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[project_id namespace_id root_namespace_id]
+ }
+ end
+ end
+end
diff --git a/app/events/projects/project_deleted_event.rb b/app/events/projects/project_deleted_event.rb
index ac58c5c6755..fe0c832fe0f 100644
--- a/app/events/projects/project_deleted_event.rb
+++ b/app/events/projects/project_deleted_event.rb
@@ -7,7 +7,8 @@ module Projects
'type' => 'object',
'properties' => {
'project_id' => { 'type' => 'integer' },
- 'namespace_id' => { 'type' => 'integer' }
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' }
},
'required' => %w[project_id namespace_id]
}
diff --git a/app/events/projects/project_path_changed_event.rb b/app/events/projects/project_path_changed_event.rb
new file mode 100644
index 00000000000..965f9258d3f
--- /dev/null
+++ b/app/events/projects/project_path_changed_event.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectPathChangedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'namespace_id' => { 'type' => 'integer' },
+ 'root_namespace_id' => { 'type' => 'integer' },
+ 'old_path' => { 'type' => 'string' },
+ 'new_path' => { 'type' => 'string' }
+ },
+ 'required' => %w[project_id namespace_id root_namespace_id old_path new_path]
+ }
+ end
+ end
+end
diff --git a/app/experiments/security_actions_continuous_onboarding_experiment.rb b/app/experiments/security_actions_continuous_onboarding_experiment.rb
new file mode 100644
index 00000000000..6adfbedc744
--- /dev/null
+++ b/app/experiments/security_actions_continuous_onboarding_experiment.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class SecurityActionsContinuousOnboardingExperiment < ApplicationExperiment
+ def control_behavior
+ end
+
+ def candidate_behavior
+ end
+end
diff --git a/app/finders/ci/auth_job_finder.rb b/app/finders/ci/auth_job_finder.rb
index 2dbdcb3c472..360afe5a7ab 100644
--- a/app/finders/ci/auth_job_finder.rb
+++ b/app/finders/ci/auth_job_finder.rb
@@ -29,7 +29,7 @@ module Ci
private
- attr_reader :token, :require_running, :raise_on_missing
+ attr_reader :token
def find_job_by_token
::Ci::Build.find_by_token(token)
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 356915722fe..4f9244d9825 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -17,6 +17,7 @@ module Ci
search!
filter_by_active!
filter_by_status!
+ filter_by_upgrade_status!
filter_by_runner_type!
filter_by_tag_list!
sort!
@@ -67,6 +68,13 @@ module Ci
filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
end
+ def filter_by_upgrade_status!
+ return unless @params.key?(:upgrade_status)
+ return unless Ci::RunnerVersion.statuses.key?(@params[:upgrade_status])
+
+ @runners = @runners.with_upgrade_status(@params[:upgrade_status])
+ end
+
def filter_by_runner_type!
filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
end
diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb
index d0b1240157c..14277db3f85 100644
--- a/app/finders/clusters/agents_finder.rb
+++ b/app/finders/clusters/agents_finder.rb
@@ -4,8 +4,8 @@ module Clusters
class AgentsFinder
include FinderMethods
- def initialize(project, current_user, params: {})
- @project = project
+ def initialize(object, current_user, params: {})
+ @object = object
@current_user = current_user
@params = params
end
@@ -13,18 +13,25 @@ module Clusters
def execute
return ::Clusters::Agent.none unless can_read_cluster_agents?
- agents = project.cluster_agents
- agents = agents.with_name(params[:name]) if params[:name].present?
+ agents = filter_clusters(object.cluster_agents)
agents.ordered_by_name
end
private
- attr_reader :project, :current_user, :params
+ attr_reader :object, :current_user, :params
+
+ def filter_clusters(agents)
+ agents = agents.with_name(params[:name]) if params[:name].present?
+
+ agents
+ end
def can_read_cluster_agents?
- current_user.can?(:read_cluster, project)
+ current_user&.can?(:read_cluster, object)
end
end
end
+
+Clusters::AgentsFinder.prepend_mod_with('Clusters::AgentsFinder')
diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb
index a351d30229e..eccc7d3f2bb 100644
--- a/app/finders/contributed_projects_finder.rb
+++ b/app/finders/contributed_projects_finder.rb
@@ -11,12 +11,15 @@ class ContributedProjectsFinder < UnionFinder
# current_user - When given the list of the projects is limited to those only
# visible by this user.
#
+ # ignore_visibility - When true the list of projects will include all contributed
+ # projects, regardless of their visibility to the current_user
+ #
# Returns an ActiveRecord::Relation.
- def execute(current_user = nil)
+ def execute(current_user = nil, ignore_visibility: false)
# Do not show contributed projects if the user profile is private.
return Project.none unless can_read_profile?(current_user)
- segments = all_projects(current_user)
+ segments = all_projects(current_user, ignore_visibility)
find_union(segments, Project).with_namespace.order_id_desc
end
@@ -27,7 +30,9 @@ class ContributedProjectsFinder < UnionFinder
Ability.allowed?(current_user, :read_user_profile, @user)
end
- def all_projects(current_user)
+ def all_projects(current_user, ignore_visibility)
+ return [@user.contributed_projects] if ignore_visibility
+
projects = []
projects << @user.contributed_projects.visible_to_user(current_user) if current_user
diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb
index f4aed413867..90367638dcf 100644
--- a/app/finders/groups/user_groups_finder.rb
+++ b/app/finders/groups/user_groups_finder.rb
@@ -35,7 +35,7 @@ module Groups
attr_reader :current_user, :target_user, :params
def sort(items)
- items.order(path: :asc, id: :asc) # rubocop: disable CodeReuse/ActiveRecord
+ items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord
end
def by_search(items)
@@ -47,6 +47,8 @@ module Groups
def by_permission_scope
if permission_scope_create_projects?
target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ elsif permission_scope_transfer_projects?
+ target_user.manageable_groups(include_groups_with_developer_maintainer_access: false)
else
target_user.groups
end
@@ -55,5 +57,9 @@ module Groups
def permission_scope_create_projects?
params[:permission_scope] == :create_projects
end
+
+ def permission_scope_transfer_projects?
+ params[:permission_scope] == :transfer_projects
+ end
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 6bbbc237e62..8ecf0c158e0 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -316,7 +316,12 @@ class IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def by_project(items)
- if params.project? || params.projects
+ # When finding issues for multiple projects it's more efficient
+ # to use a JOIN instead of running a sub-query
+ # See https://gitlab.com/gitlab-org/gitlab/-/commit/8591cc02be6b12ed60f763a5e0147f2cbbca99e1
+ if params.projects.is_a?(ActiveRecord::Relation)
+ items.merge(params.projects.reorder(nil)).join_project
+ elsif params.projects
items.of_projects(params.projects).references_project
else
items.none
@@ -431,7 +436,7 @@ class IssuableFinder
elsif not_params.filter_by_started_milestone?
items.joins(:milestone).merge(Milestone.not_started)
else
- items.without_particular_milestone(not_params[:milestone_title])
+ items.without_particular_milestones(not_params[:milestone_title])
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index f6db150c5d8..6b8dcd61d29 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -16,6 +16,7 @@
# visibility_level: int
# tag: string[] - deprecated, use 'topic' instead
# topic: string[]
+# topic_id: int
# personal: boolean
# search: string
# search_namespaces: boolean
@@ -81,6 +82,7 @@ class ProjectsFinder < UnionFinder
collection = by_trending(collection)
collection = by_visibility_level(collection)
collection = by_topics(collection)
+ collection = by_topic_id(collection)
collection = by_search(collection)
collection = by_archived(collection)
collection = by_custom_attributes(collection)
@@ -186,12 +188,21 @@ class ProjectsFinder < UnionFinder
topics = params[:topic].instance_of?(String) ? params[:topic].split(',') : params[:topic]
topics.map(&:strip).uniq.reject(&:empty?).each do |topic|
- items = items.with_topic(topic)
+ items = items.with_topic_by_name(topic)
end
items
end
+ def by_topic_id(items)
+ return items unless params[:topic_id].present?
+
+ topic = Projects::Topic.find_by(id: params[:topic_id]) # rubocop: disable CodeReuse/ActiveRecord
+ return Project.none unless topic
+
+ items.with_topic(topic)
+ end
+
def by_search(items)
params[:search] ||= params[:name]
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index b1e12721712..bf20a2c2c3d 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -41,6 +41,7 @@
class SnippetsFinder < UnionFinder
include FinderMethods
include Gitlab::Utils::StrongMemoize
+ include CreatedAtFilter
attr_reader :current_user, :params
@@ -69,6 +70,7 @@ class SnippetsFinder < UnionFinder
items = init_collection
items = by_ids(items)
items = items.with_optional_visibility(visibility_from_scope)
+ items = by_created_at(items)
items.order_by(sort_param)
end
diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb
index 0f7bf893bb2..3e06dbb2e2c 100644
--- a/app/finders/user_recent_events_finder.rb
+++ b/app/finders/user_recent_events_finder.rb
@@ -47,24 +47,6 @@ class UserRecentEventsFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def execute_optimized_multi(users)
- Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
- scope: Event.reorder(id: :desc),
- array_scope: User.select(:id).where(id: users),
- # Event model has a default scope { reorder(nil) }
- # When a relation is rordered and used as a target when merging scope,
- # its order takes a precedence and _overwrites_ the original scope's order.
- # Thus we have to explicitly provide `reorder` for array_mapping_scope here.
- array_mapping_scope: -> (author_id_expression) { Event.where(Event.arel_table[:author_id].eq(author_id_expression)).reorder(id: :desc) },
- finder_query: -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
- )
- .execute
- .limit(limit)
- .offset(params[:offset] || 0)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
def execute_multi
users = []
@target_user.each do |user|
@@ -73,26 +55,18 @@ class UserRecentEventsFinder
return Event.none if users.empty?
- if Feature.enabled?(:optimized_followed_users_queries, current_user)
- array_data = {
- scope_ids: users,
- scope_model: User,
- mapping_column: :author_id
- }
- query_builder_params = event_filter.in_operator_query_builder_params(array_data)
-
- Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
- .new(**query_builder_params)
- .execute
- .limit(limit)
- .offset(params[:offset] || 0)
- else
- if event_filter.filter == EventFilter::ALL
- execute_optimized_multi(users)
- else
- event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
- end
- end
+ array_data = {
+ scope_ids: users,
+ scope_model: User,
+ mapping_column: :author_id
+ }
+ query_builder_params = event_filter.in_operator_query_builder_params(array_data)
+
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
+ .new(**query_builder_params)
+ .execute
+ .limit(limit)
+ .offset(params[:offset] || 0)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
index 6a91a097a17..cbe1cfb4099 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -15,6 +15,15 @@ module Mutations
argument :title, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :title)
+ argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType,
+ required: false,
+ description: 'Input for description widget.'
+ argument :weight_widget, ::Types::WorkItems::Widgets::WeightInputType,
+ required: false,
+ description: 'Input for weight widget.'
+ argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType,
+ required: false,
+ description: 'Input for hierarchy widget.'
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb
new file mode 100644
index 00000000000..445b2eb6441
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/work_items/widgetable.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ module Widgetable
+ extend ActiveSupport::Concern
+
+ def extract_widget_params!(work_item_type, attributes)
+ # Get the list of widgets for the work item's type to extract only the supported attributes
+ widget_keys = ::WorkItems::Type.available_widgets.map(&:api_symbol)
+ widget_params = attributes.extract!(*widget_keys)
+
+ not_supported_keys = widget_params.keys - work_item_type.widgets.map(&:api_symbol)
+ if not_supported_keys.present?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ "Following widget keys are not supported by #{work_item_type.name} type: #{not_supported_keys}"
+ end
+
+ # Cannot use prepare to use `.to_h` on each input due to
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865
+ widget_params.transform_values { |values| values.to_h }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/notes/create/diff_note.rb b/app/graphql/mutations/notes/create/diff_note.rb
index 019e7cb8623..7b8c06fd104 100644
--- a/app/graphql/mutations/notes/create/diff_note.rb
+++ b/app/graphql/mutations/notes/create/diff_note.rb
@@ -32,7 +32,8 @@ module Mutations
def create_note_params(noteable, args)
super(noteable, args).merge({
type: 'DiffNote',
- position: position(noteable, args)
+ position: position(noteable, args),
+ merge_request_diff_head_sha: args[:position][:head_sha]
})
end
diff --git a/app/graphql/mutations/pages/base.rb b/app/graphql/mutations/pages/base.rb
new file mode 100644
index 00000000000..5eb8ecdf0ba
--- /dev/null
+++ b/app/graphql/mutations/pages/base.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Pages
+ class Base < BaseMutation
+ include FindsProject
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project.'
+ end
+ end
+end
diff --git a/app/graphql/mutations/pages/mark_onboarding_complete.rb b/app/graphql/mutations/pages/mark_onboarding_complete.rb
new file mode 100644
index 00000000000..2f5ce5db54a
--- /dev/null
+++ b/app/graphql/mutations/pages/mark_onboarding_complete.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Pages
+ class MarkOnboardingComplete < Base
+ graphql_name 'PagesMarkOnboardingComplete'
+
+ field :onboarding_complete,
+ Boolean,
+ null: false,
+ description: "Indicates the new onboarding_complete state of the project's Pages metadata."
+
+ authorize :admin_project
+
+ def resolve(project_path:)
+ project = authorized_find!(project_path)
+
+ project.mark_pages_onboarding_complete
+
+ {
+ onboarding_complete: project.pages_metadatum.onboarding_complete,
+ errors: errors_on_object(project)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 2921a77b86d..96ac3f8a113 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -54,7 +54,7 @@ module Mutations
# Only when the user is not an api user and the operation was successful
if !api_user? && service_response.success?
- ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user)
+ ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user, project: project)
end
snippet = service_response.payload[:snippet]
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 2a2941c5328..39843a3714a 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -43,7 +43,7 @@ module Mutations
# Only when the user is not an api user and the operation was successful
if !api_user? && service_response.success?
- ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user)
+ ::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user, project: snippet.project)
end
snippet = service_response.payload[:snippet]
diff --git a/app/graphql/mutations/user_callouts/create.rb b/app/graphql/mutations/user_callouts/create.rb
index 1be99ea0ecd..7f372053e84 100644
--- a/app/graphql/mutations/user_callouts/create.rb
+++ b/app/graphql/mutations/user_callouts/create.rb
@@ -15,7 +15,7 @@ module Mutations
description: 'User callout dismissed.'
def resolve(feature_name:)
- callout = Users::DismissCalloutService.new(
+ callout = ::Users::DismissCalloutService.new(
container: nil, current_user: current_user, params: { feature_name: feature_name }
).execute
errors = errors_on_object(callout)
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 2ae26ed0e1a..350153eaf19 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -7,6 +7,7 @@ module Mutations
include Mutations::SpamProtection
include FindsProject
+ include Mutations::WorkItems::Widgetable
description "Creates a work item. Available only when feature flag `work_items` is enabled."
@@ -15,6 +16,9 @@ module Mutations
argument :description, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :description)
+ argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyCreateInputType,
+ required: false,
+ description: 'Input for hierarchy widget.'
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project the work item is associated with.'
@@ -36,10 +40,18 @@ module Mutations
return { errors: ['`work_items` feature flag disabled for this project'] }
end
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
+ type = ::WorkItems::Type.find(attributes[:work_item_type_id])
+ widget_params = extract_widget_params!(type, params)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
- create_result = ::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,
+ widget_params: widget_params
+ ).execute
check_spam_action_response!(create_result[:work_item]) if create_result[:work_item]
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index c495da00f41..5d8c574877a 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -9,6 +9,7 @@ module Mutations
include Mutations::SpamProtection
include Mutations::WorkItems::UpdateArguments
+ include Mutations::WorkItems::Widgetable
authorize :update_work_item
@@ -24,19 +25,21 @@ module Mutations
end
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+ widget_params = extract_widget_params!(work_item.work_item_type, attributes)
- ::WorkItems::UpdateService.new(
+ update_result = ::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
params: attributes,
+ widget_params: widget_params,
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)
+ work_item: (update_result[:work_item] if update_result[:status] == :success),
+ errors: Array.wrap(update_result[:message])
}
end
diff --git a/app/graphql/mutations/work_items/update_widgets.rb b/app/graphql/mutations/work_items/update_widgets.rb
index d19da0abaac..7037b7e5a2a 100644
--- a/app/graphql/mutations/work_items/update_widgets.rb
+++ b/app/graphql/mutations/work_items/update_widgets.rb
@@ -2,6 +2,7 @@
module Mutations
module WorkItems
+ # TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300
class UpdateWidgets < BaseMutation
graphql_name 'WorkItemUpdateWidgets'
description "Updates the attributes of a work item's widgets by global ID." \
diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
index 264878ccaa2..5f995eb958b 100644
--- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql
+++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
@@ -32,6 +32,10 @@ query getProjectContainerRepositories(
createdAt
expirationPolicyStartedAt
expirationPolicyCleanupStatus
+ project {
+ id
+ path
+ }
__typename
}
pageInfo {
@@ -67,6 +71,10 @@ query getProjectContainerRepositories(
createdAt
expirationPolicyStartedAt
expirationPolicyCleanupStatus
+ project {
+ id
+ path
+ }
__typename
}
pageInfo {
diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index bcb07ae3182..914be3a72c1 100644
--- a/app/graphql/queries/repository/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -4,43 +4,46 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
id
repository {
__typename
- tree(path: $path, ref: $ref) {
+ paginatedTree(path: $path, ref: $ref) {
__typename
- lastCommit {
+ nodes {
__typename
- id
- sha
- title
- titleHtml
- descriptionHtml
- message
- webPath
- authoredDate
- authorName
- authorGravatar
- author {
+ lastCommit {
__typename
id
- name
- avatarUrl
+ sha
+ title
+ titleHtml
+ descriptionHtml
+ message
webPath
- }
- signatureHtml
- pipelines(ref: $ref, first: 1) {
- __typename
- edges {
+ authoredDate
+ authorName
+ authorGravatar
+ author {
+ __typename
+ id
+ name
+ avatarUrl
+ webPath
+ }
+ signatureHtml
+ pipelines(ref: $ref, first: 1) {
__typename
- node {
+ edges {
__typename
- id
- detailedStatus {
+ node {
__typename
id
- detailsPath
- icon
- tooltip
- text
- group
+ detailedStatus {
+ __typename
+ id
+ detailsPath
+ icon
+ tooltip
+ text
+ group
+ }
}
}
}
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index df138a15538..91f29948ad0 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -15,9 +15,15 @@ module Resolvers
required: false,
description: 'Filter jobs by status.'
- def resolve(statuses: nil, security_report_types: [])
+ argument :retried, ::GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter jobs by retry-status.'
+
+ def resolve(statuses: nil, security_report_types: [], retried: nil)
jobs = init_collection(security_report_types)
jobs = jobs.with_status(statuses) if statuses.present?
+ jobs = jobs.retried if retried
+ jobs = jobs.latest if retried == false
jobs
end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index e221dfea4d0..64738608b60 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -36,6 +36,10 @@ module Resolvers
required: false,
description: 'Sort order of results.'
+ argument :upgrade_status, ::Types::Ci::RunnerUpgradeStatusTypeEnum,
+ required: false,
+ description: 'Filter by upgrade status.'
+
def resolve_with_lookahead(**args)
apply_lookahead(
::Ci::RunnersFinder
@@ -54,6 +58,7 @@ module Resolvers
status_status: params[:status]&.to_s,
type_type: params[:type],
tag_name: params[:tag_list],
+ upgrade_status: params[:upgrade_status],
search: params[:search],
sort: params[:sort]&.to_s,
preload: {
diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb
index 5d61d9e986b..f758e217b47 100644
--- a/app/graphql/resolvers/ci/test_suite_resolver.rb
+++ b/app/graphql/resolvers/ci/test_suite_resolver.rb
@@ -28,7 +28,7 @@ module Resolvers
def load_test_suite_data(builds)
suite = builds.sum do |build|
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
end
Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load!
diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb
index 28618bef807..0b9eb361dbd 100644
--- a/app/graphql/resolvers/clusters/agents_resolver.rb
+++ b/app/graphql/resolvers/clusters/agents_resolver.rb
@@ -15,12 +15,10 @@ module Resolvers
description: 'Name of the cluster agent.'
end
- alias_method :project, :object
-
def resolve_with_lookahead(**args)
apply_lookahead(
::Clusters::AgentsFinder
- .new(project, current_user, params: args)
+ .new(object, current_user, params: args)
.execute
)
end
@@ -36,3 +34,5 @@ module Resolvers
end
end
end
+
+Resolvers::Clusters::AgentsResolver.prepend_mod_with('Resolvers::Clusters::AgentsResolver')
diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb
index f0be1b6e9a5..0653cd27b4d 100644
--- a/app/graphql/resolvers/todo_resolver.rb
+++ b/app/graphql/resolvers/todo_resolver.rb
@@ -2,68 +2,16 @@
module Resolvers
class TodoResolver < BaseResolver
- type Types::TodoType.connection_type, null: true
+ description 'Retrieve a single to-do item'
- alias_method :target, :object
+ type Types::TodoType, null: true
- argument :action, [Types::TodoActionEnum],
- required: false,
- description: 'Action to be filtered.'
+ argument :id, Types::GlobalIDType[Todo],
+ required: true,
+ description: 'ID of the to-do item.'
- argument :author_id, [GraphQL::Types::ID],
- required: false,
- description: 'ID of an author.'
-
- argument :project_id, [GraphQL::Types::ID],
- required: false,
- description: 'ID of a project.'
-
- argument :group_id, [GraphQL::Types::ID],
- required: false,
- description: 'ID of a group.'
-
- argument :state, [Types::TodoStateEnum],
- required: false,
- description: 'State of the todo.'
-
- argument :type, [Types::TodoTargetEnum],
- required: false,
- description: 'Type of the todo.'
-
- before_connection_authorization do |nodes, current_user|
- Preloaders::UserMaxAccessLevelInProjectsPreloader.new(
- nodes.map(&:project).compact,
- current_user
- ).execute
- end
-
- def resolve(**args)
- return Todo.none unless current_user.present? && target.present?
- return Todo.none if target.is_a?(User) && target != current_user
-
- TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations
- end
-
- private
-
- def todo_finder_params(args)
- {
- state: args[:state],
- type: args[:type],
- group_id: args[:group_id],
- author_id: args[:author_id],
- action_id: args[:action],
- project_id: args[:project_id]
- }.merge(target_params)
- end
-
- def target_params
- return {} unless TodosFinder::TODO_TYPES.include?(target.class.name)
-
- {
- type: target.class.name,
- target_id: target.id
- }
+ def resolve(id:)
+ GitlabSchema.find_by_gid(id)
end
end
end
diff --git a/app/graphql/resolvers/todos_resolver.rb b/app/graphql/resolvers/todos_resolver.rb
new file mode 100644
index 00000000000..3e8dddb4859
--- /dev/null
+++ b/app/graphql/resolvers/todos_resolver.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class TodosResolver < BaseResolver
+ type Types::TodoType.connection_type, null: true
+
+ alias_method :target, :object
+
+ argument :action, [Types::TodoActionEnum],
+ required: false,
+ description: 'Action to be filtered.'
+
+ argument :author_id, [GraphQL::Types::ID],
+ required: false,
+ description: 'ID of an author.'
+
+ argument :project_id, [GraphQL::Types::ID],
+ required: false,
+ description: 'ID of a project.'
+
+ argument :group_id, [GraphQL::Types::ID],
+ required: false,
+ description: 'ID of a group.'
+
+ argument :state, [Types::TodoStateEnum],
+ required: false,
+ description: 'State of the todo.'
+
+ argument :type, [Types::TodoTargetEnum],
+ required: false,
+ description: 'Type of the todo.'
+
+ before_connection_authorization do |nodes, current_user|
+ Preloaders::UserMaxAccessLevelInProjectsPreloader.new(
+ nodes.map(&:project).compact,
+ current_user
+ ).execute
+ end
+
+ def resolve(**args)
+ return Todo.none unless current_user.present? && target.present?
+ return Todo.none if target.is_a?(User) && target != current_user
+
+ TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations
+ end
+
+ private
+
+ def todo_finder_params(args)
+ {
+ state: args[:state],
+ type: args[:type],
+ group_id: args[:group_id],
+ author_id: args[:author_id],
+ action_id: args[:action],
+ project_id: args[:project_id]
+ }.merge(target_params)
+ end
+
+ def target_params
+ return {} unless TodosFinder::TODO_TYPES.include?(target.class.name)
+
+ {
+ type: target.class.name,
+ target_id: target.id
+ }
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 43b7bbb419f..a0d19229d3d 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -116,7 +116,7 @@ module Types
null: true,
description: 'Runbook for the alert as defined in alert details.'
- field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver
+ field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodosResolver
field :details_url,
GraphQL::Types::String,
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index b20a671179b..42b55f47f92 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -70,6 +70,8 @@ module Types
description: 'Downstream pipeline for a bridge.'
field :manual_job, GraphQL::Types::Boolean, null: true,
description: 'Whether the job has a manual action.'
+ field :manual_variables, VariableType.connection_type, null: true,
+ description: 'Variables added to a manual job when the job is triggered.'
field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
description: 'Indicates the job can be played.'
field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
@@ -78,6 +80,8 @@ module Types
description: 'Ref name of the job.'
field :ref_path, GraphQL::Types::String, null: true,
description: 'Path to the ref.'
+ field :retried, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates that the job has been retried.'
field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?,
description: 'Indicates the job can be retried.'
field :scheduling_type, GraphQL::Types::String, null: true,
@@ -188,6 +192,14 @@ module Types
def triggered
object.try(:trigger_request)
end
+
+ def manual_variables
+ if object.manual? && object.respond_to?(:job_variables)
+ object.job_variables
+ else
+ []
+ end
+ end
end
end
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 949e216a982..ac5ffd39407 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -159,12 +159,19 @@ module Types
owner_ids = runner_owner_ids_by_runner_id.values.flatten.uniq
owners = assoc_type.where(id: owner_ids).index_by(&:id)
+ # Preload projects namespaces to avoid N+1 queries when checking the `read_project` policy for each
+ preload_projects_namespaces(owners.values) if assoc_type == Project
+
runner_ids.each do |runner_id|
loader.call(runner_id, runner_owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || [])
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def preload_projects_namespaces(_projects)
+ # overridden in EE
+ end
end
end
end
diff --git a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
index 02feafe3df9..8e32eee5e6e 100644
--- a/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
+++ b/app/graphql/types/ci/runner_upgrade_status_type_enum.rb
@@ -5,10 +5,15 @@ module Types
class RunnerUpgradeStatusTypeEnum < BaseEnum
graphql_name 'CiRunnerUpgradeStatusType'
- value 'UNKNOWN', description: 'Upgrade status is unknown.', value: :unknown
+ ::Ci::RunnerVersion::STATUS_DESCRIPTIONS.each do |status, description|
+ status_name_src =
+ if status == :invalid_version
+ :invalid
+ else
+ status
+ end
- Gitlab::Ci::RunnerUpgradeCheck::STATUSES.each do |status, description|
- value status.to_s.upcase, description: description, value: status
+ value status_name_src.to_s.upcase, description: description, value: status
end
end
end
diff --git a/app/graphql/types/ci/variable_type.rb b/app/graphql/types/ci/variable_type.rb
new file mode 100644
index 00000000000..63f89b6d207
--- /dev/null
+++ b/app/graphql/types/ci/variable_type.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class VariableType < BaseObject
+ graphql_name 'CiVariable'
+
+ field :id, GraphQL::Types::ID, null: false,
+ description: 'ID of the variable.'
+
+ field :key, GraphQL::Types::String, null: true,
+ description: 'Name of the variable.'
+
+ field :value, GraphQL::Types::String, null: true,
+ description: 'Value of the variable.'
+
+ field :variable_type, ::Types::Ci::VariableTypeEnum, null: true,
+ description: 'Type of the variable.'
+
+ field :protected, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates whether the variable is protected.'
+
+ field :masked, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates whether the variable is masked.'
+
+ field :raw, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates whether the variable is raw.'
+
+ field :environment_scope, GraphQL::Types::String, null: true,
+ description: 'Scope defining the environments in which the variable can be used.'
+
+ def environment_scope
+ if object.respond_to?(:environment_scope)
+ object.environment_scope
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/variable_type_enum.rb b/app/graphql/types/ci/variable_type_enum.rb
new file mode 100644
index 00000000000..44430754a2e
--- /dev/null
+++ b/app/graphql/types/ci/variable_type_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class VariableTypeEnum < BaseEnum
+ graphql_name 'CiVariableType'
+
+ ::Ci::Variable.variable_types.keys.each do |variable_type|
+ value variable_type.upcase, value: variable_type, description: "#{variable_type.humanize} type."
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 49971d52a30..52e9f808066 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -194,6 +194,13 @@ module Types
complexity: 5,
resolver: Resolvers::GroupsResolver
+ field :ci_variables,
+ Types::Ci::VariableType.connection_type,
+ null: true,
+ description: "List of the group's CI/CD variables.",
+ authorize: :admin_group,
+ method: :variables
+
field :runners, Types::Ci::RunnerType.connection_type,
null: true,
resolver: Resolvers::Ci::GroupRunnersResolver,
diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb
index b18c8b90e96..bc21b802179 100644
--- a/app/graphql/types/issue_type_enum.rb
+++ b/app/graphql/types/issue_type_enum.rb
@@ -8,5 +8,9 @@ module Types
::WorkItems::Type.allowed_types_for_issues.each do |issue_type|
value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type"
end
+
+ value 'TASK', value: 'task',
+ description: 'Task issue type. Available only when feature flag `work_items` is enabled.',
+ deprecated: { milestone: '15.2', reason: :alpha }
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 8642957af02..46ab3f3f432 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -148,6 +148,7 @@ module Types
mount_mutation Mutations::WorkItems::UpdateTask, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update
+ mount_mutation Mutations::Pages::MarkOnboardingComplete
mount_mutation Mutations::SavedReplies::Destroy
end
end
diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb
index cc4f5e9f1f0..8b0fee8898c 100644
--- a/app/graphql/types/permission_types/group_enum.rb
+++ b/app/graphql/types/permission_types/group_enum.rb
@@ -7,6 +7,8 @@ module Types
description 'User permission on groups'
value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.'
+ value 'TRANSFER_PROJECTS', value: :transfer_projects,
+ description: 'Groups where the user can transfer projects to.'
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 603d5ead540..7e3800c6a13 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -70,10 +70,10 @@ module Types
description: 'Indicates if shared runners are enabled for the project.'
field :service_desk_enabled, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the project has service desk enabled.'
+ description: 'Indicates if the project has Service Desk enabled.'
field :service_desk_address, GraphQL::Types::String, null: true,
- description: 'E-mail address of the service desk.'
+ description: 'E-mail address of the Service Desk.'
field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true,
description: 'URL to avatar image file of the project.'
@@ -220,6 +220,13 @@ module Types
description: 'Build pipeline counts of the project.',
resolver: Resolvers::Ci::ProjectPipelineCountsResolver
+ field :ci_variables,
+ Types::Ci::VariableType.connection_type,
+ null: true,
+ description: "List of the project's CI/CD variables.",
+ authorize: :admin_build,
+ method: :variables
+
field :ci_cd_settings,
Types::Ci::CiCdSettingType,
null: true,
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 46d121f6552..9207a867639 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -123,6 +123,11 @@ module Types
resolver: Resolvers::Ci::RunnersResolver,
description: "Find runners visible to the current user."
+ field :ci_variables,
+ Types::Ci::VariableType.connection_type,
+ null: true,
+ description: "List of the instance's CI/CD variables."
+
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
field :timelogs, Types::TimelogType.connection_type,
@@ -136,6 +141,10 @@ module Types
null: true,
resolver: Resolvers::BoardListResolver
+ field :todo,
+ null: true,
+ resolver: Resolvers::TodoResolver
+
field :topics, Types::Projects::TopicType.connection_type,
null: true,
resolver: Resolvers::TopicsResolver,
@@ -174,6 +183,12 @@ module Types
application_settings
end
+ def ci_variables
+ return unless current_user.can_admin_all_resources?
+
+ ::Ci::InstanceVariable.all
+ end
+
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 43dc0c4ce85..d906c577aa5 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -40,6 +40,8 @@ module Types
authorize: :download_code
field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?,
description: 'Indicates the release is an upcoming release.'
+ field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?,
+ description: 'Indicates the release is an historical release.'
field :author, Types::UserType, null: true,
description: 'User that created the release.'
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 1c8a1352c72..edbc8aee9c5 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -88,7 +88,7 @@ module Types
null: true,
description: 'Personal namespace of the user.'
- field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.'
+ field :todos, resolver: Resolvers::TodosResolver, description: 'To-do items of the user.'
# Merge request field: MRs can be authored, assigned, or assigned-for-review:
field :authored_merge_requests,
diff --git a/app/graphql/types/work_item_id_type.rb b/app/graphql/types/work_item_id_type.rb
index ddcf3416014..bb01f865414 100644
--- a/app/graphql/types/work_item_id_type.rb
+++ b/app/graphql/types/work_item_id_type.rb
@@ -27,6 +27,7 @@ module Types
def coerce_input(string, ctx)
gid = super
+ return if gid.nil?
# Always return a WorkItemID even if an Issue Global ID is provided as input
return work_item_gid(gid) if suitable?(gid)
diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb
index f3cf1d74829..1b752393296 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -16,13 +16,19 @@ module Types
::Types::WorkItems::Widgets::DescriptionType
when ::WorkItems::Widgets::Hierarchy
::Types::WorkItems::Widgets::HierarchyType
+ when ::WorkItems::Widgets::Assignees
+ ::Types::WorkItems::Widgets::AssigneesType
+ when ::WorkItems::Widgets::Weight
+ ::Types::WorkItems::Widgets::WeightType
else
raise "Unknown GraphQL type for widget #{object}"
end
end
orphan_types ::Types::WorkItems::Widgets::DescriptionType,
- ::Types::WorkItems::Widgets::HierarchyType
+ ::Types::WorkItems::Widgets::HierarchyType,
+ ::Types::WorkItems::Widgets::AssigneesType,
+ ::Types::WorkItems::Widgets::WeightType
end
end
end
diff --git a/app/graphql/types/work_items/widgets/assignees_type.rb b/app/graphql/types/work_items/widgets/assignees_type.rb
new file mode 100644
index 00000000000..08ee06fdfa0
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/assignees_type.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ # Disabling widget level authorization as it might be too granular
+ # and we already authorize the parent work item
+ # rubocop:disable Graphql/AuthorizeTypes
+ class AssigneesType < BaseObject
+ graphql_name 'WorkItemWidgetAssignees'
+ description 'Represents an assignees widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :assignees, Types::UserType.connection_type, null: true,
+ description: 'Assignees of the work item.'
+
+ field :allows_multiple_assignees, GraphQL::Types::Boolean, null: true, method: :allows_multiple_assignees?,
+ description: 'Indicates whether multiple assignees are allowed.'
+
+ field :can_invite_members, GraphQL::Types::Boolean, null: false, resolver_method: :can_invite_members?,
+ description: 'Indicates whether the current user can invite members to the work item\'s project.'
+
+ def can_invite_members?
+ Ability.allowed?(current_user, :admin_project_member, object.work_item.project)
+ end
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb b/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb
new file mode 100644
index 00000000000..cee6d69cd0c
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/hierarchy_create_input_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class HierarchyCreateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetHierarchyCreateInput'
+
+ argument :parent_id, ::Types::GlobalIDType[::WorkItem],
+ required: false,
+ loads: ::Types::WorkItemType,
+ description: 'Global ID of the parent work item.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
new file mode 100644
index 00000000000..e1a9ebb76e9
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class HierarchyUpdateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetHierarchyUpdateInput'
+
+ argument :parent_id, ::Types::GlobalIDType[::WorkItem],
+ required: false,
+ loads: ::Types::WorkItemType,
+ description: 'Global ID of the parent work item. Use `null` to remove the association.'
+
+ argument :children_ids, [::Types::GlobalIDType[::WorkItem]],
+ required: false,
+ description: 'Global IDs of children work items.',
+ loads: ::Types::WorkItemType,
+ as: :children
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/weight_input_type.rb b/app/graphql/types/work_items/widgets/weight_input_type.rb
new file mode 100644
index 00000000000..a01c63222a5
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/weight_input_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class WeightInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetWeightInput'
+
+ argument :weight, GraphQL::Types::Int,
+ required: true,
+ description: 'Weight of the work item.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/weight_type.rb b/app/graphql/types/work_items/widgets/weight_type.rb
new file mode 100644
index 00000000000..c8eaf560268
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/weight_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ # Disabling widget level authorization as it might be too granular
+ # and we already authorize the parent work item
+ # rubocop:disable Graphql/AuthorizeTypes
+ class WeightType < BaseObject
+ graphql_name 'WorkItemWidgetWeight'
+ description 'Represents a weight widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :weight, GraphQL::Types::Int, null: true,
+ description: 'Weight of the work item.'
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index cd31d2c75ab..321a6e9395e 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -238,6 +238,8 @@ module ApplicationSettingsHelper
:email_author_in_body,
:enabled_git_access_protocol,
:enforce_terms,
+ :error_tracking_enabled,
+ :error_tracking_api_url,
:external_pipeline_validation_service_timeout,
:external_pipeline_validation_service_token,
:external_pipeline_validation_service_url,
@@ -402,6 +404,7 @@ module ApplicationSettingsHelper
:wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout,
:rate_limiting_response_text,
+ :package_registry_cleanup_policies_worker_capacity,
:container_registry_expiration_policies_worker_capacity,
:container_registry_cleanup_tags_service_max_list_size,
:container_registry_import_max_tags_count,
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index bb3f7b5aa79..d044a93213a 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -18,6 +18,7 @@ module Ci
"ci-config-path": project.ci_config_path_or_default,
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
+ "ci-lint-path" => project_ci_lint_path(project),
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
"initial-branch-name" => initial_branch,
@@ -32,6 +33,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'),
+ "simulate-pipeline-help-page-path" => help_page_path('ci/lint', anchor: 'simulate-a-pipeline'),
"total-branches" => total_branches,
"validate-tab-illustration-path" => image_path('illustrations/project-run-CICD-pipelines-sm.svg'),
"yml-help-page-path" => help_page_path('ci/yaml/index')
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 3c3179f6fbe..33b771eef69 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -184,7 +184,7 @@ module CommitsHelper
def diff_mode_swap_button(mode, file_hash)
icon = mode == 'raw' ? 'doc-code' : 'doc-text'
- entity = mode == 'raw' ? 'toHideBtn' : 'toShowBtn'
+ entity = mode == 'raw' ? 'rawButton' : 'renderedButton'
title = "Display #{mode} diff"
link_to("##{mode}-diff-#{file_hash}",
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 71c8296ad2e..457502347ee 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -33,6 +33,7 @@ module DiffHelper
if action_name == 'diff_for_path'
options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
+ options[:use_extra_viewer_as_main] = false
end
options
@@ -227,6 +228,7 @@ module DiffHelper
def conflicts(allow_tree_conflicts: false)
return unless options[:merge_ref_head_diff]
+ return unless merge_request.cannot_be_merged?
conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request, allow_tree_conflicts: allow_tree_conflicts) # rubocop:disable CodeReuse/ServiceClass
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index c23d905a008..54733fa9101 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -116,19 +116,16 @@ module EmailsHelper
end
end
- # "You are receiving this email because #{reason} on #{gitlab_host}."
- def notification_reason_text(reason)
- gitlab_host = Gitlab.config.gitlab.host
-
- case reason
- when NotificationReason::OWN_ACTIVITY
- _("You're receiving this email because of your activity on %{host}.") % { host: gitlab_host }
- when NotificationReason::ASSIGNED
- _("You're receiving this email because you have been assigned an item on %{host}.") % { host: gitlab_host }
- when NotificationReason::MENTIONED
- _("You're receiving this email because you have been mentioned on %{host}.") % { host: gitlab_host }
+ # "You are receiving this email because ... on #{host}. ..."
+ def notification_reason_text(reason: nil, show_manage_notifications_link: false, show_help_link: false, manage_label_subscriptions_url: nil, unsubscribe_url: nil, format: :text)
+ if unsubscribe_url && show_manage_notifications_link && show_help_link
+ notification_reason_text_with_unsubscribe_and_manage_notifications_and_help_links(reason: reason, unsubscribe_url: unsubscribe_url, format: format)
+ elsif !reason && manage_label_subscriptions_url && show_help_link
+ notification_reason_text_with_manage_label_subscriptions_and_help_links(manage_label_subscriptions_url: manage_label_subscriptions_url, format: format)
+ elsif show_manage_notifications_link && show_help_link
+ notification_reason_text_with_manage_notifications_and_help_links(reason: reason, format: format)
else
- _("You're receiving this email because of your account on %{host}.") % { host: gitlab_host }
+ notification_reason_text_without_links(reason: reason, format: format)
end
end
@@ -259,9 +256,7 @@ module EmailsHelper
end
def instance_access_request_text(user, format: nil)
- gitlab_host = Gitlab.config.gitlab.host
-
- _('%{username} has asked for a GitLab account on your instance %{host}:') % { username: sanitize_name(user.name), host: gitlab_host }
+ _('%{username} has asked for a GitLab account on your instance %{host}:').html_safe % { username: sanitize_name(user.name), host: gitlab_host_link(format) }
end
def instance_access_request_link(user, format: nil)
@@ -276,6 +271,14 @@ module EmailsHelper
end
end
+ def link_start(url)
+ '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: url }
+ end
+
+ def link_end
+ '</a>'.html_safe
+ end
+
def contact_your_administrator_text
_('Please contact your administrator with any questions.')
end
@@ -317,6 +320,75 @@ module EmailsHelper
def email_header_and_footer_enabled?
current_appearance&.email_header_and_footer_enabled?
end
+
+ def gitlab_host_link(format)
+ case format
+ when :html
+ generate_link(Gitlab.config.gitlab.host, Gitlab.config.gitlab.url)
+ when :text
+ Gitlab.config.gitlab.host
+ end
+ end
+
+ def notification_reason_text_with_unsubscribe_and_manage_notifications_and_help_links(reason:, unsubscribe_url:, format:)
+ unsubscribe_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: unsubscribe_url }
+ unsubscribe_link_end = '</a>'.html_safe
+
+ manage_notifications_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: profile_notifications_url }
+ manage_notifications_link_end = '</a>'.html_safe
+
+ help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url }
+ help_link_end = '</a>'.html_safe
+
+ case reason
+ when NotificationReason::OWN_ACTIVITY
+ _("You're receiving this email because of your activity on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread &middot; %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ when NotificationReason::ASSIGNED
+ _("You're receiving this email because you have been assigned an item on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread &middot; %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ when NotificationReason::MENTIONED
+ _("You're receiving this email because you have been mentioned on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread &middot; %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ else
+ _("You're receiving this email because of your account on %{host}. %{unsubscribe_link_start}Unsubscribe%{unsubscribe_link_end} from this thread &middot; %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), unsubscribe_link_start: unsubscribe_link_start, unsubscribe_link_end: unsubscribe_link_end, manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ end
+ end
+
+ def notification_reason_text_with_manage_label_subscriptions_and_help_links(manage_label_subscriptions_url:, format:)
+ manage_label_subscriptions_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: manage_label_subscriptions_url }
+ manage_label_subscriptions_link_end = '</a>'.html_safe
+
+ help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url }
+ help_link_end = '</a>'.html_safe
+
+ _("You're receiving this email because of your account on %{host}. %{manage_label_subscriptions_link_start}Manage label subscriptions%{manage_label_subscriptions_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_label_subscriptions_link_start: manage_label_subscriptions_link_start, manage_label_subscriptions_link_end: manage_label_subscriptions_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ end
+
+ def notification_reason_text_with_manage_notifications_and_help_links(reason:, format:)
+ manage_notifications_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="mng-notif-link">'.html_safe % { url: profile_notifications_url }
+ manage_notifications_link_end = '</a>'.html_safe
+
+ help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="help-link">'.html_safe % { url: help_url }
+ help_link_end = '</a>'.html_safe
+
+ case reason
+ when NotificationReason::MENTIONED
+ _("You're receiving this email because you have been mentioned on %{host}. %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ else
+ _("You're receiving this email because of your account on %{host}. %{manage_notifications_link_start}Manage all notifications%{manage_notifications_link_end} &middot; %{help_link_start}Help%{help_link_end}").html_safe % { host: gitlab_host_link(format), manage_notifications_link_start: manage_notifications_link_start, manage_notifications_link_end: manage_notifications_link_end, help_link_start: help_link_start, help_link_end: help_link_end }
+ end
+ end
+
+ def notification_reason_text_without_links(reason:, format:)
+ case reason
+ when NotificationReason::OWN_ACTIVITY
+ _("You're receiving this email because of your activity on %{host}.").html_safe % { host: gitlab_host_link(format) }
+ when NotificationReason::ASSIGNED
+ _("You're receiving this email because you have been assigned an item on %{host}.").html_safe % { host: gitlab_host_link(format) }
+ when NotificationReason::MENTIONED
+ _("You're receiving this email because you have been mentioned on %{host}.").html_safe % { host: gitlab_host_link(format) }
+ else
+ _("You're receiving this email because of your account on %{host}.").html_safe % { host: gitlab_host_link(format) }
+ end
+ end
end
EmailsHelper.prepend_mod_with('EmailsHelper')
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 59d43c51db2..2623e32dbc8 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -36,7 +36,6 @@ module EnvironmentsHelper
"environment_name": environment.name,
"environments_path": api_v4_projects_environments_path(id: project.id),
"environment_id": environment.id,
- "cluster_applications_documentation_path" => help_page_path('user/clusters/integrations.md', anchor: 'elastic-stack-cluster-integration'),
"clusters_path": project_clusters_path(project, format: :json)
}
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 37b23345d2a..2021961772a 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -9,7 +9,7 @@ module Groups::GroupMembersHelper
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
- def group_members_app_data(group, members:, invited:, access_requests:, include_relations:, search:)
+ def group_members_app_data(group, members:, invited:, access_requests:, banned:, include_relations:, search:)
{
user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }),
group: group_group_links_list_data(group, include_relations, search),
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 9ea9509bc28..9d152416b2e 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -126,7 +126,7 @@ module GroupsHelper
group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group)
end
- def show_thanks_for_purchase_banner?
+ def show_thanks_for_purchase_alert?
params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0
end
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 8d5523464c7..a1512d40235 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -216,7 +216,7 @@ module IntegrationsHelper
end
def fields_for_integration(integration)
- Integrations::FieldSerializer.new(integration: integration).represent(integration.global_fields).to_json
+ Integrations::FieldSerializer.new(integration: integration).represent(integration.form_fields).to_json
end
def integration_level(integration)
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index 890f7f099df..421cf84f98c 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -4,6 +4,7 @@ module LearnGitlabHelper
IMAGE_PATH_PLAN = "learn_gitlab/section_plan.svg"
IMAGE_PATH_DEPLOY = "learn_gitlab/section_deploy.svg"
IMAGE_PATH_WORKSPACE = "learn_gitlab/section_workspace.svg"
+ LICENSE_SCANNING_RUN_URL = 'https://docs.gitlab.com/ee/user/compliance/license_compliance/index.html'
def learn_gitlab_enabled?(project)
return false unless current_user
@@ -64,7 +65,7 @@ module LearnGitlabHelper
git_write: project_path(project),
merge_request_created: project_merge_requests_path(project),
user_added: project_members_url(project),
- security_scan_enabled: project_security_configuration_path(project)
+ **deploy_section_action_urls(project)
)
end
@@ -72,6 +73,23 @@ module LearnGitlabHelper
LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
end
+ def deploy_section_action_urls(project)
+ experiment(:security_actions_continuous_onboarding,
+ namespace: project.namespace,
+ user: current_user,
+ sticky_to: current_user
+ ) do |e|
+ e.control { { security_scan_enabled: project_security_configuration_path(project) } }
+ e.candidate do
+ {
+ license_scanning_run: LICENSE_SCANNING_RUN_URL,
+ secure_dependency_scanning_run: project_security_configuration_path(project, anchor: 'dependency-scanning'),
+ secure_dast_run: project_security_configuration_path(project, anchor: 'dast')
+ }
+ end
+ end.run
+ end
+
def learn_gitlab_project
@learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 777d485797f..6077a059f6f 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -8,8 +8,8 @@ module MarkupHelper
# Let's increase the render timeout
# For a smaller one, a test that renders the blob content statically fails
- # We can consider removing this custom timeout when refactor_blob_viewer FF is removed:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/324351
+ # We can consider removing this custom timeout when markup_rendering_timeout FF is removed:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/365358
RENDER_TIMEOUT = 5.seconds
def plain?(filename)
diff --git a/app/helpers/namespace_storage_limit_alert_helper.rb b/app/helpers/namespace_storage_limit_alert_helper.rb
deleted file mode 100644
index ed11f89a7dd..00000000000
--- a/app/helpers/namespace_storage_limit_alert_helper.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module NamespaceStorageLimitAlertHelper
- # Overridden in EE
- def display_namespace_storage_limit_alert!
- end
-end
-
-NamespaceStorageLimitAlertHelper.prepend_mod_with('NamespaceStorageLimitAlertHelper')
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index 469d6c1a7eb..fb8fafe59f3 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -16,7 +16,7 @@ module Nav
menu_sections.push(general_menu_section)
{
- title: _("Create new"),
+ title: _("Create new..."),
menu_sections: menu_sections.select { |x| x.fetch(:menu_items).any? }
}
end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 20d40626449..ec64746d6b6 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -53,4 +53,14 @@ module PackagesHelper
project.container_expiration_policy.nil? &&
project.container_repositories.exists?
end
+
+ def show_container_registry_settings(project)
+ Gitlab.config.registry.enabled &&
+ Ability.allowed?(current_user, :admin_container_image, project)
+ end
+
+ def show_package_registry_settings(project)
+ Gitlab.config.packages.enabled &&
+ Ability.allowed?(current_user, :admin_package, project)
+ end
end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index eeee8290914..3b3fe13e58a 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -4,7 +4,7 @@ module Projects
module PipelineHelper
extend ::Ci::BuildsHelper
- def js_pipeline_tabs_data(project, pipeline)
+ def js_pipeline_tabs_data(project, pipeline, _user)
{
can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json,
failed_jobs_count: pipeline.failed_builds.count,
diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb
index d5cc2b72ae9..51a7d3e35d0 100644
--- a/app/helpers/projects/project_members_helper.rb
+++ b/app/helpers/projects/project_members_helper.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
module Projects::ProjectMembersHelper
- def project_members_app_data_json(project, members:, group_links:, invited:, access_requests:)
+ def project_members_app_data_json(project, members:, invited:, access_requests:, include_relations:, search:)
{
user: project_members_list_data(project, members, { param_name: :page, params: { search_groups: nil } }),
- group: project_group_links_list_data(project, group_links),
+ group: project_group_links_list_data(project, include_relations, search),
invite: project_members_list_data(project, invited.nil? ? [] : invited),
access_request: project_members_list_data(project, access_requests.nil? ? [] : access_requests),
source_id: project.id,
@@ -57,10 +57,29 @@ module Projects::ProjectMembersHelper
}
end
- def project_group_links_list_data(project, group_links)
+ def project_group_links_list_data(project, include_relations, search)
+ members = []
+
+ if include_relations.include?(:direct)
+ project_group_links = project.project_group_links
+ project_group_links = project_group_links.search(search) if search
+ members += project_group_links_serialized(project, project_group_links)
+ end
+
+ if include_relations.include?(:inherited)
+ group_group_links = project.group_group_links.distinct_on_shared_with_group_id_with_group_access
+ group_group_links = group_group_links.search(search) if search
+ members += group_group_links_serialized(project, group_group_links)
+ end
+
+ if project_group_links.present? && group_group_links.present?
+ members = members.sort_by { |m| -m.dig(:access_level, :integer_value).to_i }
+ .uniq { |m| m.dig(:shared_with_group, :id) }
+ end
+
{
- members: project_group_links_serialized(project, group_links),
- pagination: members_pagination_data(group_links),
+ members: members,
+ pagination: members_pagination_data(members),
member_path: project_group_link_path(project, ':id')
}
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 95e91a7ba27..2ece3e87500 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -377,7 +377,7 @@ module ProjectsHelper
canDisableEmails: can_disable_emails?(project, current_user),
canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),
allowedVisibilityOptions: project_allowed_visibility_levels(project),
- visibilityHelpPath: help_page_path('public_access/public_access'),
+ visibilityHelpPath: help_page_path('user/public_access'),
registryAvailable: Gitlab.config.registry.enabled,
registryHelpPath: help_page_path('user/packages/container_registry/index'),
lfsAvailable: Gitlab.config.lfs.enabled,
@@ -388,7 +388,8 @@ module ProjectsHelper
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?,
pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control'),
- issuesHelpPath: help_page_path('user/project/issues/index')
+ issuesHelpPath: help_page_path('user/project/issues/index'),
+ membersPagePath: project_project_members_path(project)
}
end
@@ -684,7 +685,6 @@ module ProjectsHelper
product_analytics
metrics_dashboard
feature_flags
- tracings
terraform
]
end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index a516ac85131..50089c7edab 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -53,12 +53,14 @@ module ReleasesHelper
def data_for_edit_release_page
new_edit_pages_shared_data.merge(
tag_name: @release.tag,
- releases_page_path: project_releases_path(@project, anchor: @release.tag)
+ releases_page_path: project_releases_path(@project, anchor: @release.tag),
+ delete_release_docs_path: releases_help_page_path(anchor: 'delete-a-release')
)
end
def data_for_new_release_page
new_edit_pages_shared_data.merge(
+ tag_name: params[:tag_name],
default_branch: @project.default_branch,
releases_page_path: project_releases_path(@project)
)
@@ -81,7 +83,8 @@ module ReleasesHelper
release_assets_docs_path: releases_help_page_path(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project),
new_milestone_path: new_project_milestone_path(@project),
- edit_release_docs_path: releases_help_page_path(anchor: 'edit-a-release')
+ edit_release_docs_path: releases_help_page_path(anchor: 'edit-a-release'),
+ upcoming_release_docs_path: releases_help_page_path(anchor: 'upcoming-releases')
}
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index c8750cd9b52..ecbcaec27bc 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module SearchHelper
+ # params which should persist when a new tab is selected
SEARCH_GENERIC_PARAMS = [
:search,
:scope,
@@ -129,7 +130,7 @@ module SearchHelper
end
def search_service
- @search_service ||= ::SearchService.new(current_user, params.merge(confidential: Gitlab::Utils.to_boolean(params[:confidential])))
+ @search_service ||= ::SearchService.new(current_user, sanitized_search_params)
end
def search_sort_options
@@ -169,7 +170,7 @@ module SearchHelper
# 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] = { id: search_context.group.id, name: search_context.group.name, full_name: search_context.group.full_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?
@@ -207,10 +208,10 @@ module SearchHelper
{ category: "Help", label: _("API Help"), url: help_page_path("api/index") },
{ category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
{ category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
- { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
+ { category: "Help", label: _("Public Access Help"), url: help_page_path("user/public_access") },
{ category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/index") },
- { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/index") },
- { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("user/ssh") },
+ { category: "Help", label: _("System Hooks Help"), url: help_page_path("administration/system_hooks") },
{ category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }
]
end
@@ -481,6 +482,13 @@ module SearchHelper
def feature_flag_tab_enabled?(flag)
@group || Feature.enabled?(flag, current_user, type: :ops)
end
+
+ def sanitized_search_params
+ sanitized_params = params.dup
+ sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential]) if sanitized_params.key?(:confidential)
+
+ sanitized_params
+ end
end
SearchHelper.prepend_mod_with('SearchHelper')
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index f0389000eb3..129180d1ccf 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -39,4 +39,16 @@ module SessionsHelper
# 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32
request.env['rack.session.options'][:expire_after] = expiry_s
end
+
+ def send_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.peek(:email_verification_code_send, scope: user)
+ end
+
+ def obfuscated_email(email)
+ regex = ::Gitlab::UntrustedRegexp.new('^(..?)(.*)(@.?)(.*)(\..*)$')
+ match = regex.match(email)
+ return email unless match
+
+ match[1] + '*' * match[2].length + match[3] + '*' * match[4].length + match[5]
+ end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 6f15cc7f4ec..ef79e2bc86f 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -254,6 +254,7 @@ module SortingHelper
options = [
{ value: sort_value_priority, text: sort_title_priority, href: page_filter_path(sort: sort_value_priority) },
{ value: sort_value_created_date, text: sort_title_created_date, href: page_filter_path(sort: sort_value_created_date) },
+ { value: sort_value_closed_date, text: sort_title_closed_date, href: page_filter_path(sort: sort_value_closed_date) },
{ value: sort_value_recently_updated, text: sort_title_recently_updated, href: page_filter_path(sort: sort_value_recently_updated) },
{ value: sort_value_milestone, text: sort_title_milestone, href: page_filter_path(sort: sort_value_milestone) }
]
@@ -261,7 +262,7 @@ module SortingHelper
options.concat([due_date_option]) if viewing_issues
options.concat([popularity_option, label_priority_option])
- options.concat([merged_option, closed_option]) if viewing_merge_requests
+ options.concat([merged_option]) if viewing_merge_requests
options.concat([relative_position_option]) if viewing_issues
options.concat([title_option])
@@ -287,10 +288,6 @@ module SortingHelper
{ value: sort_value_merged_date, text: sort_title_merged_date, href: page_filter_path(sort: sort_value_merged_date) }
end
- def closed_option
- { value: sort_value_closed_date, text: sort_title_closed_date, href: page_filter_path(sort: sort_value_closed_date) }
- end
-
def relative_position_option
{ value: sort_value_relative_position, text: sort_title_relative_position, href: page_filter_path(sort: sort_value_relative_position) }
end
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index 38ae9b5b634..ca81d5af4af 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -27,10 +27,11 @@ module StorageHelper
def storage_enforcement_banner_info(namespace)
root_ancestor = namespace.root_ancestor
- return unless can?(current_user, :admin_namespace, root_ancestor)
+ return unless can?(current_user, :maintain_namespace, root_ancestor)
return if root_ancestor.paid?
return unless future_enforcement_date?(root_ancestor)
return if user_dismissed_storage_enforcement_banner?(root_ancestor)
+ return unless ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor)
{
text: html_escape_once(s_("UsageQuota|From %{storage_enforcement_date} storage limits will apply to this namespace. " \
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 8529959f73c..f87125af07d 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -66,7 +66,13 @@ module TodosHelper
return _('design') if todo.for_design?
return _('alert') if todo.for_alert?
- todo.target_type.titleize.downcase
+ target_type = if todo.for_issue_or_work_item?
+ todo.target.issue_type
+ else
+ todo.target_type
+ end
+
+ target_type.titleize.downcase
end
def todo_target_path(todo)
@@ -80,6 +86,9 @@ module TodosHelper
todos_design_path(todo, path_options)
elsif todo.for_alert?
details_project_alert_management_path(todo.project, todo.target)
+ elsif todo.for_issue_or_work_item?
+ path_options[:only_path] = true
+ Gitlab::UrlBuilder.build(todo.target, **path_options)
else
path = [todo.resource_parent, todo.target]
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 2fef4ae98a9..370dbb10462 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -58,14 +58,6 @@ module TreeHelper
"#{username}-#{ref}-patch-#{epoch}"
end
- def tree_edit_project(project = @project)
- if can?(current_user, :push_code, project)
- project
- elsif current_user && current_user.already_forked?(project)
- current_user.fork_of(project)
- end
- end
-
def edit_in_new_fork_notice_now
_("You're not allowed to make changes to this project directly. "\
"A fork of this project is being created that you can make changes in, so you can submit a merge request.")
@@ -111,16 +103,6 @@ module TreeHelper
end
end
- def up_dir_path
- file = File.join(@path, "..")
- tree_join(@ref, file)
- end
-
- # returns the relative path of the first subdir that doesn't have only one directory descendant
- def flatten_tree(root_path, tree)
- tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '')
- end
-
def selected_branch
@branch_name || tree_edit_branch
end
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index b8231b02ac1..3dd6b3f4a80 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -9,9 +9,9 @@ module Users
FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
- MINUTE_LIMIT_BANNER = 'minute_limit_banner'
SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze
+ WEB_HOOK_DISABLED = 'web_hook_disabled'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -61,16 +61,31 @@ module Users
!user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
end
- def minute_limit_banner_dismissed?
- user_dismissed?(MINUTE_LIMIT_BANNER)
+ def web_hook_disabled_dismissed?(project)
+ return false unless project
+
+ last_failure = Gitlab::Redis::SharedState.with do |redis|
+ key = "web_hooks:last_failure:project-#{project.id}"
+ redis.get(key)
+ end
+
+ last_failure = DateTime.parse(last_failure) if last_failure
+
+ user_dismissed?(WEB_HOOK_DISABLED, last_failure, namespace: project.namespace)
end
private
- def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
+ def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, namespace: nil)
return false unless current_user
- current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
+ query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than }
+
+ if namespace
+ current_user.dismissed_callout_for_namespace?(namespace: namespace, **query)
+ else
+ current_user.dismissed_callout?(**query)
+ end
end
end
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index e46aa6a446c..4ea2512bc67 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -15,7 +15,7 @@ module UsersHelper
end
def user_email_help_text(user)
- return 'We also use email for avatar detection if no avatar is uploaded' unless user.unconfirmed_email.present?
+ return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index b49bd33a30b..1baeeda0ef7 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -126,7 +126,7 @@ module VisibilityLevelHelper
def project_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- _("Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.")
+ _("Project access must be granted explicitly to each user. If this project is part of a group, access is granted to members of the group.")
when Gitlab::VisibilityLevel::INTERNAL
_("The project can be accessed by any logged in user except external users.")
when Gitlab::VisibilityLevel::PUBLIC
diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb
new file mode 100644
index 00000000000..95122750c2f
--- /dev/null
+++ b/app/helpers/web_hooks/web_hooks_helper.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module WebHooks
+ module WebHooksHelper
+ EXPIRY_TTL = 1.hour
+
+ def show_project_hook_failed_callout?(project:)
+ return false unless current_user
+ return false unless Feature.enabled?(:webhooks_failed_callout, project)
+ return false unless Feature.enabled?(:web_hooks_disable_failed, project)
+ return false unless Ability.allowed?(current_user, :read_web_hooks, project)
+
+ # Assumes include of Users::CalloutsHelper
+ return false if web_hook_disabled_dismissed?(project)
+
+ any_project_hook_failed?(project) # Most expensive query last
+ end
+
+ private
+
+ def any_project_hook_failed?(project)
+ Rails.cache.fetch("any_web_hook_failed:#{project.id}", expires_in: EXPIRY_TTL) do
+ ProjectHook.for_projects(project).disabled.exists?
+ end
+ end
+ end
+end
diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb
index f44dd448a35..9d02d4132a1 100644
--- a/app/mailers/emails/admin_notification.rb
+++ b/app/mailers/emails/admin_notification.rb
@@ -16,11 +16,16 @@ module Emails
mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
end
- def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:)
+ def user_auto_banned_email(admin_id, user_id, max_project_downloads:, within_seconds:, group: nil)
admin = User.find(admin_id)
@user = User.find(user_id)
@max_project_downloads = max_project_downloads
@within_minutes = within_seconds / 60
+ @ban_scope = if group.present?
+ _('your group (%{group_name})' % { group_name: group.name })
+ else
+ _('your GitLab instance')
+ end
Gitlab::I18n.with_locale(admin.preferred_language) do
email_with_layout(
diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb
new file mode 100644
index 00000000000..2fc8cae06fe
--- /dev/null
+++ b/app/mailers/emails/identity_verification.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Emails
+ module IdentityVerification
+ def verification_instructions_email(user_id, token:, expires_in:)
+ @token = token
+ @expires_in_minutes = expires_in
+ @password_link = edit_profile_password_url
+ @two_fa_link = help_page_url('user/profile/account/two_factor_authentication')
+
+ user = User.find(user_id)
+ email_with_layout(to: user.email, subject: s_('IdentityVerification|Verify your identity'))
+ end
+ end
+end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 83d37a365de..6a2b447f4a0 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -15,15 +15,15 @@ module Emails
end
# existing_commits - an array containing the first and last commits
- def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits: [], total_new_commits_count: nil, existing_commits: [], total_existing_commits_count: nil)
+ def push_to_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil, new_commits:, total_new_commits_count:, existing_commits:, total_existing_commits_count:)
setup_merge_request_mail(merge_request_id, recipient_id)
@new_commits = new_commits
- @total_new_commits_count = total_new_commits_count || @new_commits.length
+ @total_new_commits_count = total_new_commits_count
@total_stripped_new_commits_count = @total_new_commits_count - @new_commits.length
@existing_commits = existing_commits
- @total_existing_commits_count = total_existing_commits_count || @existing_commits.length
+ @total_existing_commits_count = total_existing_commits_count
@updated_by_user = User.find(updated_by_user_id)
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index b70ce1d3655..ed7681e595f 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -23,6 +23,7 @@ class Notify < ApplicationMailer
include Emails::ServiceDesk
include Emails::InProductMarketing
include Emails::AdminNotification
+ include Emails::IdentityVerification
helper TimeboxesHelper
helper MergeRequestsHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 61456ef79c8..be8d96012cc 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -205,10 +205,18 @@ class NotifyPreview < ActionMailer::Preview
Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message
end
- def user_auto_banned_email
+ def user_auto_banned_instance_email
::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message
end
+ def user_auto_banned_namespace_email
+ ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600, group: group).message
+ end
+
+ def verification_instructions_email
+ Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message
+ end
+
private
def project
@@ -239,6 +247,10 @@ class NotifyPreview < ActionMailer::Preview
@user ||= User.last
end
+ def group
+ @group ||= Group.last
+ end
+
def member
@member ||= Member.last
end
diff --git a/app/models/ability.rb b/app/models/ability.rb
index a185448d5ea..b15143c8c9c 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -26,6 +26,13 @@ class Ability
end
end
+ # A list of users that can read confidential notes in a project
+ def users_that_can_read_internal_notes(users, note_parent)
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :reporter_access, note_parent) }
+ end
+ end
+
# Returns an Array of Issues that can be read by the given user.
#
# issues - The issues to reduce down to those readable by the user.
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 6acdc02c799..17b46f929c3 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -28,6 +28,7 @@ class ApplicationSetting < ApplicationRecord
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required }
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :required
+ add_authentication_token_field :error_tracking_access_token, encrypted: :required
belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id'
belongs_to :push_rule
@@ -171,6 +172,11 @@ class ApplicationSetting < ApplicationRecord
validates :kroki_formats, json_schema: { filename: 'application_setting_kroki_formats' }
+ validates :metrics_method_call_threshold,
+ numericality: { greater_than_or_equal_to: 0 },
+ presence: true,
+ if: :prometheus_metrics_enabled
+
validates :plantuml_url,
presence: true,
if: :plantuml_enabled
@@ -393,6 +399,7 @@ class ApplicationSetting < ApplicationRecord
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :packages_cleanup_package_file_worker_capacity,
+ :package_registry_cleanup_policies_worker_capacity,
allow_nil: false,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
@@ -589,6 +596,14 @@ class ApplicationSetting < ApplicationRecord
presence: true, length: { maximum: 255 },
if: :sentry_enabled?
+ validates :error_tracking_enabled,
+ inclusion: { in: [true, false], message: _('must be a boolean value') }
+ validates :error_tracking_api_url,
+ presence: true,
+ addressable_url: true,
+ length: { maximum: 255 },
+ if: :error_tracking_enabled?
+
validates :users_get_by_id_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :users_get_by_id_limit_allowlist,
@@ -653,6 +668,7 @@ class ApplicationSetting < ApplicationRecord
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
+ before_save :ensure_error_tracking_access_token
after_commit do
reset_memoized_terms
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index a89ea05fb62..e9a0a156121 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -217,6 +217,7 @@ module ApplicationSettingImplementation
user_show_add_ssh_key_message: true,
valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES,
wiki_page_max_content_bytes: 50.megabytes,
+ package_registry_cleanup_policies_worker_capacity: 2,
container_registry_delete_tags_service_timeout: 250,
container_registry_expiration_policies_worker_capacity: 4,
container_registry_cleanup_tags_service_max_list_size: 200,
@@ -445,6 +446,10 @@ module ApplicationSettingImplementation
ensure_health_check_access_token!
end
+ def error_tracking_access_token
+ ensure_error_tracking_access_token!
+ end
+
def usage_ping_can_be_configured?
Settings.gitlab.usage_ping_enabled
end
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index 1e822629ba1..0ed197f32df 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -25,4 +25,9 @@ class AuthenticationEvent < ApplicationRecord
def self.providers
STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s)
end
+
+ def self.initial_login_or_known_ip_address?(user, ip_address)
+ !where(user_id: user).exists? ||
+ where(user_id: user, ip_address: ip_address).success.exists?
+ end
end
diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb
new file mode 100644
index 00000000000..a84a3454a27
--- /dev/null
+++ b/app/models/awareness_session.rb
@@ -0,0 +1,236 @@
+# frozen_string_literal: true
+
+# A Redis backed session store for real-time collaboration. A session is defined
+# by its documents and the users that join this session. An online user can have
+# two states within the session: "active" and "away".
+#
+# By design, session must eventually be cleaned up. If this doesn't happen
+# explicitly, all keys used within the session model must have an expiry
+# timestamp set.
+class AwarenessSession # rubocop:disable Gitlab/NamespacedClass
+ # An awareness session expires automatically after 1 hour of no activity
+ SESSION_LIFETIME = 1.hour
+ private_constant :SESSION_LIFETIME
+
+ # Expire user awareness keys after some time of inactivity
+ USER_LIFETIME = 1.hour
+ private_constant :USER_LIFETIME
+
+ PRESENCE_LIFETIME = 10.minutes
+ private_constant :PRESENCE_LIFETIME
+
+ KEY_NAMESPACE = "gitlab:awareness"
+ private_constant :KEY_NAMESPACE
+
+ class << self
+ def for(value = nil)
+ # Creates a unique value for situations where we have no unique value to
+ # create a session with. This could be when creating a new issue, a new
+ # merge request, etc.
+ value = SecureRandom.uuid unless value.present?
+
+ # We use SHA-256 based session identifiers (similar to abbreviated git
+ # hashes). There is always a chance for Hash collisions (birthday
+ # problem), we therefore have to pick a good tradeoff between the amount
+ # of data stored and the probability of a collision.
+ #
+ # The approximate probability for a collision can be calculated:
+ #
+ # p ~= n^2 / 2m
+ # ~= (2^18)^2 / (2 * 16^15)
+ # ~= 2^36 / 2^61
+ #
+ # n is the number of awareness sessions and m the number of possibilities
+ # for each item. For a hex number, this is 16^c, where c is the number of
+ # characters. With 260k (~2^18) sessions, the probability for a collision
+ # is ~2^-25.
+ #
+ # The number of 15 is selected carefully. The integer representation fits
+ # nicely into a signed 64 bit integer and eventually allows Redis to
+ # optimize its memory usage. 16 chars would exceed the space for
+ # this datatype.
+ id = Digest::SHA256.hexdigest(value.to_s)[0, 15]
+
+ AwarenessSession.new(id)
+ end
+ end
+
+ def initialize(id)
+ @id = id
+ end
+
+ def join(user)
+ user_key = user_sessions_key(user.id)
+
+ with_redis do |redis|
+ redis.pipelined do |pipeline|
+ pipeline.sadd(user_key, id_i)
+ pipeline.expire(user_key, USER_LIFETIME.to_i)
+
+ pipeline.zadd(users_key, timestamp.to_f, user.id)
+
+ # We also mark for expiry when a session key is created (first user joins),
+ # because some users might never actively leave a session and the key could
+ # therefore become stale, w/o us noticing.
+ reset_session_expiry(pipeline)
+ end
+ end
+
+ nil
+ end
+
+ def leave(user)
+ user_key = user_sessions_key(user.id)
+
+ with_redis do |redis|
+ redis.pipelined do |pipeline|
+ pipeline.srem(user_key, id_i)
+ pipeline.zrem(users_key, user.id)
+ end
+
+ # cleanup orphan sessions and users
+ #
+ # this needs to be a second pipeline due to the delete operations being
+ # dependent on the result of the cardinality checks
+ user_sessions_count, session_users_count = redis.pipelined do |pipeline|
+ pipeline.scard(user_key)
+ pipeline.zcard(users_key)
+ end
+
+ redis.pipelined do |pipeline|
+ pipeline.del(user_key) unless user_sessions_count > 0
+
+ unless session_users_count > 0
+ pipeline.del(users_key)
+ @id = nil
+ end
+ end
+ end
+
+ nil
+ end
+
+ def present?(user, threshold: PRESENCE_LIFETIME)
+ with_redis do |redis|
+ user_timestamp = redis.zscore(users_key, user.id)
+ break false unless user_timestamp.present?
+
+ timestamp - user_timestamp < threshold
+ end
+ end
+
+ def away?(user, threshold: PRESENCE_LIFETIME)
+ !present?(user, threshold: threshold)
+ end
+
+ # Updates the last_activity timestamp for a user in this session
+ def touch!(user)
+ with_redis do |redis|
+ redis.pipelined do |pipeline|
+ pipeline.zadd(users_key, timestamp.to_f, user.id)
+
+ # extend the session lifetime due to user activity
+ reset_session_expiry(pipeline)
+ end
+ end
+
+ nil
+ end
+
+ def size
+ with_redis do |redis|
+ redis.zcard(users_key)
+ end
+ end
+
+ def to_param
+ id&.to_s
+ end
+
+ def to_s
+ "awareness_session=#{id}"
+ end
+
+ def online_users_with_last_activity(threshold: PRESENCE_LIFETIME)
+ users_with_last_activity.filter do |_user, last_activity|
+ user_online?(last_activity, threshold: threshold)
+ end
+ end
+
+ def users
+ User.where(id: user_ids)
+ end
+
+ def users_with_last_activity
+ # where in (x, y, [...z]) is a set and does not maintain any order, we need
+ # to make sure to establish a stable order for both, the pairs returned from
+ # redis and the ActiveRecord query. Using IDs in ascending order.
+ user_ids, last_activities = user_ids_with_last_activity
+ .sort_by(&:first)
+ .transpose
+
+ return [] if user_ids.blank?
+
+ users = User.where(id: user_ids).order(id: :asc)
+ users.zip(last_activities)
+ end
+
+ private
+
+ attr_reader :id
+
+ def user_online?(last_activity, threshold:)
+ last_activity.to_i + threshold.to_i > Time.zone.now.to_i
+ end
+
+ # converts session id from hex to integer representation
+ def id_i
+ Integer(id, 16) if id.present?
+ end
+
+ def users_key
+ "#{KEY_NAMESPACE}:session:#{id}:users"
+ end
+
+ def user_sessions_key(user_id)
+ "#{KEY_NAMESPACE}:user:#{user_id}:sessions"
+ end
+
+ def with_redis
+ Gitlab::Redis::SharedState.with do |redis|
+ yield redis if block_given?
+ end
+ end
+
+ def timestamp
+ Time.now.to_i
+ end
+
+ def user_ids
+ with_redis do |redis|
+ redis.zrange(users_key, 0, -1)
+ end
+ end
+
+ # Returns an array of tuples, where the first element in the tuple represents
+ # the user ID and the second part the last_activity timestamp.
+ def user_ids_with_last_activity
+ pairs = with_redis do |redis|
+ redis.zrange(users_key, 0, -1, with_scores: true)
+ end
+
+ # map data type of score (float) to Time
+ pairs.map do |user_id, score|
+ [user_id, Time.zone.at(score.to_i)]
+ end
+ end
+
+ # We want sessions to cleanup automatically after a certain period of
+ # inactivity. This sets the expiry timestamp for this session to
+ # [SESSION_LIFETIME].
+ def reset_session_expiry(redis)
+ redis.expire(users_key, SESSION_LIFETIME)
+
+ nil
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e35198ba31f..7f9697d0424 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -2,6 +2,7 @@
module Ci
class Build < Ci::Processable
+ prepend Ci::BulkInsertableTags
include Ci::Metadatable
include Ci::Contextable
include TokenAuthenticatable
@@ -14,8 +15,6 @@ module Ci
extend ::Gitlab::Utils::Override
- BuildArchivedError = Class.new(StandardError)
-
belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
@@ -30,10 +29,6 @@ module Ci
return_exit_code: -> (build) { build.exit_codes_defined? }
}.freeze
- DEFAULT_RETRIES = {
- scheduler_failure: 2
- }.freeze
-
DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute
@@ -172,7 +167,6 @@ module Ci
end
scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
- scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) }
scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) }
scope :last_month, -> { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
@@ -187,13 +181,6 @@ module Ci
joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types)
end
- scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
-
- scope :preload_project_and_pipeline_project, -> do
- preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
- pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE)
- end
-
scope :with_coverage, -> { where.not(coverage: nil) }
scope :without_coverage, -> { where(coverage: nil) }
scope :with_coverage_regex, -> { where.not(coverage_regex: nil) }
@@ -207,7 +194,7 @@ module Ci
after_save :stick_build_if_status_changed
after_create unless: :importing? do |build|
- run_after_commit { BuildHooksWorker.perform_async(build.id) }
+ run_after_commit { BuildHooksWorker.perform_async(build) }
end
class << self
@@ -217,10 +204,6 @@ module Ci
ActiveModel::Name.new(self, nil, 'job')
end
- def first_pending
- pending.unstarted.order('created_at ASC').first
- end
-
def with_preloads
preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace])
end
@@ -302,7 +285,7 @@ module Ci
build.run_after_commit do
BuildQueueWorker.perform_async(id)
- BuildHooksWorker.perform_async(id)
+ BuildHooksWorker.perform_async(build)
end
end
@@ -330,7 +313,7 @@ module Ci
build.run_after_commit do
build.ensure_persistent_ref
- BuildHooksWorker.perform_async(id)
+ BuildHooksWorker.perform_async(build)
end
end
@@ -338,11 +321,7 @@ module Ci
build.run_after_commit do
build.run_status_commit_hooks!
- if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project)
- Ci::BuildFinishedWorker.perform_async(id)
- else
- ::BuildFinishedWorker.perform_async(id)
- end
+ Ci::BuildFinishedWorker.perform_async(id)
end
end
@@ -446,10 +425,6 @@ module Ci
true
end
- def save_tags
- super unless Thread.current['ci_bulk_insert_tags']
- end
-
def archived?
return true if degenerated?
@@ -556,10 +531,6 @@ module Ci
self.options.dig(:environment, :deployment_tier) if self.options
end
- def outdated_deployment?
- success? && !deployment.try(:last?)
- end
-
def triggered_by?(current_user)
user == current_user
end
@@ -1162,6 +1133,14 @@ module Ci
Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment?
end
+ def track_verify_usage
+ Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) if user_id.present? && count_user_verification?
+ end
+
+ def count_user_verification?
+ has_environment? && environment_action == 'verify'
+ end
+
def each_report(report_types)
job_artifacts_for_types(report_types).each do |report_artifact|
report_artifact.each_blob do |blob|
diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb
index 2c08fc4c8bf..b674c1b1a0e 100644
--- a/app/models/ci/build_report_result.rb
+++ b/app/models/ci/build_report_result.rb
@@ -39,9 +39,5 @@ module Ci
def suite_error
tests.dig("suite_error")
end
-
- def tests_total
- [tests_success, tests_failed, tests_errored, tests_skipped].sum
- end
end
end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index e5cb2026503..0105366d99b 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -50,8 +50,7 @@ module Ci
def status_struct
strong_memoize(:status_struct) do
- Gitlab::Ci::Status::Composite
- .new(@jobs, project: project)
+ Gitlab::Ci::Status::Composite.new(@jobs)
end
end
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 0af5533613f..e11edbda6dc 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -19,5 +19,9 @@ module Ci
scope :unprotected, -> { where(protected: false) }
scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
scope :for_groups, ->(group_ids) { where(group_id: group_ids) }
+
+ def audit_details
+ key
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 81943cfa651..ee7175a4f69 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -322,7 +322,7 @@ module Ci
def expire_in=(value)
self.expire_at =
if value
- ::Gitlab::Ci::Build::Artifacts::ExpireInParser.new(value).seconds_from_now
+ ::Gitlab::Ci::Build::DurationParser.new(value).seconds_from_now
end
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
deleted file mode 100644
index ffd3d3fcd88..00000000000
--- a/app/models/ci/legacy_stage.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- # Currently this is artificial object, constructed dynamically
- # We should migrate this object to actual database record in the future
- class LegacyStage
- include StaticModel
- include Presentable
-
- attr_reader :pipeline, :name
-
- delegate :project, to: :pipeline
-
- def initialize(pipeline, name:, status: nil, warnings: nil)
- @pipeline = pipeline
- @name = name
- @status = status
- # support ints and booleans
- @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings)
- end
-
- def groups
- @groups ||= Ci::Group.fabricate(project, self)
- end
-
- def to_param
- name
- end
-
- def statuses_count
- @statuses_count ||= statuses.count
- end
-
- def status
- @status ||= statuses.latest.composite_status(project: project)
- end
-
- def detailed_status(current_user)
- Gitlab::Ci::Status::Stage::Factory
- .new(self, current_user)
- .fabricate!
- end
-
- def latest_statuses
- statuses.ordered.latest
- end
-
- def statuses
- @statuses ||= pipeline.statuses.where(stage: name)
- end
-
- def builds
- @builds ||= pipeline.builds.where(stage: name)
- end
-
- def success?
- status.to_s == 'success'
- end
-
- def has_warnings?
- # lazilly calculate the warnings
- if @has_warnings.nil?
- @has_warnings = statuses.latest.failed_but_allowed.any?
- end
-
- @has_warnings
- end
-
- def manual_playable?
- %[manual scheduled skipped].include?(status.to_s)
- end
- end
-end
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index d900a056242..0fa6a234a3d 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -30,10 +30,6 @@ module Ci
self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
end
- def maintain_denormalized_data?
- ::Feature.enabled?(:ci_pending_builds_maintain_denormalized_data)
- end
-
private
def args_from_build(build)
@@ -43,13 +39,13 @@ module Ci
build: build,
project: project,
protected: build.protected?,
- namespace: project.namespace
+ namespace: project.namespace,
+ tag_ids: build.tags_ids,
+ instance_runners_enabled: shared_runners_enabled?(project)
}
- if maintain_denormalized_data?
- args.store(:tag_ids, build.tags_ids)
- args.store(:instance_runners_enabled, shared_runners_enabled?(project))
- args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project)
+ if group_runners_enabled?(project)
+ args.store(:namespace_traversal_ids, project.namespace.traversal_ids)
end
args
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 5d316906bd3..78b55680b5e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -27,8 +27,6 @@ module Ci
DEFAULT_CONFIG_PATH = CONFIG_EXTENSION
CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze
- BridgeStatusError = Class.new(StandardError)
-
paginates_per 15
sha_attribute :source_sha
@@ -133,6 +131,7 @@ module Ci
validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
after_create :keep_around_commits, unless: :importing?
+ after_find :observe_age_in_minutes, unless: :importing?
use_fast_destroy :job_artifacts
use_fast_destroy :build_trace_chunks
@@ -241,6 +240,13 @@ module Ci
pipeline.run_after_commit do
unless pipeline.user&.blocked?
+ Gitlab::AppLogger.info(
+ message: "Enqueuing hooks for Pipeline #{pipeline.id}: #{pipeline.status}",
+ class: self.class.name,
+ pipeline_id: pipeline.id,
+ project_id: pipeline.project_id,
+ pipeline_status: pipeline.status)
+
PipelineHooksWorker.perform_async(pipeline.id)
end
@@ -332,8 +338,8 @@ module Ci
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
scope :for_project, -> (project_id) { where(project_id: project_id) }
- scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
- scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
+ scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) }
+ scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) }
scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
scope :with_pipeline_source, -> (source) { where(source: source) }
@@ -490,40 +496,16 @@ module Ci
.pluck(:stage, :stage_idx).map(&:first)
end
- def legacy_stage(name)
- stage = Ci::LegacyStage.new(self, name: name)
- stage unless stage.statuses_count == 0
- end
-
def ref_exists?
project.repository.ref_exists?(git_ref)
rescue Gitlab::Git::Repository::NoRepository
false
end
- def legacy_stages_using_composite_status
- stages = latest_statuses_ordered_by_stage.group_by(&:stage)
-
- stages.map do |stage_name, jobs|
- composite_status = Gitlab::Ci::Status::Composite
- .new(jobs)
-
- Ci::LegacyStage.new(self,
- name: stage_name,
- status: composite_status.status,
- warnings: composite_status.warnings?)
- end
- end
-
def triggered_pipelines_with_preloads
triggered_pipelines.preload(:source_job)
end
- # TODO: Remove usage of this method in templates
- def legacy_stages
- legacy_stages_using_composite_status
- end
-
def valid_commit_sha
if self.sha == Gitlab::Git::BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
@@ -1004,6 +986,10 @@ module Ci
object_hierarchy(project_condition: :same).base_and_descendants
end
+ def self_and_descendants_complete?
+ self_and_descendants.all?(&:complete?)
+ end
+
# Follow the parent-child relationships and return the top-level parent
def root_ancestor
return self unless child?
@@ -1078,7 +1064,11 @@ module Ci
end
def has_reports?(reports_scope)
- complete? && latest_report_builds(reports_scope).exists?
+ if Feature.enabled?(:mr_show_reports_immediately, project, type: :development)
+ latest_report_builds(reports_scope).exists?
+ else
+ complete? && latest_report_builds(reports_scope).exists?
+ end
end
def has_coverage_reports?
@@ -1100,7 +1090,7 @@ module Ci
end
def test_reports
- Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
+ Gitlab::Ci::Reports::TestReport.new.tap do |test_reports|
latest_test_report_builds.find_each do |build|
build.collect_test_reports!(test_reports)
end
@@ -1222,6 +1212,10 @@ module Ci
Gitlab::Utils.slugify(source_ref.to_s)
end
+ def stage(name)
+ stages.find_by(name: name)
+ end
+
def find_stage_by_name!(name)
stages.find_by!(name: name)
end
@@ -1307,10 +1301,20 @@ module Ci
end
end
- def has_expired_test_reports?
- strong_memoize(:has_expired_test_reports) do
- has_reports?(::Ci::JobArtifact.test_reports.expired)
+ def has_test_reports?
+ strong_memoize(:has_test_reports) do
+ has_reports?(::Ci::JobArtifact.test_reports)
+ end
+ end
+
+ def age_in_minutes
+ return 0 unless persisted?
+
+ unless has_attribute?(:created_at)
+ raise ArgumentError, 'pipeline not fully loaded'
end
+
+ (Time.current - created_at).ceil / 60
end
private
@@ -1363,6 +1367,21 @@ module Ci
project.repository.keep_around(self.sha, self.before_sha)
end
+ def observe_age_in_minutes
+ return unless age_metric_enabled?
+ return unless persisted? && has_attribute?(:created_at)
+
+ ::Gitlab::Ci::Pipeline::Metrics
+ .pipeline_age_histogram
+ .observe({}, age_in_minutes)
+ end
+
+ def age_metric_enabled?
+ ::Gitlab::SafeRequestStore.fetch(:age_metric_enabled) do
+ ::Feature.enabled?(:ci_pipeline_age_histogram, type: :ops)
+ end
+ end
+
# Without using `unscoped`, caller scope is also included into the query.
# Using `unscoped` here will be redundant after Rails 6.1
def object_hierarchy(options = {})
diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb
index 2284a05bcc9..cdc3d69f754 100644
--- a/app/models/ci/pipeline_artifact.rb
+++ b/app/models/ci/pipeline_artifact.rb
@@ -51,6 +51,23 @@ module Ci
def find_by_file_type(file_type)
find_by(file_type: file_type)
end
+
+ def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:)
+ transaction do
+ pipeline.pipeline_artifacts.find_by_file_type(file_type)&.destroy!
+
+ pipeline.pipeline_artifacts.create!(
+ file_type: file_type,
+ project_id: pipeline.project_id,
+ size: size,
+ file: file,
+ file_format: REPORT_TYPES[file_type],
+ expire_at: EXPIRATION_DATE.from_now
+ )
+ end
+ rescue ActiveRecord::ActiveRecordError => err
+ Gitlab::ErrorTracking.track_and_raise_exception(err, { pipeline_id: pipeline.id, file_type: file_type })
+ end
end
def present
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 61194c9b7d1..f41ad890184 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -2,6 +2,7 @@
module Ci
class Runner < Ci::ApplicationRecord
+ prepend Ci::BulkInsertableTags
include Gitlab::SQL::Pattern
include RedisCacheable
include ChronicDurationAttribute
@@ -14,6 +15,8 @@ module Ci
include Presentable
include EachBatch
+ ignore_column :semver, remove_with: '15.3', remove_after: '2022-07-22'
+
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced?
enum access_level: {
@@ -75,9 +78,9 @@ module Ci
has_many :groups, through: :runner_namespaces, disable_joins: true
has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build'
+ has_one :runner_version, primary_key: :version, foreign_key: :version, class_name: 'Ci::RunnerVersion'
before_save :ensure_token
- before_save :update_semver, if: -> { version_changed? }
scope :active, -> (value = true) { where(active: value) }
scope :paused, -> { active(false) }
@@ -430,7 +433,6 @@ module Ci
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
values[:contacted_at] = Time.current
values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
- values[:semver] = semver_from_version(values[:version])
cache_attributes(values)
@@ -451,16 +453,6 @@ module Ci
read_attribute(:contacted_at)
end
- def semver_from_version(version)
- parsed_runner_version = ::Gitlab::VersionInfo.parse(version)
-
- parsed_runner_version.valid? ? parsed_runner_version.to_s : nil
- end
-
- def update_semver
- self.semver = semver_from_version(self.version)
- end
-
def namespace_ids
strong_memoize(:namespace_ids) do
runner_namespaces.pluck(:namespace_id).compact
@@ -484,6 +476,10 @@ module Ci
private
+ scope :with_upgrade_status, ->(upgrade_status) do
+ Ci::Runner.joins(:runner_version).where(runner_version: { status: upgrade_status })
+ end
+
EXECUTOR_NAME_TO_TYPES = {
'unknown' => :unknown,
'custom' => :custom,
diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb
new file mode 100644
index 00000000000..6b2d0060c9b
--- /dev/null
+++ b/app/models/ci/runner_version.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnerVersion < Ci::ApplicationRecord
+ include EachBatch
+ include EnumWithNil
+
+ enum_with_nil status: {
+ not_processed: nil,
+ invalid_version: -1,
+ unknown: 0,
+ not_available: 1,
+ available: 2,
+ recommended: 3
+ }
+
+ STATUS_DESCRIPTIONS = {
+ invalid_version: 'Runner version is not valid.',
+ unknown: 'Upgrade status is unknown.',
+ not_available: 'Upgrade is not available for the runner.',
+ available: 'Upgrade is available for the runner.',
+ recommended: 'Upgrade is available and recommended for the runner.'
+ }.freeze
+
+ # Override auto generated negative scope (from available) so the scope has expected behavior
+ scope :not_available, -> { where(status: :not_available) }
+
+ # This scope returns all versions that might need recalculating. For instance, once a version is considered
+ # :recommended, it normally doesn't change status even if the instance is upgraded
+ scope :potentially_outdated, -> { where(status: [nil, :not_available, :available, :unknown]) }
+
+ validates :version, length: { maximum: 2048 }
+ end
+end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 8c4e97ac840..f03d1e96a4b 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -142,7 +142,7 @@ module Ci
end
def latest_stage_status
- statuses.latest.composite_status(project: project) || 'skipped'
+ statuses.latest.composite_status || 'skipped'
end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 5bf5ae51ec8..c4db4754c52 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -4,6 +4,9 @@ module Ci
class Trigger < Ci::ApplicationRecord
include Presentable
include Limitable
+ include IgnorableColumns
+
+ ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22'
self.limit_name = 'pipeline_triggers'
self.limit_scope = :project
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 1e91f248fc4..c80c2ebe69a 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -18,5 +18,9 @@ module Ci
scope :unprotected, -> { where(protected: false) }
scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
+
+ def audit_details
+ key
+ end
end
end
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index fb12ce7d292..3478bb69707 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -53,3 +53,5 @@ module Clusters
end
end
end
+
+Clusters::Agent.prepend_mod_with('Clusters::Agent')
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
deleted file mode 100644
index 73c731aab1a..00000000000
--- a/app/models/clusters/applications/elastic_stack.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class ElasticStack < ApplicationRecord
- include ::Clusters::Concerns::ElasticsearchClient
-
- VERSION = '3.0.0'
-
- self.table_name = 'clusters_applications_elastic_stacks'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
-
- default_value_for :version, VERSION
-
- after_destroy do
- cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil)
- end
-
- state_machine :status do
- after_transition any => [:installed] do |application|
- application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: true, chart_version: application.version)
- end
-
- after_transition any => [:uninstalled] do |application|
- application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil)
- end
- end
-
- def chart
- 'elastic-stack/elastic-stack'
- end
-
- def repository
- 'https://charts.gitlab.io'
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: 'elastic-stack',
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- repository: repository,
- files: files,
- preinstall: migrate_to_3_script,
- postinstall: post_install_script
- )
- end
-
- def uninstall_command
- helm_command_module::DeleteCommand.new(
- name: 'elastic-stack',
- rbac: cluster.platform_kubernetes_rbac?,
- files: files,
- postdelete: post_delete_script
- )
- end
-
- def files
- super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh"))
- end
-
- def chart_above_v2?
- Gem::Version.new(version) >= Gem::Version.new('2.0.0')
- end
-
- def chart_above_v3?
- Gem::Version.new(version) >= Gem::Version.new('3.0.0')
- end
-
- private
-
- def service_name
- chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client'
- end
-
- def pvc_selector
- chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack"
- end
-
- def post_install_script
- [
- "timeout 60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200"
- ]
- end
-
- def post_delete_script
- [
- Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
- ]
- end
-
- def migrate_to_3_script
- return [] if !updating? || chart_above_v3?
-
- # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack
- # and is not compatible with pre-existing resources. We first remove them.
- [
- helm_command_module::DeleteCommand.new(
- name: 'elastic-stack',
- rbac: cluster.platform_kubernetes_rbac?,
- files: files
- ).delete_command,
- Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
- ]
- end
- end
- end
-end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 014f7530357..ad1e7dc305f 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -20,7 +20,6 @@ module Clusters
Clusters::Applications::Runner.application_name => Clusters::Applications::Runner,
Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter,
Clusters::Applications::Knative.application_name => Clusters::Applications::Knative,
- Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack,
Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium
}.freeze
DEFAULT_ENVIRONMENT = '*'
@@ -51,7 +50,6 @@ module Clusters
has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true
has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster
- has_one :integration_elastic_stack, class_name: 'Clusters::Integrations::ElasticStack', inverse_of: :cluster
def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName
application = APPLICATIONS[name.to_s]
@@ -66,7 +64,6 @@ module Clusters
has_one_cluster_application :runner
has_one_cluster_application :jupyter
has_one_cluster_application :knative
- has_one_cluster_application :elastic_stack
has_one_cluster_application :cilium
has_many :kubernetes_namespaces
@@ -102,7 +99,6 @@ module Clusters
delegate :available?, to: :application_helm, prefix: true, allow_nil: true
delegate :available?, to: :application_ingress, prefix: true, allow_nil: true
delegate :available?, to: :application_knative, prefix: true, allow_nil: true
- delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true
delegate :available?, to: :integration_prometheus, prefix: true, allow_nil: true
delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true
delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true
@@ -136,7 +132,6 @@ module Clusters
scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) }
scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) }
- scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) }
scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct }
scope :managed, -> { where(managed: true) }
@@ -271,10 +266,6 @@ module Clusters
integration_prometheus || build_integration_prometheus
end
- def find_or_build_integration_elastic_stack
- integration_elastic_stack || build_integration_elastic_stack
- end
-
def provider
if gcp?
provider_gcp
@@ -309,18 +300,6 @@ module Clusters
platform_kubernetes&.kubeclient if kubernetes?
end
- def elastic_stack_adapter
- integration_elastic_stack
- end
-
- def elasticsearch_client
- elastic_stack_adapter&.elasticsearch_client
- end
-
- def elastic_stack_available?
- !!integration_elastic_stack_available?
- end
-
def kubernetes_namespace_for(environment, deployable: environment.last_deployable)
if deployable && environment.project_id != deployable.project_id
raise ArgumentError, 'environment.project_id must match deployable.project_id'
diff --git a/app/models/clusters/concerns/elasticsearch_client.rb b/app/models/clusters/concerns/elasticsearch_client.rb
deleted file mode 100644
index e9aab7897a8..00000000000
--- a/app/models/clusters/concerns/elasticsearch_client.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Concerns
- module ElasticsearchClient
- include ::Gitlab::Utils::StrongMemoize
-
- ELASTICSEARCH_PORT = 9200
- ELASTICSEARCH_NAMESPACE = 'gitlab-managed-apps'
-
- def elasticsearch_client(timeout: nil)
- strong_memoize(:elasticsearch_client) do
- kube_client = cluster&.kubeclient&.core_client
- next unless kube_client
-
- proxy_url = kube_client.proxy_url('service', service_name, ELASTICSEARCH_PORT, ELASTICSEARCH_NAMESPACE)
-
- Elasticsearch::Client.new(url: proxy_url, adapter: :net_http) do |faraday|
- # ensures headers containing auth data are appended to original client options
- faraday.headers.merge!(kube_client.headers)
- # ensure TLS certs are properly verified
- faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl]
- faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store]
- faraday.options.timeout = timeout unless timeout.nil?
- end
-
- rescue Kubeclient::HttpError => error
- # If users have mistakenly set parameters or removed the depended clusters,
- # `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
- # We check for a nil client in downstream use and behaviour is equivalent to an empty state
- log_exception(error, :failed_to_create_elasticsearch_client)
-
- nil
- end
- end
- end
- end
-end
diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb
deleted file mode 100644
index 97d73d252b9..00000000000
--- a/app/models/clusters/integrations/elastic_stack.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Integrations
- class ElasticStack < ApplicationRecord
- include ::Clusters::Concerns::ElasticsearchClient
- include ::Clusters::Concerns::KubernetesLogger
-
- self.table_name = 'clusters_integration_elasticstack'
- self.primary_key = :cluster_id
-
- belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
-
- validates :cluster, presence: true
- validates :enabled, inclusion: { in: [true, false] }
-
- scope :enabled, -> { where(enabled: true) }
-
- def available?
- enabled
- end
-
- def service_name
- chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client'
- end
-
- def chart_above_v2?
- return true if chart_version.nil?
-
- Gem::Version.new(chart_version) >= Gem::Version.new('2.0.0')
- end
-
- def chart_above_v3?
- return true if chart_version.nil?
-
- Gem::Version.new(chart_version) >= Gem::Version.new('3.0.0')
- end
- end
- end
-end
diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb
index 0d6177beae7..899529ff49f 100644
--- a/app/models/clusters/integrations/prometheus.rb
+++ b/app/models/clusters/integrations/prometheus.rb
@@ -55,23 +55,13 @@ module Clusters
private
def activate_project_integrations
- if Feature.enabled?(:rename_integrations_workers)
- ::Clusters::Applications::ActivateIntegrationWorker
- .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
- else
- ::Clusters::Applications::ActivateServiceWorker
- .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
- end
+ ::Clusters::Applications::ActivateIntegrationWorker
+ .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
end
def deactivate_project_integrations
- if Feature.enabled?(:rename_integrations_workers)
- ::Clusters::Applications::DeactivateIntegrationWorker
- .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
- else
- ::Clusters::Applications::DeactivateServiceWorker
- .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
- end
+ ::Clusters::Applications::DeactivateIntegrationWorker
+ .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index ac9d8c39bd2..afe4927ee73 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -8,9 +8,12 @@ class CommitStatus < Ci::ApplicationRecord
include EnumWithNil
include BulkInsertableAssociations
include TaggableQueries
+ include IgnorableColumns
self.table_name = 'ci_builds'
+ ignore_column :token, remove_with: '15.4', remove_after: '2022-08-22'
+
belongs_to :user
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
@@ -220,10 +223,6 @@ class CommitStatus < Ci::ApplicationRecord
false
end
- def self.bulk_insert_tags!(statuses)
- Gitlab::Ci::Tags::BulkInsert.new(statuses).insert!
- end
-
def locking_enabled?
will_save_change_to_status?
end
@@ -325,5 +324,3 @@ class CommitStatus < Ci::ApplicationRecord
script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure?
end
end
-
-CommitStatus.prepend_mod_with('CommitStatus')
diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb
new file mode 100644
index 00000000000..da87d87e838
--- /dev/null
+++ b/app/models/concerns/awareness.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Awareness
+ extend ActiveSupport::Concern
+
+ KEY_NAMESPACE = "gitlab:awareness"
+ private_constant :KEY_NAMESPACE
+
+ def join(session)
+ session.join(self)
+
+ nil
+ end
+
+ def leave(session)
+ session.leave(self)
+
+ nil
+ end
+
+ def session_ids
+ with_redis do |redis|
+ redis
+ .smembers(user_sessions_key)
+ # converts session ids from (internal) integer to hex presentation
+ .map { |key| key.to_i.to_s(16) }
+ end
+ end
+
+ private
+
+ def user_sessions_key
+ "#{KEY_NAMESPACE}:user:#{id}:sessions"
+ end
+
+ def with_redis
+ Gitlab::Redis::SharedState.with do |redis|
+ yield redis if block_given?
+ end
+ end
+end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 99dbe464a7c..9ee0fd1db1d 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -172,7 +172,7 @@ module CacheMarkdownField
refs = all_references(self.author)
references = {}
- references[:mentioned_users_ids] = refs.mentioned_user_ids.presence
+ references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs)
references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence
references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence
@@ -185,6 +185,13 @@ module CacheMarkdownField
true
end
+ # Overriden on objects that needs to filter
+ # mentioned users that cannot read them, for example,
+ # guest users that are referenced on a confidential note.
+ def mentioned_filtered_user_ids_for(refs)
+ refs.mentioned_user_ids.presence
+ end
+
def mentionable_attributes_changed?(changes = saved_changes)
return false unless is_a?(Mentionable)
diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb
index 78340cf967b..fb4ea4206f4 100644
--- a/app/models/concerns/ci/artifactable.rb
+++ b/app/models/concerns/ci/artifactable.rb
@@ -30,6 +30,8 @@ module Ci
raise NotSupportedAdapterError, 'This file format requires a dedicated adapter'
end
+ ::Gitlab::ApplicationContext.push(artifact: file.model)
+
file.open do |stream|
file_format_adapter_class.new(stream).each_blob(&blk)
end
diff --git a/app/models/concerns/ci/bulk_insertable_tags.rb b/app/models/concerns/ci/bulk_insertable_tags.rb
new file mode 100644
index 00000000000..453b3b3fbc9
--- /dev/null
+++ b/app/models/concerns/ci/bulk_insertable_tags.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Ci
+ module BulkInsertableTags
+ extend ActiveSupport::Concern
+
+ BULK_INSERT_TAG_THREAD_KEY = 'ci_bulk_insert_tags'
+
+ class << self
+ def with_bulk_insert_tags
+ previous = Thread.current[BULK_INSERT_TAG_THREAD_KEY]
+ Thread.current[BULK_INSERT_TAG_THREAD_KEY] = true
+ yield
+ ensure
+ Thread.current[BULK_INSERT_TAG_THREAD_KEY] = previous
+ end
+ end
+
+ # overrides save_tags from acts-as-taggable
+ def save_tags
+ super unless Thread.current[BULK_INSERT_TAG_THREAD_KEY]
+ end
+ end
+end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index cca66c3ec94..721cb14201f 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -23,11 +23,9 @@ module Ci
UnknownStatusError = Class.new(StandardError)
class_methods do
- # The parameter `project` is only used for the feature flag check, and will be removed with
- # https://gitlab.com/gitlab-org/gitlab/-/issues/321972
- def composite_status(project: nil)
+ def composite_status
Gitlab::Ci::Status::Composite
- .new(all, with_allow_failure: columns_hash.key?('allow_failure'), project: project)
+ .new(all, with_allow_failure: columns_hash.key?('allow_failure'))
.status
end
diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb
index 443e1ab53b4..dbc0887dc97 100644
--- a/app/models/concerns/each_batch.rb
+++ b/app/models/concerns/each_batch.rb
@@ -2,6 +2,7 @@
module EachBatch
extend ActiveSupport::Concern
+ include LooseIndexScan
class_methods do
# Iterates over the rows in a relation in batches, similar to Rails'
@@ -100,5 +101,65 @@ module EachBatch
break unless stop
end
end
+
+ # Iterates over the rows in a relation in batches by skipping duplicated values in the column.
+ # Example: counting the number of distinct authors in `issues`
+ #
+ # - Table size: 100_000
+ # - Column: author_id
+ # - Distinct author_ids in the table: 1000
+ #
+ # The query will read maximum 1000 rows if we have index coverage on user_id.
+ #
+ # > count = 0
+ # > Issue.distinct_each_batch(column: 'author_id', of: 1000) { |r| count += r.count(:author_id) }
+ def distinct_each_batch(column:, order: :asc, of: 1000)
+ start = except(:select)
+ .select(column)
+ .reorder(column => order)
+
+ start = start.take
+
+ return unless start
+
+ start_id = start[column]
+ arel_table = self.arel_table
+ arel_column = arel_table[column.to_s]
+
+ 1.step do |index|
+ stop = loose_index_scan(column: column, order: order) do |cte_query, inner_query|
+ if order == :asc
+ [cte_query.where(arel_column.gteq(start_id)), inner_query]
+ else
+ [cte_query.where(arel_column.lteq(start_id)), inner_query]
+ end
+ end.offset(of).take
+
+ if stop
+ stop_id = stop[column]
+
+ relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query|
+ if order == :asc
+ [cte_query.where(arel_column.gteq(start_id)), inner_query.where(arel_column.lt(stop_id))]
+ else
+ [cte_query.where(arel_column.lteq(start_id)), inner_query.where(arel_column.gt(stop_id))]
+ end
+ end
+ start_id = stop_id
+ else
+ relation = loose_index_scan(column: column, order: order) do |cte_query, inner_query|
+ if order == :asc
+ [cte_query.where(arel_column.gteq(start_id)), inner_query]
+ else
+ [cte_query.where(arel_column.lteq(start_id)), inner_query]
+ end
+ end
+ end
+
+ unscoped { yield relation, index }
+
+ break unless stop
+ end
+ end
end
end
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 445277a7a7c..ecb120d8013 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -29,9 +29,12 @@ module Enums
builds_disabled: 20,
environment_creation_failure: 21,
deployment_rejected: 22,
+ protected_environment_failure: 1_000,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
+ upstream_bridge_project_not_found: 1_004,
+ insufficient_upstream_permissions: 1_005,
bridge_pipeline_is_child_pipeline: 1_006, # not used anymore, but cannot be deleted because of old data
downstream_pipeline_creation_failed: 1_007,
secrets_provider_not_found: 1_008,
@@ -42,5 +45,3 @@ module Enums
end
end
end
-
-Enums::Ci::CommitStatus.prepend_mod_with('Enums::Ci::CommitStatus')
diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb
index b1def38d019..57f8e21c5a6 100644
--- a/app/models/concerns/integrations/has_issue_tracker_fields.rb
+++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb
@@ -5,26 +5,32 @@ module Integrations
extend ActiveSupport::Concern
included do
+ self.field_storage = :data_fields
+
field :project_url,
required: true,
- storage: :data_fields,
title: -> { _('Project URL') },
- help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') }
+ help: -> do
+ s_('IssueTracker|The URL to the project in the external issue tracker.')
+ end
field :issues_url,
required: true,
- storage: :data_fields,
title: -> { s_('IssueTracker|Issue URL') },
help: -> do
- format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'),
+ ERB::Util.html_escape(
+ s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.')
+ ) % {
colon_id: '<code>:id</code>'.html_safe
+ }
end
field :new_issue_url,
required: true,
- storage: :data_fields,
title: -> { s_('IssueTracker|New issue URL') },
- help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') }
+ help: -> do
+ s_('IssueTracker|The URL to create an issue in the external issue tracker.')
+ end
end
end
end
diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb
index 3bdaa852ddf..142e62bb501 100644
--- a/app/models/concerns/integrations/slack_mattermost_notifier.rb
+++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb
@@ -34,7 +34,7 @@ module Integrations
class HTTPClient
def self.post(uri, params = {})
params.delete(:http_options) # these are internal to the client and we do not want them
- Gitlab::HTTP.post(uri, body: params, use_read_total_timeout: true)
+ Gitlab::HTTP.post(uri, body: params)
end
end
end
diff --git a/app/models/concerns/loose_index_scan.rb b/app/models/concerns/loose_index_scan.rb
new file mode 100644
index 00000000000..5d37a30171a
--- /dev/null
+++ b/app/models/concerns/loose_index_scan.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module LooseIndexScan
+ extend ActiveSupport::Concern
+
+ class_methods do
+ # Builds a recursive query to read distinct values from a column.
+ #
+ # Example 1: collect all distinct author ids for the `issues` table
+ #
+ # Bad: The DB reads all issues, sorts and dedups them in memory
+ #
+ # > Issue.select(:author_id).distinct.map(&:author_id)
+ #
+ # Good: Use loose index scan (skip index scan)
+ #
+ # > Issue.loose_index_scan(column: :author_id).map(&:author_id)
+ #
+ # Example 2: List of users for the DONE todos selector. Select all users who created a todo.
+ #
+ # Bad: Loads all DONE todos for the given user and extracts the author_ids
+ #
+ # > User.where(id: Todo.where(user_id: 4156052).done.select(:author_id))
+ #
+ # Good: Loads distinct author_ids from todos and then loads users
+ #
+ # > distinct_authors = Todo.where(user_id: 4156052).done.loose_index_scan(column: :author_id).select(:author_id)
+ # > User.where(id: distinct_authors)
+ def loose_index_scan(column:, order: :asc)
+ arel_table = self.arel_table
+ arel_column = arel_table[column.to_s]
+
+ cte = Gitlab::SQL::RecursiveCTE.new(:loose_index_scan_cte, union_args: { remove_order: false })
+
+ cte_query = except(:select)
+ .select(column)
+ .order(column => order)
+ .limit(1)
+
+ inner_query = except(:select)
+
+ cte_query, inner_query = yield([cte_query, inner_query]) if block_given?
+ cte << cte_query
+
+ inner_query = if order == :asc
+ inner_query.where(arel_column.gt(cte.table[column.to_s]))
+ else
+ inner_query.where(arel_column.lt(cte.table[column.to_s]))
+ end
+
+ inner_query = inner_query.order(column => order)
+ .select(column)
+ .limit(1)
+
+ cte << cte.table
+ .project(Arel::Nodes::Grouping.new(Arel.sql(inner_query.to_sql)).as(column.to_s))
+
+ unscoped do
+ select(column)
+ .with
+ .recursive(cte.to_arel)
+ .from(cte.alias_to(arel_table))
+ .where(arel_column.not_eq(nil)) # filtering out the last NULL value
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 12041b103f6..14c54d99ef3 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -16,7 +16,7 @@ module Milestoneable
scope :any_milestone, -> { where.not(milestone_id: nil) }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
- scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) }
+ scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) }
diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb
index 18ec996c3df..f2df7579a65 100644
--- a/app/models/concerns/notification_branch_selection.rb
+++ b/app/models/concerns/notification_branch_selection.rb
@@ -6,13 +6,15 @@
module NotificationBranchSelection
extend ActiveSupport::Concern
- def branch_choices
- [
- [_('All branches'), 'all'].freeze,
- [_('Default branch'), 'default'].freeze,
- [_('Protected branches'), 'protected'].freeze,
- [_('Default branch and protected branches'), 'default_and_protected'].freeze
- ].freeze
+ class_methods do
+ def branch_choices
+ [
+ [_('All branches'), 'all'].freeze,
+ [_('Default branch'), 'default'].freeze,
+ [_('Protected branches'), 'protected'].freeze,
+ [_('Default branch and protected branches'), 'default_and_protected'].freeze
+ ].freeze
+ end
end
def notify_for_branch?(data)
diff --git a/app/models/concerns/packages/fips.rb b/app/models/concerns/packages/fips.rb
new file mode 100644
index 00000000000..b8589cdc991
--- /dev/null
+++ b/app/models/concerns/packages/fips.rb
@@ -0,0 +1,11 @@
+# rubocop:disable Naming/FileName
+# frozen_string_literal: true
+
+module Packages
+ module FIPS
+ extend ActiveSupport::Concern
+
+ DisabledError = Class.new(StandardError)
+ end
+end
+# rubocop:enable Naming/FileName
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 20743ebcb52..f59b5d1ecc8 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -92,7 +92,13 @@ module Participable
end
def raw_participants(current_user = nil, verify_access: false)
- ext = Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor = Gitlab::ReferenceExtractor.new(project, current_user)
+
+ # Used to extract references from confidential notes.
+ # Referenced users that cannot read confidential notes are
+ # later removed from participants array.
+ internal_notes_extractor = Gitlab::ReferenceExtractor.new(project, current_user)
+
participants = Set.new
process = [self]
@@ -107,6 +113,8 @@ module Participable
source.class.participant_attrs.each do |attr|
if attr.respond_to?(:call)
+ ext = use_internal_notes_extractor_for?(source) ? internal_notes_extractor : extractor
+
source.instance_exec(current_user, ext, &attr)
else
process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend
@@ -121,7 +129,18 @@ module Participable
end
end
- participants.merge(ext.users)
+ participants.merge(users_that_can_read_internal_notes(internal_notes_extractor))
+ participants.merge(extractor.users)
+ end
+
+ def use_internal_notes_extractor_for?(source)
+ source.is_a?(Note) && source.confidential?
+ end
+
+ def users_that_can_read_internal_notes(extractor)
+ return [] unless self.is_a?(Noteable) && self.try(:resource_parent)
+
+ Ability.users_that_can_read_internal_notes(extractor.users, self.resource_parent)
end
def source_visible_to_user?(source, user)
diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb
new file mode 100644
index 00000000000..cf6a31e6ebd
--- /dev/null
+++ b/app/models/concerns/require_email_verification.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# == Require Email Verification module
+#
+# Contains functionality to handle email verification
+module RequireEmailVerification
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor
+ # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1.
+ # See: https://github.com/tinfoil/devise-two-factor/issues/127
+ MAXIMUM_ATTEMPTS = 3 * 2
+ UNLOCK_IN = 24.hours
+
+ included do
+ # Virtual attribute for the email verification token form
+ attr_accessor :verification_token
+ end
+
+ # When overridden, do not send Devise unlock instructions when locking access.
+ def lock_access!(opts = {})
+ return super unless override_devise_lockable?
+
+ super({ send_instructions: false })
+ end
+
+ protected
+
+ # We cannot override the class methods `maximum_attempts` and `unlock_in`, because we want to
+ # check for 2FA being enabled on the instance. So instead override the Devise Lockable methods
+ # where those values are used.
+ def attempts_exceeded?
+ return super unless override_devise_lockable?
+
+ failed_attempts >= MAXIMUM_ATTEMPTS
+ end
+
+ def lock_expired?
+ return super unless override_devise_lockable?
+
+ locked_at && locked_at < UNLOCK_IN.ago
+ end
+
+ private
+
+ def override_devise_lockable?
+ strong_memoize(:override_devise_lockable) do
+ Feature.enabled?(:require_email_verification, self) && !two_factor_enabled?
+ end
+ end
+end
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index 7f96b3901f1..4cf36f83857 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -42,4 +42,41 @@ module VulnerabilityFindingHelpers
)
end
end
+
+ def build_vulnerability_finding(security_finding)
+ report_finding = report_finding_for(security_finding)
+ return Vulnerabilities::Finding.new unless report_finding
+
+ finding_data = report_finding.to_hash.except(:compare_key, :identifiers, :location, :scanner, :links, :signatures,
+ :flags, :evidence)
+ identifiers = report_finding.identifiers.map do |identifier|
+ Vulnerabilities::Identifier.new(identifier.to_hash)
+ end
+ signatures = report_finding.signatures.map do |signature|
+ Vulnerabilities::FindingSignature.new(signature.to_hash)
+ end
+ evidence = Vulnerabilities::Finding::Evidence.new(data: report_finding.evidence.data) if report_finding.evidence
+
+ Vulnerabilities::Finding.new(finding_data).tap do |finding|
+ finding.location_fingerprint = report_finding.location.fingerprint
+ finding.vulnerability = vulnerability_for(security_finding.uuid)
+ finding.project = project
+ finding.sha = pipeline.sha
+ finding.scanner = security_finding.scanner
+ finding.finding_evidence = evidence
+
+ if calculate_false_positive?
+ finding.vulnerability_flags = report_finding.flags.map do |flag|
+ Vulnerabilities::Flag.new(flag)
+ end
+ end
+
+ finding.identifiers = identifiers
+ finding.signatures = signatures
+ end
+ end
+
+ def calculate_false_positive?
+ project.licensed_feature_available?(:sast_fp_reduction)
+ end
end
diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb
index 47d21d21afd..d4075e1ff1b 100644
--- a/app/models/container_registry/event.rb
+++ b/app/models/container_registry/event.rb
@@ -6,6 +6,7 @@ module ContainerRegistry
ALLOWED_ACTIONS = %w(push delete).freeze
PUSH_ACTION = 'push'
+ DELETE_ACTION = 'delete'
EVENT_TRACKING_CATEGORY = 'container_registry:notification'
attr_reader :event
@@ -41,6 +42,10 @@ module ContainerRegistry
event['target'].has_key?('tag')
end
+ def target_digest?
+ event['target'].has_key?('digest')
+ end
+
def target_repository?
!target_tag? && event['target'].has_key?('repository')
end
@@ -53,6 +58,10 @@ module ContainerRegistry
PUSH_ACTION == action
end
+ def action_delete?
+ DELETE_ACTION == action
+ end
+
def container_repository_exists?
return unless container_registry_path
@@ -74,7 +83,7 @@ module ContainerRegistry
def update_project_statistics
return unless supported?
- return unless target_tag?
+ return unless target_tag? || (action_delete? && target_digest?)
return unless project
Rails.cache.delete(project.root_ancestor.container_repositories_size_cache_key)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index c965d7cffe1..cdfd24e00aa 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -468,7 +468,7 @@ class ContainerRepository < ApplicationRecord
def size
strong_memoize(:size) do
next unless Gitlab.com?
- next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT)
+ next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT) && self.migration_state != 'import_done'
next unless gitlab_api_client.supports_gitlab_api?
gitlab_api_client.repository_details(self.path, sizing: :self)['size_bytes']
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index ded6ab8687a..0f13c45b84d 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -57,7 +57,22 @@ class CustomerRelations::Contact < ApplicationRecord
end
def self.sort_by_name
- order("last_name ASC, first_name ASC")
+ order(Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'last_name',
+ order_expression: arel_table[:last_name].asc,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'first_name',
+ order_expression: arel_table[:first_name].asc,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table[:id].asc
+ )
+ ]))
end
def self.find_ids_by_emails(group, emails)
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 3c0f7d91a03..20d19ec9541 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -5,9 +5,6 @@ class DeployToken < ApplicationRecord
include TokenAuthenticatable
include PolicyActor
include Gitlab::Utils::StrongMemoize
- include IgnorableColumns
-
- ignore_column :token, remove_with: '15.2', remove_after: '2022-07-22'
add_authentication_token_field :token, encrypted: :required
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index fc0dd7e00c7..c25ba6f9268 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -108,13 +108,9 @@ class Deployment < ApplicationRecord
end
end
- after_transition any => :running do |deployment|
+ after_transition any => :running do |deployment, transition|
deployment.run_after_commit do
- if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project)
- deployment.execute_hooks(Time.current)
- else
- Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current)
- end
+ Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current)
end
end
@@ -126,13 +122,9 @@ class Deployment < ApplicationRecord
end
end
- after_transition any => FINISHED_STATUSES do |deployment|
+ after_transition any => FINISHED_STATUSES do |deployment, transition|
deployment.run_after_commit do
- if Feature.enabled?(:deployment_hooks_skip_worker, deployment.project)
- deployment.execute_hooks(Time.current)
- else
- Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current)
- end
+ Deployments::HooksWorker.perform_async(deployment_id: id, status: transition.to, status_changed_at: Time.current)
end
end
@@ -193,7 +185,7 @@ class Deployment < ApplicationRecord
def self.last_deployment_group_for_environment(env)
return self.none unless env.last_deployment_pipeline&.latest_successful_builds&.present?
- BatchLoader.for(env).batch do |environments, loader|
+ BatchLoader.for(env).batch(default_value: self.none) do |environments, loader|
latest_successful_build_ids = []
environments_hash = {}
@@ -269,8 +261,8 @@ class Deployment < ApplicationRecord
Commit.truncate_sha(sha)
end
- def execute_hooks(status_changed_at)
- deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at)
+ def execute_hooks(status, status_changed_at)
+ deployment_data = Gitlab::DataBuilder::Deployment.build(self, status, status_changed_at)
project.execute_hooks(deployment_data, :deployment_hooks)
project.execute_integrations(deployment_data, :deployment_hooks)
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index da6ab5ed077..68540ce0f5c 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -451,13 +451,11 @@ class Environment < ApplicationRecord
def auto_stop_in=(value)
return unless value
- return unless parsed_result = ChronicDuration.parse(value)
- self.auto_stop_at = parsed_result.seconds.from_now
- end
+ parser = ::Gitlab::Ci::Build::DurationParser.new(value)
+ return if parser.seconds_from_now.nil?
- def elastic_stack_available?
- !!deployment_platform&.cluster&.elastic_stack_available?
+ self.auto_stop_at = parser.seconds_from_now
end
def rollout_status
diff --git a/app/models/error_tracking/client_key.rb b/app/models/error_tracking/client_key.rb
index bbc57573aa9..d58a183f223 100644
--- a/app/models/error_tracking/client_key.rb
+++ b/app/models/error_tracking/client_key.rb
@@ -16,7 +16,7 @@ class ErrorTracking::ClientKey < ApplicationRecord
end
def sentry_dsn
- @sentry_dsn ||= ErrorTracking::Collector::Dsn.build_url(public_key, project_id)
+ @sentry_dsn ||= ::Gitlab::ErrorTracking::ErrorRepository.build(project).dsn_url(public_key)
end
private
diff --git a/app/models/group.rb b/app/models/group.rb
index f5aad6e74ff..6d8f8bd7613 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -112,6 +112,8 @@ class Group < Namespace
has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest'
+ has_one :harbor_integration, class_name: 'Integrations::Harbor'
+
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -361,8 +363,8 @@ class Group < Namespace
owners.include?(user)
end
- def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
- Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
+ Members::Groups::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass
self,
users,
access_level,
@@ -373,8 +375,8 @@ class Group < Namespace
)
end
- def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true)
- Members::Groups::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass
+ def add_member(user, access_level, current_user: nil, expires_at: nil, ldap: false, blocking_refresh: true)
+ Members::Groups::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass
self,
user,
access_level,
@@ -386,23 +388,23 @@ class Group < Namespace
end
def add_guest(user, current_user = nil)
- add_user(user, :guest, current_user: current_user)
+ add_member(user, :guest, current_user: current_user)
end
def add_reporter(user, current_user = nil)
- add_user(user, :reporter, current_user: current_user)
+ add_member(user, :reporter, current_user: current_user)
end
def add_developer(user, current_user = nil)
- add_user(user, :developer, current_user: current_user)
+ add_member(user, :developer, current_user: current_user)
end
def add_maintainer(user, current_user = nil)
- add_user(user, :maintainer, current_user: current_user)
+ add_member(user, :maintainer, current_user: current_user)
end
def add_owner(user, current_user = nil)
- add_user(user, :owner, current_user: current_user)
+ add_member(user, :owner, current_user: current_user)
end
def member?(user, min_access_level = Gitlab::Access::GUEST)
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index b7ace34141e..bcbf43ee38b 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -27,6 +27,8 @@ class ProjectHook < WebHook
belongs_to :project
validates :project, presence: true
+ scope :for_projects, ->(project) { where(project: project) }
+
def pluralized_name
_('Webhooks')
end
@@ -41,6 +43,19 @@ class ProjectHook < WebHook
project
end
+ override :update_last_failure
+ def update_last_failure
+ return if executable?
+
+ key = "web_hooks:last_failure:project-#{project_id}"
+ time = Time.current.utc.iso8601
+
+ Gitlab::Redis::SharedState.with do |redis|
+ prev = redis.get(key)
+ redis.set(key, time) if !prev || prev < time
+ end
+ end
+
private
override :web_hooks_disable_failed?
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index c8a0cc05912..c0073f9a9b8 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -26,6 +26,6 @@ class SystemHook < WebHook
end
def help_path
- 'system_hooks/system_hooks'
+ 'administration/system_hooks'
end
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 37fd612e652..f428d07cd7f 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -3,6 +3,8 @@
class WebHook < ApplicationRecord
include Sortable
+ InterpolationError = Class.new(StandardError)
+
MAX_FAILURES = 100
FAILURE_THRESHOLD = 3 # three strikes
INITIAL_BACKOFF = 10.minutes
@@ -36,6 +38,7 @@ class WebHook < ApplicationRecord
validates :token, format: { without: /\n/ }
validates :push_events_branch_filter, branch_filter: true
validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' }
+ validate :no_missing_url_variables
after_initialize :initialize_url_variables
@@ -45,6 +48,11 @@ class WebHook < ApplicationRecord
where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current)
end
+ # Inverse of executable
+ scope :disabled, -> do
+ where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current)
+ end
+
def executable?
!temporarily_disabled? && !permanently_disabled?
end
@@ -164,6 +172,24 @@ class WebHook < ApplicationRecord
super(options)
end
+ # See app/validators/json_schemas/web_hooks_url_variables.json
+ VARIABLE_REFERENCE_RE = /\{([A-Za-z_][A-Za-z0-9_]+)\}/.freeze
+
+ def interpolated_url
+ return url unless url.include?('{')
+
+ vars = url_variables
+ url.gsub(VARIABLE_REFERENCE_RE) do
+ vars.fetch(_1.delete_prefix('{').delete_suffix('}'))
+ end
+ rescue KeyError => e
+ raise InterpolationError, "Invalid URL template. Missing key #{e.key}"
+ end
+
+ def update_last_failure
+ # Overridden in child classes.
+ end
+
private
def web_hooks_disable_failed?
@@ -177,4 +203,17 @@ class WebHook < ApplicationRecord
def rate_limiter
@rate_limiter ||= Gitlab::WebHooks::RateLimiter.new(self)
end
+
+ def no_missing_url_variables
+ return if url.nil?
+
+ variable_names = url_variables.keys
+ used_variables = url.scan(VARIABLE_REFERENCE_RE).map(&:first)
+
+ missing = used_variables - variable_names
+
+ return if missing.empty?
+
+ errors.add(:url, "Invalid URL template. Missing keys: #{missing}")
+ end
end
diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb
index fc881e62efd..3c581f0489a 100644
--- a/app/models/incident_management/issuable_escalation_status.rb
+++ b/app/models/incident_management/issuable_escalation_status.rb
@@ -7,7 +7,7 @@ module IncidentManagement
self.table_name = 'incident_management_issuable_escalation_statuses'
belongs_to :issue
- has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status
+ has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_statuses
validates :issue, presence: true, uniqueness: true
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 726e95b7cbf..f5f701662e7 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -13,8 +13,6 @@ class Integration < ApplicationRecord
include IgnorableColumns
extend ::Gitlab::Utils::Override
- ignore_column :properties, remove_with: '15.1', remove_after: '2022-05-22'
-
UnknownType = Class.new(StandardError)
self.inheritance_column = :type_new
@@ -154,6 +152,8 @@ class Integration < ApplicationRecord
else
raise ArgumentError, "Unknown field storage: #{storage}"
end
+
+ boolean_accessor(name) if attrs[:type] == 'checkbox'
end
# :nocov:
@@ -200,14 +200,21 @@ class Integration < ApplicationRecord
# Provide convenient boolean accessor methods for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
def self.boolean_accessor(*args)
- prop_accessor(*args)
-
args.each do |arg|
+ # TODO: Allow legacy usage of `.boolean_accessor`, once all integrations
+ # are converted to the field DSL we can remove this and only call
+ # `.boolean_accessor` through `.field`.
+ #
+ # See https://gitlab.com/groups/gitlab-org/-/epics/7652
+ prop_accessor(arg) unless method_defined?(arg)
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
- def #{arg}
- return if properties.blank?
+ # Make the original getter available as a private method.
+ alias_method :#{arg}_before_type_cast, :#{arg}
+ private(:#{arg}_before_type_cast)
- Gitlab::Utils.to_boolean(properties['#{arg}'])
+ def #{arg}
+ Gitlab::Utils.to_boolean(#{arg}_before_type_cast)
end
def #{arg}?
@@ -494,16 +501,12 @@ class Integration < ApplicationRecord
self.class.event_names
end
- def event_field(event)
- nil
- end
-
def api_field_names
fields.reject { _1[:type] == 'password' }.pluck(:name)
end
- def global_fields
- fields
+ def form_fields
+ fields.reject { _1[:api_only] == true }
end
def configurable_events
@@ -574,11 +577,7 @@ class Integration < ApplicationRecord
def async_execute(data)
return unless supported_events.include?(data[:object_kind])
- if Feature.enabled?(:rename_integrations_workers)
- Integrations::ExecuteWorker.perform_async(id, data)
- else
- ProjectServiceWorker.perform_async(id, data)
- end
+ Integrations::ExecuteWorker.perform_async(id, data)
end
# override if needed
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
index d25bf8b1b1e..2cfd71c9eb2 100644
--- a/app/models/integrations/asana.rb
+++ b/app/models/integrations/asana.rb
@@ -4,9 +4,22 @@ require 'asana'
module Integrations
class Asana < Integration
- prop_accessor :api_key, :restrict_to_branch
validates :api_key, presence: true, if: :activated?
+ field :api_key,
+ type: 'password',
+ title: 'API key',
+ help: -> { s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') },
+ # Example Personal Access Token from Asana docs
+ placeholder: '0/68a9e79b868c6789e79a124c30b0',
+ required: true
+
+ field :restrict_to_branch,
+ title: -> { s_('Integrations|Restrict to branch (optional)') },
+ help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') }
+
def title
'Asana'
end
@@ -24,28 +37,6 @@ module Integrations
'asana'
end
- def fields
- [
- {
- type: 'password',
- name: 'api_key',
- title: 'API key',
- help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'),
- non_empty_password_title: s_('ProjectService|Enter new API key'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'),
- # Example Personal Access Token from Asana docs
- placeholder: '0/68a9e79b868c6789e79a124c30b0',
- required: true
- },
- {
- type: 'text',
- name: 'restrict_to_branch',
- title: 'Restrict to branch (optional)',
- help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.')
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb
index ccd24c1fb2c..88dbf2915ef 100644
--- a/app/models/integrations/assembla.rb
+++ b/app/models/integrations/assembla.rb
@@ -2,9 +2,18 @@
module Integrations
class Assembla < Integration
- prop_accessor :token, :subdomain
validates :token, presence: true, if: :activated?
+ field :token,
+ type: 'password',
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: '',
+ required: true
+
+ field :subdomain,
+ placeholder: ''
+
def title
'Assembla'
end
@@ -17,24 +26,6 @@ module Integrations
'assembla'
end
- def fields
- [
- {
- type: 'password',
- name: 'token',
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- placeholder: '',
- required: true
- },
- {
- type: 'text',
- name: 'subdomain',
- placeholder: ''
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 4e30c1ccc69..230dc6bb336 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -155,7 +155,6 @@ module Integrations
query_params[:os_authType] = 'basic'
params[:basic_auth] = basic_auth
- params[:use_read_total_timeout] = true
params
end
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index 33d4eecbf49..c7992e4083c 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Base class for Chat notifications services
+# Base class for Chat notifications integrations
# This class is not meant to be used directly, but only to inherit from.
module Integrations
@@ -46,7 +46,7 @@ module Integrations
# `notify_only_default_branch`. Now we have a string property named
# `branches_to_be_notified`. Instead of doing a background migration, we
# opted to set a value for the new property based on the old one, if
- # users haven't specified one already. When users edit the service and
+ # users haven't specified one already. When users edit the integration and
# select a value for this new property, it will override everything.
self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all"
@@ -78,7 +78,7 @@ module Integrations
type: 'select',
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}.freeze,
{
type: 'text',
@@ -118,7 +118,7 @@ module Integrations
event_type = data[:event_type] || object_kind
- channel_names = get_channel_field(event_type).presence || channel.presence
+ channel_names = event_channel_value(event_type).presence || channel.presence
channels = channel_names&.split(',')&.map(&:strip)
opts = {}
@@ -134,15 +134,13 @@ module Integrations
end
def event_channel_names
- supported_events.map { |event| event_channel_name(event) }
- end
+ return [] unless configurable_channels?
- def event_field(event)
- fields.find { |field| field[:name] == event_channel_name(event) }
+ supported_events.map { |event| event_channel_name(event) }
end
- def global_fields
- fields.reject { |field| field[:name].end_with?('channel') }
+ def form_fields
+ super.reject { |field| field[:name].end_with?('channel') }
end
def default_channel_placeholder
@@ -153,6 +151,21 @@ module Integrations
raise NotImplementedError
end
+ # With some integrations the webhook is already tied to a specific channel,
+ # for others the channels are configurable for each event.
+ def configurable_channels?
+ false
+ end
+
+ def event_channel_name(event)
+ EVENT_CHANNEL[event]
+ end
+
+ def event_channel_value(event)
+ field_name = event_channel_name(event)
+ self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
private
def log_usage(_, _)
@@ -213,21 +226,12 @@ module Integrations
end
end
- def get_channel_field(event)
- field_name = event_channel_name(event)
- self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend
- end
-
def build_event_channels
- supported_events.reduce([]) do |channels, event|
- channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder }
+ event_channel_names.map do |channel_field|
+ { type: 'text', name: channel_field, placeholder: default_channel_placeholder }
end
end
- def event_channel_name(event)
- EVENT_CHANNEL[event]
- end
-
def project_name
project.full_name
end
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index bffe87c21ee..fe4a2f43b13 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -94,7 +94,7 @@ module Integrations
result = false
begin
- response = Gitlab::HTTP.head(self.project_url, verify: true, use_read_total_timeout: true)
+ response = Gitlab::HTTP.head(self.project_url, verify: true)
if response
message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}"
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index 7889cd8f9a9..bf1358ac0f6 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -2,9 +2,34 @@
module Integrations
class Campfire < Integration
- prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
+ field :token,
+ type: 'password',
+ title: -> { _('Campfire token') },
+ help: -> { s_('CampfireService|API authentication token from Campfire.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: '',
+ required: true
+
+ field :subdomain,
+ title: -> { _('Campfire subdomain (optional)') },
+ placeholder: '',
+ help: -> do
+ ERB::Util.html_escape(
+ s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.')
+ ) % {
+ code_open: '<code>'.html_safe,
+ code_close: '</code>'.html_safe
+ }
+ end
+
+ field :room,
+ title: -> { _('Campfire room ID (optional)') },
+ placeholder: '123456',
+ help: -> { s_('CampfireService|From the end of the room URL.') }
+
def title
'Campfire'
end
@@ -15,42 +40,18 @@ module Integrations
def help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
- s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
+
+ ERB::Util.html_escape(
+ s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}')
+ ) % {
+ docs_link: docs_link.html_safe
+ }
end
def self.to_param
'campfire'
end
- def fields
- [
- {
- type: 'password',
- name: 'token',
- title: _('Campfire token'),
- help: s_('CampfireService|API authentication token from Campfire.'),
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- placeholder: '',
- required: true
- },
- {
- type: 'text',
- name: 'subdomain',
- title: _('Campfire subdomain (optional)'),
- placeholder: '',
- help: s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- },
- {
- type: 'text',
- name: 'room',
- title: _('Campfire room ID (optional)'),
- placeholder: '123456',
- help: s_('CampfireService|From the end of the room URL.')
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index 4e1d1993d02..c1c43af99bf 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -6,11 +6,14 @@ module Integrations
VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
- prop_accessor :confluence_url
-
validates :confluence_url, presence: true, if: :activated?
validate :validate_confluence_url_is_cloud, if: :activated?
+ field :confluence_url,
+ title: -> { s_('Confluence Cloud Workspace URL') },
+ placeholder: 'https://example.atlassian.net/wiki',
+ required: true
+
def self.to_param
'confluence'
end
@@ -38,18 +41,6 @@ module Integrations
end
end
- def fields
- [
- {
- type: 'text',
- name: 'confluence_url',
- title: s_('Confluence Cloud Workspace URL'),
- placeholder: 'https://example.atlassian.net/wiki',
- required: true
- }
- ]
- end
-
def testable?
false
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index bb0fb6b9079..97e586c0662 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -15,7 +15,75 @@ module Integrations
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
+ field :datadog_site,
+ placeholder: DEFAULT_DOMAIN,
+ help: -> do
+ ERB::Util.html_escape(
+ s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.')
+ ) % {
+ codeOpen: '<code>'.html_safe,
+ codeClose: '</code>'.html_safe
+ }
+ end
+
+ field :api_url,
+ title: -> { s_('DatadogIntegration|API URL') },
+ help: -> { s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.') }
+
+ field :api_key,
+ type: 'password',
+ title: -> { _('API key') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key') },
+ help: -> do
+ ERB::Util.html_escape(
+ s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.')
+ ) % {
+ linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
+ linkClose: '</a>'.html_safe
+ }
+ end,
+ required: true
+
+ field :archive_trace_events,
+ type: 'checkbox',
+ title: -> { s_('Logs') },
+ checkbox_label: -> { s_('Enable logs collection') },
+ help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') }
+
+ field :datadog_service,
+ title: -> { s_('DatadogIntegration|Service') },
+ placeholder: 'gitlab-ci',
+ help: -> { s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') }
+
+ field :datadog_env,
+ title: -> { s_('DatadogIntegration|Environment') },
+ placeholder: 'ci',
+ help: -> do
+ ERB::Util.html_escape(
+ s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{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
+ }
+ end
+
+ field :datadog_tags,
+ type: 'textarea',
+ title: -> { s_('DatadogIntegration|Tags') },
+ placeholder: "tag:value\nanother_tag:value",
+ help: -> do
+ 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
+ }
+ end
before_validation :strip_properties
@@ -77,92 +145,11 @@ module Integrations
end
def fields
- f = [
- {
- type: 'text',
- name: 'datadog_site',
- placeholder: DEFAULT_DOMAIN,
- help: ERB::Util.html_escape(
- s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.')
- ) % {
- codeOpen: '<code>'.html_safe,
- codeClose: '</code>'.html_safe
- },
- required: false
- },
- {
- type: 'text',
- name: 'api_url',
- title: s_('DatadogIntegration|API URL'),
- help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'),
- required: false
- },
- {
- type: 'password',
- name: 'api_key',
- title: _('API key'),
- non_empty_password_title: s_('ProjectService|Enter new API key'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'),
- help: ERB::Util.html_escape(
- s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.')
- ) % {
- linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
- linkClose: '</a>'.html_safe
- },
- required: true
- }
- ]
-
if Feature.enabled?(:datadog_integration_logs_collection, parent)
- f.append({
- type: 'checkbox',
- name: 'archive_trace_events',
- title: s_('Logs'),
- checkbox_label: s_('Enable logs collection'),
- help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'),
- required: false
- })
+ super
+ else
+ super.reject { _1.name == 'archive_trace_events' }
end
-
- f += [
- {
- type: 'text',
- name: 'datadog_service',
- title: s_('DatadogIntegration|Service'),
- placeholder: 'gitlab-ci',
- help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.')
- },
- {
- type: 'text',
- name: 'datadog_env',
- title: s_('DatadogIntegration|Environment'),
- placeholder: 'ci',
- help: ERB::Util.html_escape(
- s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{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
- }
- },
- {
- 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
- }
- }
- ]
-
- f
end
override :hook_url
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 790e41e5a2a..ecabf23c90b 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -23,10 +23,6 @@ module Integrations
s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
- def event_field(event)
- # No-op.
- end
-
def default_channel_placeholder
# No-op.
end
@@ -43,7 +39,7 @@ module Integrations
type: 'select',
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}
]
end
diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb
index 35524503dea..b1f72b7144e 100644
--- a/app/models/integrations/drone_ci.rb
+++ b/app/models/integrations/drone_ci.rb
@@ -60,8 +60,7 @@ module Integrations
response = Gitlab::HTTP.try_get(
commit_status_path(sha, ref),
verify: enable_ssl_verification,
- extra_log_info: { project_id: project_id },
- use_read_total_timeout: true
+ extra_log_info: { project_id: project_id }
)
status =
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index ab458bb2c27..ed12a3a8d63 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -6,12 +6,35 @@ module Integrations
RECIPIENTS_LIMIT = 750
- boolean_accessor :send_from_committer_email
- boolean_accessor :disable_diffs
- prop_accessor :recipients, :branches_to_be_notified
validates :recipients, presence: true, if: :validate_recipients?
validate :number_of_recipients_within_limit, if: :validate_recipients?
+ field :send_from_committer_email,
+ type: 'checkbox',
+ title: -> { s_("EmailsOnPushService|Send from committer") },
+ help: -> do
+ @help ||= begin
+ domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
+
+ s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains }
+ end
+ end
+
+ field :disable_diffs,
+ type: 'checkbox',
+ title: -> { s_("EmailsOnPushService|Disable code diffs") },
+ help: -> { s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }
+
+ field :branches_to_be_notified,
+ type: 'select',
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: branch_choices
+
+ field :recipients,
+ type: 'textarea',
+ placeholder: -> { s_('EmailsOnPushService|tanuki@example.com gitlab@example.com') },
+ help: -> { s_('EmailsOnPushService|Emails separated by whitespace.') }
+
def self.valid_recipients(recipients)
recipients.split.grep(Devise.email_regexp).uniq(&:downcase)
end
@@ -67,28 +90,6 @@ module Integrations
Gitlab::Utils.to_boolean(self.disable_diffs)
end
- def fields
- domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
- [
- { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"),
- help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } },
- { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
- help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
- {
- type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
- },
- {
- type: 'textarea',
- name: 'recipients',
- placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'),
- help: s_('EmailsOnPushService|Emails separated by whitespace.')
- }
- ]
- end
-
private
def number_of_recipients_within_limit
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index 18c48411e30..bc2ea193a84 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -2,9 +2,14 @@
module Integrations
class ExternalWiki < Integration
- prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
+ field :external_wiki_url,
+ title: -> { s_('ExternalWikiService|External wiki URL') },
+ placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') },
+ help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') },
+ required: true
+
def title
s_('ExternalWikiService|External wiki')
end
@@ -17,19 +22,6 @@ module Integrations
'external_wiki'
end
- def fields
- [
- {
- type: 'text',
- name: 'external_wiki_url',
- title: s_('ExternalWikiService|External wiki URL'),
- placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'),
- help: 'Enter the URL to the external wiki.',
- required: true
- }
- ]
- end
-
def help
docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
@@ -37,7 +29,7 @@ module Integrations
end
def execute(_data)
- response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true)
+ response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true)
response.body if response.code == 200
rescue StandardError
nil
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
index cbda418755b..53c8f5f623e 100644
--- a/app/models/integrations/field.rb
+++ b/app/models/integrations/field.rb
@@ -4,14 +4,16 @@ module Integrations
class Field
SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze
+ BOOLEAN_ATTRIBUTES = %i[required api_only exposes_secrets].freeze
+
ATTRIBUTES = %i[
- section type placeholder required choices value checkbox_label
+ section type placeholder choices value checkbox_label
title help
non_empty_password_help
non_empty_password_title
- api_only
- exposes_secrets
- ].freeze
+ ].concat(BOOLEAN_ATTRIBUTES).freeze
+
+ TYPES = %w[text textarea password checkbox select].freeze
attr_reader :name, :integration_class
@@ -22,6 +24,13 @@ module Integrations
attributes[:type] = SECRET_NAME.match?(@name) ? 'password' : type
attributes[:api_only] = api_only
@attributes = attributes.freeze
+
+ invalid_attributes = attributes.keys - ATTRIBUTES
+ if invalid_attributes.present?
+ raise ArgumentError, "Invalid attributes #{invalid_attributes.inspect}"
+ elsif !TYPES.include?(self[:type])
+ raise ArgumentError, "Invalid type #{self[:type].inspect}"
+ end
end
def [](key)
@@ -34,11 +43,19 @@ module Integrations
end
def secret?
- @attributes[:type] == 'password'
+ self[:type] == 'password'
end
ATTRIBUTES.each do |name|
define_method(name) { self[name] }
end
+
+ BOOLEAN_ATTRIBUTES.each do |name|
+ define_method("#{name}?") { !!self[name] }
+ end
+
+ TYPES.each do |type|
+ define_method("#{type}?") { self[:type] == type }
+ end
end
end
diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb
index 703d8013bab..52efb29f2c1 100644
--- a/app/models/integrations/flowdock.rb
+++ b/app/models/integrations/flowdock.rb
@@ -2,9 +2,16 @@
module Integrations
class Flowdock < Integration
- prop_accessor :token
validates :token, presence: true, if: :activated?
+ field :token,
+ type: 'password',
+ help: -> { s_('FlowdockService|Enter your Flowdock token.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: '1b609b52537...',
+ required: true
+
def title
'Flowdock'
end
@@ -22,20 +29,6 @@ module Integrations
'flowdock'
end
- def fields
- [
- {
- type: 'password',
- name: 'token',
- help: s_('FlowdockService|Enter your Flowdock token.'),
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- placeholder: '1b609b52537...',
- required: true
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 8c68c9ff95a..df112ad6ca8 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -19,9 +19,6 @@ module Integrations
s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
- def event_field(event)
- end
-
def default_channel_placeholder
end
@@ -42,7 +39,7 @@ module Integrations
type: 'select',
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}
]
end
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 44813795fc0..82981493822 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -4,7 +4,7 @@ module Integrations
class Harbor < Integration
prop_accessor :url, :project_name, :username, :password
- validates :url, public_url: true, presence: true, if: :activated?
+ validates :url, public_url: true, presence: true, addressable_url: { allow_localhost: false, allow_local_network: false }, if: :activated?
validates :project_name, presence: true, if: :activated?
validates :username, presence: true, if: :activated?
validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated?
diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb
index 780f4bef0c9..3f3e321f45e 100644
--- a/app/models/integrations/irker.rb
+++ b/app/models/integrations/irker.rb
@@ -4,13 +4,55 @@ require 'uri'
module Integrations
class Irker < Integration
- prop_accessor :server_host, :server_port, :default_irc_uri
- prop_accessor :recipients, :channels
- boolean_accessor :colorize_messages
validates :recipients, presence: true, if: :validate_recipients?
-
before_validation :get_channels
+ field :server_host,
+ placeholder: 'localhost',
+ title: -> { s_('IrkerService|Server host (optional)') },
+ help: -> { s_('IrkerService|irker daemon hostname (defaults to localhost).') }
+
+ field :server_port,
+ placeholder: 6659,
+ title: -> { s_('IrkerService|Server port (optional)') },
+ help: -> { s_('IrkerService|irker daemon port (defaults to 6659).') }
+
+ field :default_irc_uri,
+ title: -> { s_('IrkerService|Default IRC URI (optional)') },
+ help: -> { s_('IrkerService|URI to add before each recipient.') },
+ placeholder: 'irc://irc.network.net:6697/'
+
+ field :recipients,
+ type: 'textarea',
+ title: -> { s_('IrkerService|Recipients') },
+ placeholder: 'irc[s]://irc.network.net[:port]/#channel',
+ required: true,
+ help: -> do
+ recipients_docs_link = ActionController::Base.helpers.link_to(
+ s_('IrkerService|How to enter channels or users?'),
+ Rails.application.routes.url_helpers.help_page_url(
+ 'user/project/integrations/irker',
+ anchor: 'enter-irker-recipients'
+ ),
+ target: '_blank', rel: 'noopener noreferrer'
+ )
+
+ ERB::Util.html_escape(
+ s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}')
+ ) % {
+ recipients_docs_link: recipients_docs_link.html_safe
+ }
+ end
+
+ field :colorize_messages,
+ type: 'checkbox',
+ title: -> { _('Colorize messages') }
+
+ # NOTE: This field is only used internally to store the parsed
+ # channels from the `recipients` field, it should not be exposed
+ # in the UI or API.
+ prop_accessor :channels
+
def title
s_('IrkerService|irker (IRC gateway)')
end
@@ -30,17 +72,10 @@ module Integrations
def execute(data)
return unless supported_events.include?(data[:object_kind])
- if Feature.enabled?(:rename_integrations_workers)
- Integrations::IrkerWorker.perform_async(
- project_id, channels,
- colorize_messages, data, settings
- )
- else
- ::IrkerWorker.perform_async(
- project_id, channels,
- colorize_messages, data, settings
- )
- end
+ Integrations::IrkerWorker.perform_async(
+ project_id, channels,
+ colorize_messages, data, settings
+ )
end
def settings
@@ -50,34 +85,6 @@ module Integrations
}
end
- def fields
- recipients_docs_link = ActionController::Base.helpers.link_to(
- s_('IrkerService|How to enter channels or users?'),
- Rails.application.routes.url_helpers.help_page_url(
- 'user/project/integrations/irker',
- anchor: 'enter-irker-recipients'
- ),
- target: '_blank', rel: 'noopener noreferrer'
- )
-
- [
- { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'),
- help: s_('IrkerService|irker daemon hostname (defaults to localhost).') },
- { type: 'text', name: 'server_port', placeholder: 6659, title: s_('IrkerService|Server port (optional)'),
- help: s_('IrkerService|irker daemon port (defaults to 6659).') },
- { type: 'text', name: 'default_irc_uri', title: s_('IrkerService|Default IRC URI (optional)'),
- help: s_('IrkerService|URI to add before each recipient.'),
- placeholder: 'irc://irc.network.net:6697/' },
- { type: 'textarea', name: 'recipients', title: s_('IrkerService|Recipients'),
- placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true,
- help: format(
- s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}').html_safe,
- recipients_docs_link: recipients_docs_link.html_safe
- ) },
- { type: 'checkbox', name: 'colorize_messages', title: _('Colorize messages') }
- ]
- end
-
def help
docs_link = ActionController::Base.helpers.link_to(
_('Learn more.'),
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 125f52104d4..c9c9b9d59d6 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -71,11 +71,12 @@ module Integrations
non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') },
help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') }
+ field :jira_issue_transition_id, api_only: true
+
# TODO: we can probably just delegate as part of
# https://gitlab.com/gitlab-org/gitlab/issues/29404
# These fields are API only, so no field definition is required.
data_field :jira_issue_transition_automatic
- data_field :jira_issue_transition_id
data_field :project_key
data_field :issues_enabled
data_field :vulnerabilities_enabled
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index d9ccbb7ea34..dae11b99bc5 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -3,6 +3,7 @@
module Integrations
class Mattermost < BaseChatNotification
include SlackMattermostNotifier
+ extend ::Gitlab::Utils::Override
def title
s_('Mattermost notifications')
@@ -28,5 +29,10 @@ module Integrations
def webhook_placeholder
'http://mattermost.example.com/hooks/'
end
+
+ override :configurable_channels?
+ def configurable_channels?
+ true
+ end
end
end
diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb
index 625ee0bc522..69863f164cd 100644
--- a/app/models/integrations/microsoft_teams.rb
+++ b/app/models/integrations/microsoft_teams.rb
@@ -22,9 +22,6 @@ module Integrations
'https://outlook.office.com/webhook/…'
end
- def event_field(event)
- end
-
def default_channel_placeholder
end
@@ -47,7 +44,7 @@ module Integrations
section: SECTION_TYPE_CONFIGURATION,
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}
]
end
diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb
index 0b3a9bc5405..2d8e26d409f 100644
--- a/app/models/integrations/mock_ci.rb
+++ b/app/models/integrations/mock_ci.rb
@@ -49,7 +49,7 @@ module Integrations
# # => 'running'
#
def commit_status(sha, ref)
- response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification, use_read_total_timeout: true)
+ response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification)
read_commit_status(response)
rescue Errno::ECONNREFUSED
:error
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index 758c9e4761b..05ee919892d 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -5,7 +5,25 @@ module Integrations
include HasWebHook
extend Gitlab::Utils::Override
- prop_accessor :username, :token, :server
+ field :username,
+ title: -> { _('Username') },
+ help: -> { s_('Enter your Packagist username.') },
+ placeholder: '',
+ required: true
+
+ field :token,
+ type: 'password',
+ title: -> { _('Token') },
+ help: -> { s_('Enter your Packagist token.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: '',
+ required: true
+
+ field :server,
+ title: -> { _('Server (optional)') },
+ help: -> { s_('Enter your Packagist server. Defaults to https://packagist.org.') },
+ placeholder: 'https://packagist.org'
validates :username, presence: true, if: :activated?
validates :token, presence: true, if: :activated?
@@ -22,37 +40,6 @@ module Integrations
'packagist'
end
- def fields
- [
- {
- type: 'text',
- name: 'username',
- title: _('Username'),
- help: s_('Enter your Packagist username.'),
- placeholder: '',
- required: true
- },
- {
- type: 'password',
- name: 'token',
- title: _('Token'),
- help: s_('Enter your Packagist token.'),
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- placeholder: '',
- required: true
- },
- {
- type: 'text',
- name: 'server',
- title: _('Server (optional)'),
- help: s_('Enter your Packagist server. Defaults to https://packagist.org.'),
- placeholder: 'https://packagist.org',
- required: false
- }
- ]
- end
-
def self.supported_events
%w(push merge_request tag_push)
end
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index f15482dc2e1..77cbba25f2c 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -6,11 +6,26 @@ module Integrations
RECIPIENTS_LIMIT = 30
- prop_accessor :recipients, :branches_to_be_notified
- boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
validates :recipients, presence: true, if: :validate_recipients?
validate :number_of_recipients_within_limit, if: :validate_recipients?
+ field :recipients,
+ type: 'textarea',
+ help: -> { _('Comma-separated list of email addresses.') },
+ required: true
+
+ field :notify_only_broken_pipelines,
+ type: 'checkbox'
+
+ field :notify_only_default_branch,
+ type: 'checkbox',
+ api_only: true
+
+ field :branches_to_be_notified,
+ type: 'select',
+ title: -> { s_('Integrations|Branches for which notifications are to be sent') },
+ choices: branch_choices
+
def initialize_properties
super
@@ -65,21 +80,6 @@ module Integrations
project&.ci_pipelines&.any?
end
- def fields
- [
- { type: 'textarea',
- name: 'recipients',
- help: _('Comma-separated list of email addresses.'),
- required: true },
- { type: 'checkbox',
- name: 'notify_only_broken_pipelines' },
- { type: 'select',
- name: 'branches_to_be_notified',
- title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices }
- ]
- end
-
def test(data)
result = execute(data, force: true)
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index 931ccf46655..d32fb974339 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -4,9 +4,22 @@ module Integrations
class Pivotaltracker < Integration
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'
- prop_accessor :token, :restrict_to_branch
validates :token, presence: true, if: :activated?
+ field :token,
+ type: 'password',
+ help: -> { s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ required: true
+
+ field :restrict_to_branch,
+ title: -> { s_('Integrations|Restrict to branch (optional)') },
+ help: -> do
+ s_('PivotalTrackerService|Comma-separated list of branches to ' \
+ 'automatically inspect. Leave blank to include all branches.')
+ end
+
def title
'Pivotal Tracker'
end
@@ -24,26 +37,6 @@ module Integrations
'pivotaltracker'
end
- def fields
- [
- {
- type: 'password',
- name: 'token',
- help: s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.'),
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- required: true
- },
- {
- type: 'text',
- name: 'restrict_to_branch',
- title: 'Restrict to branch (optional)',
- help: s_('PivotalTrackerService|Comma-separated list of branches to ' \
- 'automatically inspect. Leave blank to include all branches.')
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 36060565317..e672a985810 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -4,11 +4,30 @@ module Integrations
class Prometheus < BaseMonitoring
include PrometheusAdapter
- # Access to prometheus is directly through the API
- prop_accessor :api_url
- prop_accessor :google_iap_service_account_json
- prop_accessor :google_iap_audience_client_id
- boolean_accessor :manual_configuration
+ field :manual_configuration,
+ type: 'checkbox',
+ title: -> { s_('PrometheusService|Active') },
+ help: -> { s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.') },
+ required: true
+
+ field :api_url,
+ title: 'API URL',
+ placeholder: -> { s_('PrometheusService|https://prometheus.example.com/') },
+ help: -> { s_('PrometheusService|The Prometheus API base URL.') },
+ required: true
+
+ field :google_iap_audience_client_id,
+ title: 'Google IAP Audience Client ID',
+ placeholder: -> { s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com') },
+ help: -> { s_('PrometheusService|The ID of the IAP-secured resource.') },
+ required: false
+
+ field :google_iap_service_account_json,
+ type: 'textarea',
+ title: 'Google IAP Service Account JSON',
+ placeholder: -> { s_('PrometheusService|{ "type": "service_account", "project_id": ... }') },
+ help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') },
+ required: false
# We need to allow the self-monitoring project to connect to the internal
# Prometheus instance.
@@ -45,43 +64,6 @@ module Integrations
'prometheus'
end
- def fields
- [
- {
- type: 'checkbox',
- name: 'manual_configuration',
- title: s_('PrometheusService|Active'),
- help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
- required: true
- },
- {
- type: 'text',
- name: 'api_url',
- title: 'API URL',
- placeholder: s_('PrometheusService|https://prometheus.example.com/'),
- help: s_('PrometheusService|The Prometheus API base URL.'),
- required: true
- },
- {
- type: 'text',
- name: 'google_iap_audience_client_id',
- title: 'Google IAP Audience Client ID',
- placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
- help: s_('PrometheusService|The ID of the IAP-secured resource.'),
- autocomplete: 'off',
- required: false
- },
- {
- type: 'textarea',
- name: 'google_iap_service_account_json',
- title: 'Google IAP Service Account JSON',
- placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
- help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
- required: false
- }
- ]
- end
-
# Check we can connect to the Prometheus API
def test(*args)
return { success: false, result: 'Prometheus configuration error' } unless prometheus_client
diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb
index 7fd5efa8765..791e27c5db7 100644
--- a/app/models/integrations/pushover.rb
+++ b/app/models/integrations/pushover.rb
@@ -4,9 +4,73 @@ module Integrations
class Pushover < Integration
BASE_URI = 'https://api.pushover.net/1'
- prop_accessor :api_key, :user_key, :device, :priority, :sound
validates :api_key, :user_key, :priority, presence: true, if: :activated?
+ field :api_key,
+ type: 'password',
+ title: -> { _('API key') },
+ help: -> { s_('PushoverService|Enter your application key.') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new API key') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current API key.') },
+ placeholder: '',
+ required: true
+
+ field :user_key,
+ type: 'password',
+ title: -> { _('User key') },
+ help: -> { s_('PushoverService|Enter your user key.') },
+ non_empty_password_title: -> { s_('PushoverService|Enter new user key') },
+ non_empty_password_help: -> { s_('PushoverService|Leave blank to use your current user key.') },
+ placeholder: '',
+ required: true
+
+ field :device,
+ title: -> { _('Devices (optional)') },
+ help: -> { s_('PushoverService|Leave blank for all active devices.') },
+ placeholder: ''
+
+ field :priority,
+ type: 'select',
+ required: true,
+ choices: -> do
+ [
+ [s_('PushoverService|Lowest priority'), -2],
+ [s_('PushoverService|Low priority'), -1],
+ [s_('PushoverService|Normal priority'), 0],
+ [s_('PushoverService|High priority'), 1]
+ ]
+ end
+
+ field :sound,
+ type: 'select',
+ choices: -> do
+ [
+ ['Device default sound', nil],
+ ['Pushover (default)', 'pushover'],
+ %w(Bike bike),
+ %w(Bugle bugle),
+ ['Cash Register', 'cashregister'],
+ %w(Classical classical),
+ %w(Cosmic cosmic),
+ %w(Falling falling),
+ %w(Gamelan gamelan),
+ %w(Incoming incoming),
+ %w(Intermission intermission),
+ %w(Magic magic),
+ %w(Mechanical mechanical),
+ ['Piano Bar', 'pianobar'],
+ %w(Siren siren),
+ ['Space Alarm', 'spacealarm'],
+ ['Tug Boat', 'tugboat'],
+ ['Alien Alarm (long)', 'alien'],
+ ['Climb (long)', 'climb'],
+ ['Persistent (long)', 'persistent'],
+ ['Pushover Echo (long)', 'echo'],
+ ['Up Down (long)', 'updown'],
+ ['None (silent)', 'none']
+ ]
+ end
+
def title
'Pushover'
end
@@ -19,81 +83,6 @@ module Integrations
'pushover'
end
- def fields
- [
- {
- type: 'password',
- name: 'api_key',
- title: _('API key'),
- help: s_('PushoverService|Enter your application key.'),
- non_empty_password_title: s_('ProjectService|Enter new API key'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current API key.'),
- placeholder: '',
- required: true
- },
- {
- type: 'password',
- name: 'user_key',
- title: _('User key'),
- help: s_('PushoverService|Enter your user key.'),
- non_empty_password_title: s_('PushoverService|Enter new user key'),
- non_empty_password_help: s_('PushoverService|Leave blank to use your current user key.'),
- placeholder: '',
- required: true
- },
- {
- type: 'text',
- name: 'device',
- title: _('Devices (optional)'),
- help: s_('PushoverService|Leave blank for all active devices.'),
- placeholder: ''
- },
- {
- type: 'select',
- name: 'priority',
- required: true,
- choices:
- [
- [s_('PushoverService|Lowest priority'), -2],
- [s_('PushoverService|Low priority'), -1],
- [s_('PushoverService|Normal priority'), 0],
- [s_('PushoverService|High priority'), 1]
- ],
- default_choice: 0
- },
- {
- type: 'select',
- name: 'sound',
- choices:
- [
- ['Device default sound', nil],
- ['Pushover (default)', 'pushover'],
- %w(Bike bike),
- %w(Bugle bugle),
- ['Cash Register', 'cashregister'],
- %w(Classical classical),
- %w(Cosmic cosmic),
- %w(Falling falling),
- %w(Gamelan gamelan),
- %w(Incoming incoming),
- %w(Intermission intermission),
- %w(Magic magic),
- %w(Mechanical mechanical),
- ['Piano Bar', 'pianobar'],
- %w(Siren siren),
- ['Space Alarm', 'spacealarm'],
- ['Tug Boat', 'tugboat'],
- ['Alien Alarm (long)', 'alien'],
- ['Climb (long)', 'climb'],
- ['Persistent (long)', 'persistent'],
- ['Pushover Echo (long)', 'echo'],
- ['Up Down (long)', 'updown'],
- ['None (silent)', 'none']
- ]
- }
- ]
- end
-
def self.supported_events
%w(push)
end
diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb
index dd25a0bc558..8bc296e0320 100644
--- a/app/models/integrations/shimo.rb
+++ b/app/models/integrations/shimo.rb
@@ -2,9 +2,12 @@
module Integrations
class Shimo < BaseThirdPartyWiki
- prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
+ field :external_wiki_url,
+ title: -> { s_('Shimo|Shimo Workspace URL') },
+ required: true
+
def render?
return false unless Feature.enabled?(:shimo_integration, project)
@@ -25,21 +28,10 @@ module Integrations
# support for `test` method
def execute(_data)
- response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true)
+ response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true)
response.body if response.code == 200
rescue StandardError
nil
end
-
- def fields
- [
- {
- type: 'text',
- name: 'external_wiki_url',
- title: s_('Shimo|Shimo Workspace URL'),
- required: true
- }
- ]
- end
end
end
diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb
index 0381db3a67e..93263229109 100644
--- a/app/models/integrations/slack.rb
+++ b/app/models/integrations/slack.rb
@@ -55,5 +55,10 @@ module Integrations
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id)
end
+
+ override :configurable_channels?
+ def configurable_channels?
+ true
+ end
end
end
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index a23aa5f783d..e0299c9ac5f 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -156,7 +156,7 @@ module Integrations
end
def get_path(path)
- Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }, use_read_total_timeout: true)
+ Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id })
end
def post_to_build_queue(data, branch)
@@ -167,8 +167,7 @@ module Integrations
'</build>',
headers: { 'Content-type' => 'application/xml' },
verify: enable_ssl_verification,
- basic_auth: basic_auth,
- use_read_total_timeout: true
+ basic_auth: basic_auth
)
end
diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb
index f085423d229..f10a75fac5d 100644
--- a/app/models/integrations/unify_circuit.rb
+++ b/app/models/integrations/unify_circuit.rb
@@ -19,9 +19,6 @@ module Integrations
s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
- def event_field(event)
- end
-
def default_channel_placeholder
end
@@ -38,7 +35,7 @@ module Integrations
type: 'select',
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}
]
end
@@ -49,8 +46,7 @@ module Integrations
response = Gitlab::HTTP.post(webhook, body: {
subject: message.project_name,
text: message.summary,
- markdown: true,
- use_read_total_timeout: true
+ markdown: true
}.to_json)
response if response.success?
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 345dd98cbc1..75be457dcf5 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -19,9 +19,6 @@ module Integrations
s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe }
end
- def event_field(event)
- end
-
def default_channel_placeholder
end
@@ -38,7 +35,7 @@ module Integrations
type: 'select',
name: 'branches_to_be_notified',
title: s_('Integrations|Branches for which notifications are to be sent'),
- choices: branch_choices
+ choices: self.class.branch_choices
}
]
end
@@ -47,7 +44,7 @@ module Integrations
def notify(message, opts)
header = { 'Content-Type' => 'application/json' }
- response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json, use_read_total_timeout: true)
+ response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json)
response if response.success?
end
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index ab6e1da27f8..fa719f925ed 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -33,10 +33,7 @@ module Integrations
end
def fields
- [
- { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true },
- { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }
- ]
+ super.select { _1.name.in?(%w[project_url issues_url]) }
end
end
end
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index c33df465fde..11db469f7ee 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -4,7 +4,28 @@ module Integrations
class Zentao < Integration
include Gitlab::Routing
- data_field :url, :api_url, :api_token, :zentao_product_xid
+ self.field_storage = :data_fields
+
+ field :url,
+ title: -> { s_('ZentaoIntegration|ZenTao Web URL') },
+ placeholder: 'https://www.zentao.net',
+ help: -> { s_('ZentaoIntegration|Base URL of the ZenTao instance.') },
+ required: true
+
+ field :api_url,
+ title: -> { s_('ZentaoIntegration|ZenTao API URL (optional)') },
+ help: -> { s_('ZentaoIntegration|If different from Web URL.') }
+
+ field :api_token,
+ type: 'password',
+ title: -> { s_('ZentaoIntegration|ZenTao API token') },
+ non_empty_password_title: -> { s_('ZentaoIntegration|Enter new ZenTao API token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ required: true
+
+ field :zentao_product_xid,
+ title: -> { s_('ZentaoIntegration|ZenTao Product ID') },
+ required: true
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
@@ -47,39 +68,6 @@ module Integrations
%w()
end
- def fields
- [
- {
- type: 'text',
- name: 'url',
- title: s_('ZentaoIntegration|ZenTao Web URL'),
- placeholder: 'https://www.zentao.net',
- help: s_('ZentaoIntegration|Base URL of the ZenTao instance.'),
- required: true
- },
- {
- type: 'text',
- name: 'api_url',
- title: s_('ZentaoIntegration|ZenTao API URL (optional)'),
- help: s_('ZentaoIntegration|If different from Web URL.')
- },
- {
- type: 'password',
- name: 'api_token',
- title: s_('ZentaoIntegration|ZenTao API token'),
- non_empty_password_title: s_('ZentaoIntegration|Enter new ZenTao API token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- required: true
- },
- {
- type: 'text',
- name: 'zentao_product_xid',
- title: s_('ZentaoIntegration|ZenTao Product ID'),
- required: true
- }
- ]
- end
-
private
def client
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 47aa2b24feb..cae42115bef 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -46,7 +46,7 @@ class Issue < ApplicationRecord
TYPES_FOR_LIST = %w(issue incident).freeze
belongs_to :project
- has_one :namespace, through: :project
+ belongs_to :namespace, inverse_of: :issues
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
@@ -98,6 +98,7 @@ class Issue < ApplicationRecord
validates :project, presence: true
validates :issue_type, presence: true
+ validates :namespace, presence: true, if: -> { project.present? }
enum issue_type: WorkItems::Type.base_types
@@ -123,8 +124,24 @@ class Issue < ApplicationRecord
scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) }
scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
- scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') }
- scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
+ scope :order_severity_asc, -> do
+ build_keyset_order_on_joined_column(
+ scope: includes(:issuable_severity),
+ attribute_name: 'issuable_severities_severity',
+ column: IssuableSeverity.arel_table[:severity],
+ direction: :asc,
+ nullable: :nulls_first
+ )
+ end
+ scope :order_severity_desc, -> do
+ build_keyset_order_on_joined_column(
+ scope: includes(:issuable_severity),
+ attribute_name: 'issuable_severities_severity',
+ column: IssuableSeverity.arel_table[:severity],
+ direction: :desc,
+ nullable: :nulls_last
+ )
+ end
scope :order_escalation_status_asc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].asc.nulls_last).references(:incident_management_issuable_escalation_status) }
scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) }
scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) }
@@ -184,6 +201,8 @@ class Issue < ApplicationRecord
scope :with_null_relative_position, -> { where(relative_position: nil) }
scope :with_non_null_relative_position, -> { where.not(relative_position: nil) }
+ before_validation :ensure_namespace_id
+
after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing?
after_create_commit :record_create_action, unless: :importing?
@@ -231,6 +250,31 @@ class Issue < ApplicationRecord
alias_method :with_state, :with_state_id
alias_method :with_states, :with_state_ids
+ def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:)
+ reversed_direction = direction == :asc ? :desc : :asc
+
+ # rubocop: disable GitlabSecurity/PublicSend
+ order = ::Gitlab::Pagination::Keyset::Order.build([
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: attribute_name,
+ column_expression: column,
+ order_expression: column.send(direction).send(nullable),
+ reversed_order_expression: column.send(reversed_direction).send(nullable),
+ order_direction: direction,
+ distinct: false,
+ add_to_projections: true,
+ nullable: nullable
+ ),
+ ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table['id'].desc
+ )
+ ])
+ # rubocop: enable GitlabSecurity/PublicSend
+
+ order.apply_cursor_conditions(scope).order(order)
+ end
+
override :order_upvotes_desc
def order_upvotes_desc
reorder(upvotes_count: :desc)
@@ -328,11 +372,11 @@ class Issue < ApplicationRecord
when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc
when 'due_date_desc' then order_due_date_desc.with_order_id_desc
when 'relative_position', 'relative_position_asc' then order_by_relative_position
- when 'severity_asc' then order_severity_asc.with_order_id_desc
- when 'severity_desc' then order_severity_desc.with_order_id_desc
- when 'escalation_status_asc' then order_escalation_status_asc.with_order_id_desc
- when 'escalation_status_desc' then order_escalation_status_desc.with_order_id_desc
- when 'closed_at_asc' then order_closed_at_asc
+ when 'severity_asc' then order_severity_asc
+ when 'severity_desc' then order_severity_desc
+ when 'escalation_status_asc' then order_escalation_status_asc
+ when 'escalation_status_desc' then order_escalation_status_desc
+ when 'closed_at', 'closed_at_asc' then order_closed_at_asc
when 'closed_at_desc' then order_closed_at_desc
else
super
@@ -405,14 +449,6 @@ class Issue < ApplicationRecord
end
end
- # Returns boolean if a related branch exists for the current issue
- # ignores merge requests branchs
- def has_related_branch?
- project.repository.branch_names.any? do |branch|
- /\A#{iid}-(?!\d+-stable)/i =~ branch
- end
- end
-
# To allow polymorphism with MergeRequest.
def source_project
project
@@ -656,6 +692,10 @@ class Issue < ApplicationRecord
# Symptom of running out of space - schedule rebalancing
Issues::RebalancingWorker.perform_async(nil, *project.self_or_root_group_ids)
end
+
+ def ensure_namespace_id
+ self.namespace = project.project_namespace if project
+ end
end
Issue.prepend_mod_with('Issue')
diff --git a/app/models/key.rb b/app/models/key.rb
index 5268ce2e040..9f6029cc5d4 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -28,7 +28,7 @@ class Key < ApplicationRecord
validate :key_meets_restrictions
validate :expiration, on: :create
- validate :banned_key, if: :should_check_for_banned_key?
+ validate :banned_key, if: :key_changed?
delegate :name, :email, to: :user, prefix: true
@@ -121,6 +121,12 @@ class Key < ApplicationRecord
@public_key ||= Gitlab::SSHPublicKey.new(key)
end
+ def ensure_sha256_fingerprint!
+ return if self.fingerprint_sha256
+
+ save if generate_fingerprint
+ end
+
private
def generate_fingerprint
@@ -143,12 +149,6 @@ class Key < ApplicationRecord
end
end
- def should_check_for_banned_key?
- return false unless user
-
- key_changed? && Feature.enabled?(:ssh_banned_key, user)
- end
-
def banned_key
return unless public_key.banned?
diff --git a/app/models/member.rb b/app/models/member.rb
index bb5d2b10f8e..dcca63b5691 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -219,7 +219,23 @@ class Member < ApplicationRecord
class << self
def search(query)
- joins(:user).merge(User.search(query, use_minimum_char_limit: false))
+ scope = joins(:user).merge(User.search(query, use_minimum_char_limit: false))
+
+ return scope unless Gitlab::Pagination::Keyset::Order.keyset_aware?(scope)
+
+ # If the User.search method returns keyset pagination aware AR scope then we
+ # need call apply_cursor_conditions which adds the ORDER BY columns from the scope
+ # to the SELECT clause.
+ #
+ # Why is this needed:
+ # When using keyset pagination, the next page is loaded using the ORDER BY
+ # values of the last record (cursor). This query selects `members.*` and
+ # orders by a custom SQL expression on `users` and `users.name`. The values
+ # will not be part of `members.*`.
+ #
+ # Result: `SELECT members.*, users.column1, users.column2 FROM members ...`
+ order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope)
+ order.apply_cursor_conditions(scope).reorder(order)
end
def search_invite_email(query)
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 791cb6f0dff..c97f00364fd 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -21,30 +21,30 @@ class ProjectMember < Member
end
class << self
- # Add users to projects with passed access option
+ # Add members to projects with passed access option
#
# access can be an integer representing a access code
# or symbol like :maintainer representing role
#
# Ex.
- # add_users_to_projects(
+ # add_members_to_projects(
# project_ids,
# user_ids,
# ProjectMember::MAINTAINER
# )
#
- # add_users_to_projects(
+ # add_members_to_projects(
# project_ids,
# user_ids,
# :maintainer
# )
#
- def add_users_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil)
+ def add_members_to_projects(project_ids, users, access_level, current_user: nil, expires_at: nil)
self.transaction do
project_ids.each do |project_id|
project = Project.find(project_id)
- Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
@@ -111,7 +111,7 @@ class ProjectMember < Member
# rubocop:disable CodeReuse/ServiceClass
if blocking
- AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute
+ AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.bulk_perform_and_wait([[project.id, user.id]])
else
AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker.perform_async(project.id, user.id)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 1a3464d05a2..ec97ab0ea42 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -174,6 +174,10 @@ class MergeRequest < ApplicationRecord
merge_request.merge_jid = nil
end
+ before_transition any => :closed do |merge_request|
+ merge_request.merge_error = nil
+ end
+
after_transition any => :opened do |merge_request|
merge_request.run_after_commit do
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
@@ -1567,6 +1571,7 @@ class MergeRequest < ApplicationRecord
variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path)
variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url)
variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s)
variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title)
variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present?
variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 87afb7a489a..e08b2cc2a7d 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -21,6 +21,10 @@ class MergeRequestDiff < ApplicationRecord
# from the database if this sentinel is seen
FILES_COUNT_SENTINEL = 2**15 - 1
+ # External diff cache key used by diffs export
+ EXTERNAL_DIFFS_CACHE_TMPDIR = 'project-%{project_id}-external-mr-%{mr_id}-diff-%{id}-cache'
+ EXTERNAL_DIFF_CACHE_CHUNK_SIZE = 8.megabytes
+
belongs_to :merge_request
manual_inverse_association :merge_request, :merge_request_diff
@@ -545,6 +549,28 @@ class MergeRequestDiff < ApplicationRecord
merge_request_diff_files.reset
end
+ # Yields locally cached external diff if it's externally stored.
+ # Used during Project Export to speed up externally
+ # stored merge request diffs export
+ def cached_external_diff
+ return yield(nil) unless stored_externally?
+
+ cache_external_diff unless File.exist?(external_diff_cache_filepath)
+
+ File.open(external_diff_cache_filepath) do |file|
+ yield(file)
+ end
+ end
+
+ def remove_cached_external_diff
+ Gitlab::Utils.check_path_traversal!(external_diff_cache_dir)
+ Gitlab::Utils.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir])
+
+ return unless Dir.exist?(external_diff_cache_dir)
+
+ FileUtils.rm_rf(external_diff_cache_dir)
+ end
+
private
def convert_external_diffs_to_database
@@ -791,6 +817,31 @@ class MergeRequestDiff < ApplicationRecord
def sort_diffs(diffs)
Gitlab::Diff::FileCollectionSorter.new(diffs).sort
end
+
+ # Downloads external diff to a temp storage location.
+ def cache_external_diff
+ return unless stored_externally?
+ return if File.exist?(external_diff_cache_filepath)
+
+ Dir.mkdir(external_diff_cache_dir) unless Dir.exist?(external_diff_cache_dir)
+
+ opening_external_diff do |external_diff|
+ File.open(external_diff_cache_filepath, 'wb') do |file|
+ file.write(external_diff.read(EXTERNAL_DIFF_CACHE_CHUNK_SIZE)) until external_diff.eof?
+ end
+ end
+ end
+
+ def external_diff_cache_filepath
+ File.join(external_diff_cache_dir, "diff-#{id}")
+ end
+
+ def external_diff_cache_dir
+ File.join(
+ Dir.tmpdir,
+ EXTERNAL_DIFFS_CACHE_TMPDIR % { project_id: project.id, mr_id: merge_request_id, id: id }
+ )
+ end
end
MergeRequestDiff.prepend_mod_with('MergeRequestDiff')
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index f7648937c1d..36902e43a77 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -15,7 +15,12 @@ class MergeRequestDiffFile < ApplicationRecord
end
def utf8_diff
- fetched_diff = diff
+ fetched_diff = if Feature.enabled?(:externally_stored_diffs_caching_export) &&
+ merge_request_diff&.stored_externally?
+ diff_export
+ else
+ diff
+ end
return '' if fetched_diff.blank?
@@ -45,4 +50,40 @@ class MergeRequestDiffFile < ApplicationRecord
content
end
end
+
+ private
+
+ # This method is meant to be used during Project Export.
+ # It is identical to the behaviour in #diff with the only
+ # difference of caching externally stored diffs on local disk in
+ # temp storage location in order to improve diff export performance.
+ def diff_export
+ content = merge_request_diff.cached_external_diff do |file|
+ file.seek(external_diff_offset)
+
+ force_encode_utf8(file.read(external_diff_size))
+ end
+
+ # See #diff
+ if binary?
+ content = begin
+ content.unpack1('m0')
+ rescue ArgumentError
+ content
+ end
+ end
+
+ content
+ rescue StandardError => e
+ log_payload = {
+ message: 'Cached external diff export failed',
+ merge_request_diff_file_id: id,
+ merge_request_diff_id: merge_request_diff&.id
+ }
+
+ Gitlab::ExceptionLogFormatter.format!(e, log_payload)
+ Gitlab::AppLogger.warn(log_payload)
+
+ diff
+ end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5bb06cdbb4a..f23a859b119 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -74,6 +74,8 @@ class Namespace < ApplicationRecord
has_many :sync_events, class_name: 'Namespaces::SyncEvent'
has_one :cluster_enabled_grant, inverse_of: :namespace, class_name: 'Clusters::ClusterEnabledGrant'
+ has_many :work_items, inverse_of: :namespace
+ has_many :issues, inverse_of: :namespace
validates :owner, presence: true, if: ->(n) { n.owner_required? }
validates :name,
@@ -341,6 +343,10 @@ class Namespace < ApplicationRecord
end
end
+ def emails_enabled?
+ !emails_disabled?
+ end
+
def lfs_enabled?
# User namespace will always default to the global setting
Gitlab.config.lfs.enabled
@@ -450,9 +456,14 @@ class Namespace < ApplicationRecord
end
def pages_virtual_domain
+ cache = if Feature.enabled?(:cache_pages_domain_api, root_ancestor)
+ ::Gitlab::Pages::CacheControl.for_namespace(root_ancestor.id)
+ end
+
Pages::VirtualDomain.new(
- all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment),
- trim_prefix: full_path
+ projects: all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment),
+ trim_prefix: full_path,
+ cache: cache
)
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 504daf2662e..595e34821af 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -24,14 +24,27 @@ class NamespaceSetting < ApplicationRecord
chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval
chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval
- NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal,
- :lock_delayed_project_removal, :resource_access_token_creation_allowed,
- :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap,
- :setup_for_company, :jobs_to_be_done, :runner_token_expiration_interval, :enabled_git_access_protocol,
- :subgroup_runner_token_expiration_interval, :project_runner_token_expiration_interval].freeze
+ NAMESPACE_SETTINGS_PARAMS = %i[
+ default_branch_name
+ delayed_project_removal
+ lock_delayed_project_removal
+ resource_access_token_creation_allowed
+ prevent_sharing_groups_outside_hierarchy
+ new_user_signups_cap
+ setup_for_company
+ jobs_to_be_done
+ runner_token_expiration_interval
+ enabled_git_access_protocol
+ subgroup_runner_token_expiration_interval
+ project_runner_token_expiration_interval
+ ].freeze
self.primary_key = :namespace_id
+ def self.allowed_namespace_settings_params
+ NAMESPACE_SETTINGS_PARAMS
+ end
+
sanitizes! :default_branch_name
def prevent_sharing_groups_outside_hierarchy
diff --git a/app/models/note.rb b/app/models/note.rb
index 41e45a8759f..986a85acac6 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -111,6 +111,7 @@ class Note < ApplicationRecord
end
validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?]
+ validate :validate_created_after
# @deprecated attachments are handled by the Upload model.
#
@@ -665,6 +666,25 @@ class Note < ApplicationRecord
)
end
+ def mentioned_users(current_user = nil)
+ users = super
+
+ return users unless confidential?
+
+ Ability.users_that_can_read_internal_notes(users, resource_parent)
+ end
+
+ def mentioned_filtered_user_ids_for(references)
+ return super unless confidential?
+
+ user_ids = references.mentioned_user_ids.presence
+
+ return [] if user_ids.blank?
+
+ users = User.where(id: user_ids)
+ Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id)
+ end
+
private
def system_note_viewable_by?(user)
@@ -729,6 +749,13 @@ class Note < ApplicationRecord
errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
end
+ def validate_created_after
+ return unless created_at
+ return if created_at >= '1970-01-01'
+
+ errors.add(:created_at, s_('Note|The created date provided is too far in the past.'))
+ end
+
def noteable_label_url_method
for_merge_request? ? :project_merge_requests_url : :project_issues_url
end
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 79a84231083..b3eaed154e2 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -125,6 +125,10 @@ class NotificationRecipient
@project ? @project.emails_disabled? : @group&.emails_disabled?
end
+ def emails_enabled?
+ !emails_disabled?
+ end
+
def read_ability
return if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability)
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index 9789d8ed62b..20130f01d44 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -7,6 +7,8 @@ class OauthAccessToken < Doorkeeper::AccessToken
alias_attribute :user, :resource_owner
scope :distinct_resource_owner_counts, ->(applications) { where(application: applications).distinct.group(:application_id).count(:resource_owner_id) }
+ scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) }
+ scope :preload_application, -> { preload(:application) }
def scopes=(value)
if value.is_a?(Array)
diff --git a/app/models/operations/feature_flags_client.rb b/app/models/operations/feature_flags_client.rb
index 1c65c3f096e..e8c237abbc5 100644
--- a/app/models/operations/feature_flags_client.rb
+++ b/app/models/operations/feature_flags_client.rb
@@ -4,6 +4,8 @@ module Operations
class FeatureFlagsClient < ApplicationRecord
include TokenAuthenticatable
+ DEFAULT_UNLEASH_API_VERSION = 1
+
self.table_name = 'operations_feature_flags_clients'
belongs_to :project
@@ -13,6 +15,8 @@ module Operations
add_authentication_token_field :token, encrypted: :required
+ attr_accessor :unleash_app_name
+
before_validation :ensure_token!
def self.find_for_project_and_token(project, token)
@@ -21,5 +25,25 @@ module Operations
where(project_id: project).find_by_token(token)
end
+
+ def self.update_last_feature_flag_updated_at!(project)
+ where(project: project).update_all(last_feature_flag_updated_at: Time.current)
+ end
+
+ def unleash_api_version
+ DEFAULT_UNLEASH_API_VERSION
+ end
+
+ def unleash_api_features
+ return [] unless unleash_app_name.present?
+
+ Operations::FeatureFlag.for_unleash_client(project, unleash_app_name)
+ end
+
+ def unleash_api_cache_key
+ "api_version:#{unleash_api_version}:" \
+ "app_name:#{unleash_app_name}:" \
+ "updated_at:#{last_feature_flag_updated_at.to_i}"
+ end
end
end
diff --git a/app/models/packages/cleanup/policy.rb b/app/models/packages/cleanup/policy.rb
index d7df90a4ce0..35f58f3680d 100644
--- a/app/models/packages/cleanup/policy.rb
+++ b/app/models/packages/cleanup/policy.rb
@@ -23,10 +23,25 @@ module Packages
where.not(keep_n_duplicated_package_files: 'all')
end
+ def self.with_packages
+ exists_select = ::Packages::Package.installable
+ .where('packages_packages.project_id = packages_cleanup_policies.project_id')
+ .select(1)
+ where('EXISTS (?)', exists_select)
+ end
+
+ def self.runnable
+ runnable_schedules.with_packages.order(next_run_at: :asc)
+ end
+
def set_next_run_at
# fixed cadence of 12 hours
self.next_run_at = Time.zone.now + 12.hours
end
+
+ def keep_n_duplicated_package_files_disabled?
+ keep_n_duplicated_package_files == 'all'
+ end
end
end
end
diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb
index eb66f4acfa9..b70b6c460d2 100644
--- a/app/models/packages/debian/file_entry.rb
+++ b/app/models/packages/debian/file_entry.rb
@@ -4,6 +4,7 @@ module Packages
module Debian
class FileEntry
include ActiveModel::Model
+ include ::Packages::FIPS
DIGESTS = %i[md5 sha1 sha256].freeze
FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze
@@ -31,6 +32,8 @@ module Packages
private
def valid_package_file_digests
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
+
DIGESTS.each do |digest|
package_file_digest = package_file["file_#{digest}"]
sum = public_send("#{digest}sum") # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb
index 497f67993ae..119cc7fc166 100644
--- a/app/models/pages/virtual_domain.rb
+++ b/app/models/pages/virtual_domain.rb
@@ -2,8 +2,9 @@
module Pages
class VirtualDomain
- def initialize(projects, trim_prefix: nil, domain: nil)
+ def initialize(projects:, cache: nil, trim_prefix: nil, domain: nil)
@projects = projects
+ @cache = cache
@trim_prefix = trim_prefix
@domain = domain
end
@@ -27,8 +28,12 @@ module Pages
paths.sort_by(&:prefix).reverse
end
+ def cache_key
+ @cache_key ||= cache&.cache_key
+ end
+
private
- attr_reader :projects, :trim_prefix, :domain
+ attr_reader :projects, :trim_prefix, :domain, :cache
end
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 93119bbff1f..9e93bff4acf 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -209,7 +209,15 @@ class PagesDomain < ApplicationRecord
def pages_virtual_domain
return unless pages_deployed?
- Pages::VirtualDomain.new([project], domain: self)
+ cache = if Feature.enabled?(:cache_pages_domain_api, project.root_namespace)
+ ::Gitlab::Pages::CacheControl.for_project(project.id)
+ end
+
+ Pages::VirtualDomain.new(
+ projects: [project],
+ domain: self,
+ cache: cache
+ )
end
def clear_auto_ssl_failure
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
index b4ce61a869c..99a31a620c5 100644
--- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
+++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
@@ -12,6 +12,8 @@ module Preloaders
def execute
return unless @projects.present? && @users.present?
+ preload_users_namespace_bans(@users)
+
access_levels.each do |(project_id, user_id), access_level|
project = projects_by_id[project_id]
@@ -42,5 +44,11 @@ module Preloaders
def projects_by_id
@projects_by_id ||= @projects.index_by(&:id)
end
+
+ def preload_users_namespace_bans(_users)
+ # overridden in EE
+ end
end
end
+
+# Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod
diff --git a/app/models/project.rb b/app/models/project.rb
index dca47911d20..46e25564eab 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -247,7 +247,6 @@ class Project < ApplicationRecord
has_many :export_jobs, class_name: 'ProjectExportJob'
has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :project
has_one :project_repository, inverse_of: :project
- has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
@@ -261,6 +260,7 @@ class Project < ApplicationRecord
has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project
has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
has_many :issues
+ has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus'
has_many :labels, class_name: 'ProjectLabel'
has_many :integrations
has_many :events
@@ -434,7 +434,6 @@ class Project < ApplicationRecord
allow_destroy: true,
reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
- accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :incident_management_setting, update_only: true
accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
@@ -442,33 +441,29 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :prometheus_integration, update_only: true
accepts_nested_attributes_for :alerting_setting, update_only: true
- delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
- :merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
- :pages_enabled?, :analytics_enabled?, :snippets_enabled?, :public_pages?, :private_pages?,
- :merge_requests_access_level, :forking_access_level, :issues_access_level,
- :wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level,
- :operations_enabled?, :operations_access_level, :security_and_compliance_access_level,
- :container_registry_access_level, :container_registry_enabled?,
- to: :project_feature, allow_nil: true
- alias_method :container_registry_enabled, :container_registry_enabled?
- delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?,
- :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=, :enforce_auth_checks_on_uploads?,
- :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, :warn_about_potentially_unwanted_characters?,
- to: :project_setting, allow_nil: true
- delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
- prefix: :import, to: :import_state, allow_nil: true
+ delegate :merge_requests_access_level, :forking_access_level, :issues_access_level,
+ :wiki_access_level, :snippets_access_level, :builds_access_level,
+ :repository_access_level, :package_registry_access_level, :pages_access_level,
+ :metrics_dashboard_access_level, :analytics_access_level,
+ :operations_access_level, :security_and_compliance_access_level,
+ :container_registry_access_level,
+ to: :project_feature, allow_nil: true
+
+ delegate :show_default_award_emojis, :show_default_award_emojis=,
+ :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=,
+ :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=,
+ to: :project_setting, allow_nil: true
+
delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
delegate :squash_option, :squash_option=, to: :project_setting
delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting
delegate :previous_default_branch, :previous_default_branch=, to: :project_setting
- delegate :no_import?, to: :import_state, allow_nil: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
- delegate :add_user, :add_users, to: :team
+ delegate :add_member, :add_members, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team
delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true
- delegate :root_ancestor, :certificate_based_clusters_enabled?, to: :namespace, allow_nil: true
+ delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true
@@ -476,6 +471,7 @@ class Project < ApplicationRecord
delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true
+ delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true
@@ -483,7 +479,6 @@ class Project < ApplicationRecord
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
:allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?,
to: :project_setting
- delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true
delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true
delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true
@@ -667,7 +662,6 @@ class Project < ApplicationRecord
scope :created_by, -> (user) { where(creator: user) }
scope :imported_from, -> (type) { where(import_type: type) }
scope :imported, -> { where.not(import_type: nil) }
- scope :with_tracing_enabled, -> { joins(:tracing_setting) }
scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
scope :with_service_desk_key, -> (key) do
@@ -676,10 +670,12 @@ class Project < ApplicationRecord
joins(:service_desk_setting).where('service_desk_settings.project_key' => key)
end
- scope :with_topic, ->(topic_name) do
+ scope :with_topic, ->(topic) { where(id: topic.project_topics.select(:project_id)) }
+
+ scope :with_topic_by_name, ->(topic_name) do
topic = Projects::Topic.find_by_name(topic_name)
- topic ? where(id: topic.project_topics.select(:project_id)) : none
+ topic ? with_topic(topic) : none
end
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -917,6 +913,14 @@ class Project < ApplicationRecord
association(:namespace).loaded?
end
+ def certificate_based_clusters_enabled?
+ !!namespace&.certificate_based_clusters_enabled?
+ end
+
+ def prometheus_integration_active?
+ !!prometheus_integration&.active?
+ end
+
def personal_namespace_holder?(user)
return false unless personal?
return false unless user
@@ -933,6 +937,42 @@ class Project < ApplicationRecord
super.presence || build_project_setting
end
+ def show_default_award_emojis?
+ !!project_setting&.show_default_award_emojis?
+ end
+
+ def enforce_auth_checks_on_uploads?
+ !!project_setting&.enforce_auth_checks_on_uploads?
+ end
+
+ def warn_about_potentially_unwanted_characters?
+ !!project_setting&.warn_about_potentially_unwanted_characters?
+ end
+
+ def no_import?
+ !!import_state&.no_import?
+ end
+
+ def import_scheduled?
+ !!import_state&.scheduled?
+ end
+
+ def import_started?
+ !!import_state&.started?
+ end
+
+ def import_in_progress?
+ !!import_state&.in_progress?
+ end
+
+ def import_failed?
+ !!import_state&.failed?
+ end
+
+ def import_finished?
+ !!import_state&.finished?
+ end
+
def all_pipelines
if builds_enabled?
super
@@ -998,6 +1038,9 @@ class Project < ApplicationRecord
end
end
+ def emails_enabled?
+ !emails_disabled?
+ end
override :lfs_enabled?
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -1840,6 +1883,59 @@ class Project < ApplicationRecord
end
end
+ def feature_available?(feature, user = nil)
+ !!project_feature&.feature_available?(feature, user)
+ end
+
+ def builds_enabled?
+ !!project_feature&.builds_enabled?
+ end
+
+ def wiki_enabled?
+ !!project_feature&.wiki_enabled?
+ end
+
+ def merge_requests_enabled?
+ !!project_feature&.merge_requests_enabled?
+ end
+
+ def forking_enabled?
+ !!project_feature&.forking_enabled?
+ end
+
+ def issues_enabled?
+ !!project_feature&.issues_enabled?
+ end
+
+ def pages_enabled?
+ !!project_feature&.pages_enabled?
+ end
+
+ def analytics_enabled?
+ !!project_feature&.analytics_enabled?
+ end
+
+ def snippets_enabled?
+ !!project_feature&.snippets_enabled?
+ end
+
+ def public_pages?
+ !!project_feature&.public_pages?
+ end
+
+ def private_pages?
+ !!project_feature&.private_pages?
+ end
+
+ def operations_enabled?
+ !!project_feature&.operations_enabled?
+ end
+
+ def container_registry_enabled?
+ !!project_feature&.container_registry_enabled?
+ end
+ alias_method :container_registry_enabled, :container_registry_enabled?
+
def enable_ci
project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED)
end
@@ -2762,10 +2858,6 @@ class Project < ApplicationRecord
instance.token
end
- def tracing_external_url
- tracing_setting&.external_url
- end
-
override :git_garbage_collect_worker_klass
def git_garbage_collect_worker_klass
Projects::GitGarbageCollectWorker
@@ -2907,6 +2999,10 @@ class Project < ApplicationRecord
build_artifacts_size_refresh&.started?
end
+ def group_group_links
+ group&.shared_with_group_links&.of_ancestors_and_self || GroupGroupLink.none
+ end
+
def security_training_available?
licensed_feature_available?(:security_training)
end
diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb
index c7fe3d7bc10..decc71ee193 100644
--- a/app/models/project_export_job.rb
+++ b/app/models/project_export_job.rb
@@ -2,6 +2,7 @@
class ProjectExportJob < ApplicationRecord
belongs_to :project
+ has_many :relation_exports, class_name: 'Projects::ImportExport::RelationExport'
validates :project, :jid, :status, presence: true
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index f478af32788..0a30e125c83 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -69,6 +69,11 @@ class ProjectFeature < ApplicationRecord
default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false
default_value_for :operations_access_level, value: ENABLED, allows_nil: false
default_value_for :security_and_compliance_access_level, value: PRIVATE, allows_nil: false
+ default_value_for :monitor_access_level, value: ENABLED, allows_nil: false
+ default_value_for :infrastructure_access_level, value: ENABLED, allows_nil: false
+ default_value_for :feature_flags_access_level, value: ENABLED, allows_nil: false
+ default_value_for :environments_access_level, value: ENABLED, allows_nil: false
+ default_value_for :releases_access_level, value: ENABLED, allows_nil: false
default_value_for(:pages_access_level, allows_nil: false) do |feature|
if ::Gitlab::Pages.access_control_is_forced?
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index b1c1a5b6697..7711c6d604a 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -31,6 +31,10 @@ class ProjectImportState < ApplicationRecord
transition started: :finished
end
+ event :cancel do
+ transition [:none, :scheduled, :started] => :canceled
+ end
+
event :fail_op do
transition [:scheduled, :started] => :failed
end
@@ -39,6 +43,7 @@ class ProjectImportState < ApplicationRecord
state :started
state :finished
state :failed
+ state :canceled
after_transition [:none, :finished, :failed] => :scheduled do |state, _|
state.run_after_commit do
@@ -51,7 +56,7 @@ class ProjectImportState < ApplicationRecord
end
end
- after_transition any => :finished do |state, _|
+ after_transition any => [:canceled, :finished] do |state, _|
if state.jid.present?
Gitlab::SidekiqStatus.unset(state.jid)
@@ -59,7 +64,7 @@ class ProjectImportState < ApplicationRecord
end
end
- after_transition any => :failed do |state, _|
+ after_transition any => [:canceled, :failed] do |state, _|
state.project.remove_import_data
end
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index e9fd7e4446c..59d2e3deb4f 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -5,6 +5,8 @@ class ProjectSetting < ApplicationRecord
belongs_to :project, inverse_of: :project_setting
+ scope :for_projects, ->(projects) { where(project_id: projects) }
+
enum squash_option: {
never: 0,
always: 1,
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 97ab5aa2619..5641fbfb867 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -8,23 +8,23 @@ class ProjectTeam
end
def add_guest(user, current_user: nil)
- add_user(user, :guest, current_user: current_user)
+ add_member(user, :guest, current_user: current_user)
end
def add_reporter(user, current_user: nil)
- add_user(user, :reporter, current_user: current_user)
+ add_member(user, :reporter, current_user: current_user)
end
def add_developer(user, current_user: nil)
- add_user(user, :developer, current_user: current_user)
+ add_member(user, :developer, current_user: current_user)
end
def add_maintainer(user, current_user: nil)
- add_user(user, :maintainer, current_user: current_user)
+ add_member(user, :maintainer, current_user: current_user)
end
def add_owner(user, current_user: nil)
- add_user(user, :owner, current_user: current_user)
+ add_member(user, :owner, current_user: current_user)
end
def add_role(user, role, current_user: nil)
@@ -43,8 +43,8 @@ class ProjectTeam
member
end
- def add_users(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
- Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil)
+ Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
@@ -55,8 +55,8 @@ class ProjectTeam
)
end
- def add_user(user, access_level, current_user: nil, expires_at: nil)
- Members::Projects::CreatorService.add_user( # rubocop:disable CodeReuse/ServiceClass
+ def add_member(user, access_level, current_user: nil, expires_at: nil)
+ Members::Projects::CreatorService.add_member( # rubocop:disable CodeReuse/ServiceClass
project,
user,
access_level,
diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb
deleted file mode 100644
index 93fa80aed67..00000000000
--- a/app/models/project_tracing_setting.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-class ProjectTracingSetting < ApplicationRecord
- belongs_to :project
-
- validates :external_url, length: { maximum: 255 }, public_url: true
-
- before_validation :sanitize_external_url
-
- private
-
- def sanitize_external_url
- self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url)
- end
-end
diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb
new file mode 100644
index 00000000000..0a31e525ac2
--- /dev/null
+++ b/app/models/projects/import_export/relation_export.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class RelationExport < ApplicationRecord
+ self.table_name = 'project_relation_exports'
+
+ belongs_to :project_export_job
+
+ has_one :upload,
+ class_name: 'Projects::ImportExport::RelationExportUpload',
+ foreign_key: :project_relation_export_id,
+ inverse_of: :relation_export
+
+ validates :export_error, length: { maximum: 300 }
+ validates :jid, length: { maximum: 255 }
+ validates :project_export_job, presence: true
+ validates :relation, presence: true, length: { maximum: 255 }, uniqueness: { scope: :project_export_job_id }
+ validates :status, numericality: { only_integer: true }, presence: true
+ end
+ end
+end
diff --git a/app/models/projects/import_export/relation_export_upload.rb b/app/models/projects/import_export/relation_export_upload.rb
new file mode 100644
index 00000000000..965dc39d19f
--- /dev/null
+++ b/app/models/projects/import_export/relation_export_upload.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class RelationExportUpload < ApplicationRecord
+ include WithUploads
+ include ObjectStorage::BackgroundMove
+
+ self.table_name = 'project_relation_export_uploads'
+
+ belongs_to :relation_export,
+ class_name: 'Projects::ImportExport::RelationExport',
+ foreign_key: :project_relation_export_id,
+ inverse_of: :upload
+
+ mount_uploader :export_file, ImportExportUploader
+ end
+ end
+end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 77038d52efe..7cf15439b47 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,6 +4,8 @@ class ProtectedBranch < ApplicationRecord
include ProtectedRef
include Gitlab::SQL::Pattern
+ CACHE_EXPIRE_IN = 1.hour
+
scope :requiring_code_owner_approval,
-> { where(code_owner_approval_required: true) }
@@ -29,7 +31,7 @@ class ProtectedBranch < ApplicationRecord
return true if project.empty_repo? && project.default_branch_protected?
return false if ref_name.blank?
- Rails.cache.fetch(protected_ref_cache_key(project, ref_name)) do
+ Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do
self.matching(ref_name, protected_refs: protected_refs(project)).present?
end
end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 7f41f0907d5..f8d500e106b 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -128,7 +128,7 @@ class RemoteMirror < ApplicationRecord
def sync
return unless sync?
- if recently_scheduled?
+ if schedule_with_delay?
RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.current)
else
RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.current)
@@ -261,7 +261,8 @@ class RemoteMirror < ApplicationRecord
super
end
- def recently_scheduled?
+ def schedule_with_delay?
+ return false if Feature.enabled?(:remote_mirror_no_delay, project, type: :ops)
return false unless self.last_update_started_at
self.last_update_started_at >= Time.current - backoff_delay
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 0135020e586..0da71d87457 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1049,8 +1049,8 @@ class Repository
blob_data_at(sha, '.lfsconfig')
end
- def changelog_config(ref = 'HEAD')
- blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH)
+ def changelog_config(ref, path)
+ blob_data_at(ref, path)
end
def fetch_ref(source_repository, source_ref:, target_ref:)
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index cf4b83d44c2..c813c5cb5b8 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -18,6 +18,7 @@ class Snippet < ApplicationRecord
include CanMoveRepositoryStorage
include AfterCommitQueue
extend ::Gitlab::Utils::Override
+ include CreatedAtFilterable
MAX_FILE_COUNT = 10
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index ac7ba9530dd..daa64f4e087 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -12,7 +12,15 @@ class SshHostKey
end
def as_json(*)
- { bits: bits, fingerprint: fingerprint, type: type, index: index }
+ { bits: bits, type: type, index: index }.merge(fingerprint_data)
+ end
+
+ private
+
+ def fingerprint_data
+ data = { fingerprint_sha256: fingerprint_sha256 }
+ data[:fingerprint] = fingerprint unless Gitlab::FIPS.enabled?
+ data
end
end
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 4d17a4d332c..59f7d852ce6 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -3,6 +3,7 @@
module Terraform
class State < ApplicationRecord
include UsageStatistics
+ include AfterCommitQueue
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 45ab770a0f6..cff7a93f72f 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -230,6 +230,10 @@ class Todo < ApplicationRecord
target_type == AlertManagement::Alert.name
end
+ def for_issue_or_work_item?
+ [Issue.name, WorkItem.name].any? { |klass_name| target_type == klass_name }
+ end
+
# override to return commits, which are not active record
def target
if for_commit?
diff --git a/app/models/user.rb b/app/models/user.rb
index 40096dfa411..12f434db631 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -9,6 +9,7 @@ class User < ApplicationRecord
include Gitlab::SQL::Pattern
include AfterCommitQueue
include Avatarable
+ include Awareness
include Referable
include Sortable
include CaseSensitivity
@@ -80,7 +81,7 @@ class User < ApplicationRecord
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
- :validatable, :omniauthable, :confirmable, :registerable
+ :validatable, :omniauthable, :confirmable, :registerable, :pbkdf2_encryptable
include AdminChangedPasswordNotifier
@@ -88,6 +89,7 @@ class User < ApplicationRecord
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
include ForcedEmailConfirmation
+ include RequireEmailVerification
MINIMUM_INACTIVE_DAYS = 90
MINIMUM_DAYS_CREATED = 7
@@ -220,6 +222,7 @@ class User < ApplicationRecord
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'Users::Callout'
has_many :group_callouts, class_name: 'Users::GroupCallout'
+ has_many :namespace_callouts, class_name: 'Users::NamespaceCallout'
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
@@ -476,8 +479,8 @@ class User < ApplicationRecord
end
scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) }
scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
- scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last) }
- scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first) }
+ scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) }
+ scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) }
scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) }
scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) }
scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) }
@@ -687,7 +690,33 @@ class User < ApplicationRecord
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)
+ if Feature.enabled?(:use_keyset_aware_user_search_query)
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_match_priority',
+ order_expression: sanitized_order_sql.asc,
+ add_to_projections: true,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_name',
+ order_expression: arel_table[:name].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'users_id',
+ order_expression: arel_table[:id].asc,
+ add_to_projections: true,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ scope.reorder(order)
+ else
+ scope.reorder(sanitized_order_sql, :name)
+ end
end
# Limits the result set to users _not_ in the given query/list of IDs.
@@ -894,21 +923,59 @@ class User < ApplicationRecord
reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago
end
- # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638
- DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze
+ def authenticatable_salt
+ return encrypted_password[0, 29] unless Feature.enabled?(:pbkdf2_password_encryption)
+ return super if password_strategy == :pbkdf2_sha512
+
+ encrypted_password[0, 29]
+ end
# Overwrites valid_password? from Devise::Models::DatabaseAuthenticatable
# In constant-time, check both that the password isn't on a denylist AND
# that the password is the user's password
def valid_password?(password)
+ return false unless password_allowed?(password)
+ return super if Feature.enabled?(:pbkdf2_password_encryption)
+
+ Devise::Encryptor.compare(self.class, encrypted_password, password)
+ rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash
+ validate_and_migrate_bcrypt_password(password)
+ rescue ::BCrypt::Errors::InvalidHash
+ false
+ end
+
+ # This method should be removed once the :pbkdf2_password_encryption feature flag is removed.
+ def password=(new_password)
+ if Feature.enabled?(:pbkdf2_password_encryption) && Feature.enabled?(:pbkdf2_password_encryption_write, self)
+ super
+ else
+ # Copied from Devise DatabaseAuthenticatable.
+ @password = new_password
+ self.encrypted_password = Devise::Encryptor.digest(self.class, new_password) if new_password.present?
+ end
+ end
+
+ def password_strategy
+ super
+ rescue Devise::Pbkdf2Encryptable::Encryptors::InvalidHash
+ begin
+ return :bcrypt if BCrypt::Password.new(encrypted_password)
+ rescue BCrypt::Errors::InvalidHash
+ :unknown
+ end
+ end
+
+ # See https://gitlab.com/gitlab-org/security/gitlab/-/issues/638
+ DISALLOWED_PASSWORDS = %w[123qweQWE!@#000000000].freeze
+
+ def password_allowed?(password)
password_allowed = true
+
DISALLOWED_PASSWORDS.each do |disallowed_password|
password_allowed = false if Devise.secure_compare(password, disallowed_password)
end
- original_result = super
-
- password_allowed && original_result
+ password_allowed
end
def remember_me!
@@ -1570,6 +1637,10 @@ class User < ApplicationRecord
self.followees.exists?(user.id)
end
+ def followed_by?(user)
+ self.followers.include?(user)
+ end
+
def follow(user)
return false if self.id == user.id
@@ -1625,7 +1696,7 @@ class User < ApplicationRecord
end
def oauth_authorized_tokens
- Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil)
+ OauthAccessToken.where(resource_owner_id: id, revoked_at: nil)
end
# Returns the projects a user contributed to in the last year.
@@ -1899,7 +1970,7 @@ class User < ApplicationRecord
end
# override, from Devise
- def lock_access!
+ def lock_access!(opts = {})
Gitlab::AppLogger.info("Account Locked: username=#{username}")
super
end
@@ -2015,6 +2086,13 @@ class User < ApplicationRecord
callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
+ def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil)
+ source_feature_name = "#{feature_name}_#{namespace.id}"
+ callout = namespace_callouts_by_feature_name[source_feature_name]
+
+ callout_dismissed?(callout, ignore_dismissal_earlier_than)
+ end
+
# Load the current highest access by looking directly at the user's memberships
def current_highest_access_level
members.non_request.maximum(:access_level)
@@ -2041,6 +2119,11 @@ class User < ApplicationRecord
.find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id)
end
+ def find_or_initialize_namespace_callout(feature_name, namespace_id)
+ namespace_callouts
+ .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id)
+ end
+
def can_trigger_notifications?
confirmed? && !blocked? && !ghost?
end
@@ -2158,6 +2241,10 @@ class User < ApplicationRecord
@group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name)
end
+ def namespace_callouts_by_feature_name
+ @namespace_callouts_by_feature_name ||= namespace_callouts.index_by(&:source_feature_name)
+ end
+
def authorized_groups_without_shared_membership
Group.from_union([
groups.select(*Namespace.cached_column_list),
@@ -2318,6 +2405,15 @@ class User < ApplicationRecord
Ci::NamespaceMirror.contains_traversal_ids(traversal_ids)
end
+
+ def validate_and_migrate_bcrypt_password(password)
+ return false unless Devise::Encryptor.compare(self.class, encrypted_password, password)
+ return true unless Feature.enabled?(:pbkdf2_password_encryption_write, self)
+
+ update_attribute(:password, password)
+ rescue ::BCrypt::Errors::InvalidHash
+ false
+ end
end
User.prepend_mod_with('User')
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 0ecae4d148a..570e3ae9b3c 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -49,11 +49,14 @@ module Users
storage_enforcement_banner_fourth_enforcement_threshold: 46,
attention_requests_top_nav: 47,
attention_requests_side_nav: 48,
- minute_limit_banner: 49,
+ # 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533
+ # because the banner was no longer relevant.
+ # Records will be migrated with https://gitlab.com/gitlab-org/gitlab/-/issues/367293
preview_user_over_limit_free_plan_alert: 50, # EE-only
user_reached_limit_free_plan_alert: 51, # EE-only
submit_license_usage_data_banner: 52, # EE-only
- personal_project_limitations_banner: 53 # EE-only
+ personal_project_limitations_banner: 53, # EE-only
+ mr_experience_survey: 54
}
validates :feature_name,
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 373bc30889f..0ea7b8199aa 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -16,7 +16,8 @@ module Users
storage_enforcement_banner_third_enforcement_threshold: 5,
storage_enforcement_banner_fourth_enforcement_threshold: 6,
preview_user_over_limit_free_plan_alert: 7, # EE-only
- user_reached_limit_free_plan_alert: 8 # EE-only
+ user_reached_limit_free_plan_alert: 8, # EE-only
+ free_group_limited_alert: 9 # EE-only
}
validates :group, presence: true
diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb
index 82c2e336a09..f220cfd17c5 100644
--- a/app/models/users/in_product_marketing_email.rb
+++ b/app/models/users/in_product_marketing_email.rb
@@ -41,7 +41,7 @@ module Users
# Tracks we don't send emails for (e.g. unsuccessful experiment). These
# are kept since we already have DB records that use the enum value.
- INACTIVE_TRACK_NAMES = %w(invite_team).freeze
+ INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze
ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES)
scope :for_user_with_track_and_series, -> (user, track, series) do
diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb
new file mode 100644
index 00000000000..a20a196a4ef
--- /dev/null
+++ b/app/models/users/namespace_callout.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Users
+ class NamespaceCallout < ApplicationRecord
+ include Users::Calloutable
+
+ self.table_name = 'user_namespace_callouts'
+
+ belongs_to :namespace
+
+ enum feature_name: {
+ invite_members_banner: 1,
+ approaching_seat_count_threshold: 2, # EE-only
+ storage_enforcement_banner_first_enforcement_threshold: 3,
+ storage_enforcement_banner_second_enforcement_threshold: 4,
+ storage_enforcement_banner_third_enforcement_threshold: 5,
+ storage_enforcement_banner_fourth_enforcement_threshold: 6,
+ preview_user_over_limit_free_plan_alert: 7, # EE-only
+ user_reached_limit_free_plan_alert: 8, # EE-only
+ web_hook_disabled: 9
+ }
+
+ validates :namespace, presence: true
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: [:user_id, :namespace_id] },
+ inclusion: { in: NamespaceCallout.feature_names.keys }
+
+ def source_feature_name
+ "#{feature_name}_#{namespace_id}"
+ end
+ end
+end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 647b4e787c6..63c60f5a89e 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -316,6 +316,7 @@ class WikiPage
end
def update_front_matter(attrs)
+ return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
return unless attrs.has_key?(:front_matter)
fm_yaml = serialize_front_matter(attrs[:front_matter])
@@ -326,7 +327,7 @@ class WikiPage
def parsed_content
strong_memoize(:parsed_content) do
- Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse
+ Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
end
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index bdd9aae90a4..d29df0c31fc 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -1,9 +1,12 @@
# frozen_string_literal: true
class WorkItem < Issue
+ include Gitlab::Utils::StrongMemoize
+
self.table_name = 'issues'
self.inheritance_column = :_type_disabled
+ belongs_to :namespace, inverse_of: :work_items
has_one :parent_link, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_id
has_one :work_item_parent, through: :parent_link, class_name: 'WorkItem'
@@ -22,8 +25,10 @@ class WorkItem < Issue
end
def widgets
- work_item_type.widgets.map do |widget_class|
- widget_class.new(self)
+ strong_memoize(:widgets) do
+ work_item_type.widgets.map do |widget_class|
+ widget_class.new(self)
+ end
end
end
diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb
index 3c405dbce3b..f5ebbfa59b8 100644
--- a/app/models/work_items/parent_link.rb
+++ b/app/models/work_items/parent_link.rb
@@ -5,11 +5,13 @@ module WorkItems
self.table_name = 'work_item_parent_links'
MAX_CHILDREN = 100
+ PARENT_TYPES = [:issue, :incident].freeze
belongs_to :work_item
belongs_to :work_item_parent, class_name: 'WorkItem'
- validates :work_item, :work_item_parent, presence: true
+ validates :work_item_parent, presence: true
+ validates :work_item, presence: true, uniqueness: true
validate :validate_child_type
validate :validate_parent_type
validate :validate_same_project
@@ -21,15 +23,20 @@ module WorkItems
return unless work_item
unless work_item.task?
- errors.add :work_item, _('Only Task can be assigned as a child in hierarchy.')
+ errors.add :work_item, _('only Task can be assigned as a child in hierarchy.')
end
end
def validate_parent_type
return unless work_item_parent
- unless work_item_parent.issue?
- errors.add :work_item_parent, _('Only Issue can be parent of Task.')
+ base_type = work_item_parent.work_item_type.base_type.to_sym
+ unless PARENT_TYPES.include?(base_type)
+ parent_names = WorkItems::Type::BASE_TYPES.slice(*WorkItems::ParentLink::PARENT_TYPES)
+ .values.map { |type| type[:name] }
+
+ errors.add :work_item_parent, _('only %{parent_types} can be parent of Task.') %
+ { parent_types: parent_names.to_sentence }
end
end
@@ -37,7 +44,7 @@ module WorkItems
return if work_item.nil? || work_item_parent.nil?
if work_item.resource_parent != work_item_parent.resource_parent
- errors.add :work_item_parent, _('Parent must be in the same project as child.')
+ errors.add :work_item_parent, _('parent must be in the same project as child.')
end
end
@@ -46,7 +53,7 @@ module WorkItems
max = persisted? ? MAX_CHILDREN : MAX_CHILDREN - 1
if work_item_parent.child_links.count > max
- errors.add :work_item_parent, _('Parent already has maximum number of children.')
+ errors.add :work_item_parent, _('parent already has maximum number of children.')
end
end
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index bf251a3ade5..e38d0ae153a 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -21,11 +21,11 @@ module WorkItems
}.freeze
WIDGETS_FOR_TYPE = {
- issue: [Widgets::Description, Widgets::Hierarchy],
- incident: [Widgets::Description],
+ issue: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight],
+ incident: [Widgets::Description, Widgets::Hierarchy],
test_case: [Widgets::Description],
requirement: [Widgets::Description],
- task: [Widgets::Description, Widgets::Hierarchy]
+ task: [Widgets::Assignees, Widgets::Description, Widgets::Hierarchy, Widgets::Weight]
}.freeze
cache_markdown_field :description, pipeline: :single_line
diff --git a/app/models/work_items/widgets/assignees.rb b/app/models/work_items/widgets/assignees.rb
new file mode 100644
index 00000000000..ecbbee1bcfb
--- /dev/null
+++ b/app/models/work_items/widgets/assignees.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class Assignees < Base
+ delegate :assignees, to: :work_item
+ delegate :allows_multiple_assignees?, to: :work_item
+ end
+ end
+end
diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb
index 35b6d295321..1e84d172bef 100644
--- a/app/models/work_items/widgets/description.rb
+++ b/app/models/work_items/widgets/description.rb
@@ -4,10 +4,6 @@ module WorkItems
module Widgets
class Description < Base
delegate :description, to: :work_item
-
- def update(params:)
- work_item.description = params[:description] if params&.key?(:description)
- end
end
end
end
diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb
index dadd341de83..930aced8ace 100644
--- a/app/models/work_items/widgets/hierarchy.rb
+++ b/app/models/work_items/widgets/hierarchy.rb
@@ -4,13 +4,13 @@ module WorkItems
module Widgets
class Hierarchy < Base
def parent
- return unless Feature.enabled?(:work_items_hierarchy, work_item.project)
+ return unless work_item.project.work_items_feature_flag_enabled?
work_item.work_item_parent
end
def children
- return WorkItem.none unless Feature.enabled?(:work_items_hierarchy, work_item.project)
+ return WorkItem.none unless work_item.project.work_items_feature_flag_enabled?
work_item.work_item_children
end
diff --git a/app/models/work_items/widgets/weight.rb b/app/models/work_items/widgets/weight.rb
new file mode 100644
index 00000000000..f589378f307
--- /dev/null
+++ b/app/models/work_items/widgets/weight.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class Weight < Base
+ delegate :weight, to: :work_item
+ end
+ end
+end
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
index 2c1d0110b7c..7c2581b8bb2 100644
--- a/app/models/x509_certificate.rb
+++ b/app/models/x509_certificate.rb
@@ -16,7 +16,7 @@ class X509Certificate < ApplicationRecord
has_many :x509_commit_signatures, class_name: 'CommitSignatures::X509CommitSignature', inverse_of: 'x509_certificate'
# rfc 5280 - 4.2.1.2 Subject Key Identifier
- validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ }
+ validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex }
# rfc 5280 - 4.1.2.6 Subject
validates :subject, presence: true
# rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address)
diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb
index 4b75e38bbde..81491d8e507 100644
--- a/app/models/x509_issuer.rb
+++ b/app/models/x509_issuer.rb
@@ -4,7 +4,7 @@ class X509Issuer < ApplicationRecord
has_many :x509_certificates, inverse_of: 'x509_issuer'
# rfc 5280 - 4.2.1.1 Authority Key Identifier
- validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ }
+ validates :subject_key_identifier, presence: true, format: { with: Gitlab::Regex.x509_subject_key_identifier_regex }
# rfc 5280 - 4.1.2.4 Issuer
validates :subject, presence: true
# rfc 5280 - 4.2.1.13 CRL Distribution Points
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index fa7b117f3cd..406144b7a5c 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -120,6 +120,8 @@ class GlobalPolicy < BasePolicy
# We can't use `read_statistics` because the user may have different permissions for different projects
rule { admin }.enable :use_project_statistics_filters
+ rule { admin }.enable :delete_runners
+
rule { external_user }.prevent :create_snippet
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 6ca30ba5dab..50b6f4bbe15 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -154,6 +154,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { reporter }.policy do
enable :reporter_access
enable :read_container_image
+ enable :read_harbor_registry
enable :admin_issue_board
enable :admin_label
enable :admin_milestone
@@ -179,6 +180,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_deploy_token
enable :create_jira_connect_subscription
enable :maintainer_access
+ enable :maintain_namespace
end
rule { owner }.policy do
diff --git a/app/policies/incident_management/timeline_event_policy.rb b/app/policies/incident_management/timeline_event_policy.rb
index 514a2bf0a56..d8c3b283cd0 100644
--- a/app/policies/incident_management/timeline_event_policy.rb
+++ b/app/policies/incident_management/timeline_event_policy.rb
@@ -3,5 +3,15 @@
module IncidentManagement
class TimelineEventPolicy < ::BasePolicy
delegate { @subject.incident }
+
+ condition(:is_editable, scope: :subject, score: 0) { @subject.editable? }
+
+ rule { ~can?(:admin_incident_management_timeline_event) }.policy do
+ prevent :edit_incident_management_timeline_event
+ end
+
+ rule { is_editable }.policy do
+ enable :edit_incident_management_timeline_event
+ end
end
end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 2b6dcc56fa0..0a0a35d41cc 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -57,13 +57,7 @@ class IssuePolicy < IssuablePolicy
enable :update_subscription
end
- # admin can set metadata on new issues
- rule { ~persisted & admin }.policy do
- enable :set_issue_metadata
- end
-
- # support bot needs to be able to set metadata on new issues when service desk is enabled
- rule { ~persisted & support_bot & can?(:guest_access) }.policy do
+ rule { can?(:admin_issue) }.policy do
enable :set_issue_metadata
end
@@ -72,10 +66,6 @@ class IssuePolicy < IssuablePolicy
enable :set_issue_metadata
end
- rule { persisted & can?(:admin_issue) }.policy do
- enable :set_issue_metadata
- end
-
rule { can?(:set_issue_metadata) }.policy do
enable :set_confidentiality
end
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index 96002d98afe..bda327cb661 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -14,7 +14,7 @@ class MergeRequestPolicy < IssuablePolicy
prevent :accept_merge_request
end
- rule { can?(:update_merge_request) }.policy do
+ rule { can?(:update_merge_request) & is_project_member }.policy do
enable :approve_merge_request
end
diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb
index 028247497e5..26112332003 100644
--- a/app/policies/namespaces/user_namespace_policy.rb
+++ b/app/policies/namespaces/user_namespace_policy.rb
@@ -11,6 +11,7 @@ module Namespaces
enable :owner_access
enable :create_projects
enable :admin_namespace
+ enable :maintain_namespace
enable :read_namespace
enable :read_statistics
enable :create_jira_connect_subscription
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 2594310c498..54270dc186e 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -195,6 +195,8 @@ class ProjectPolicy < BasePolicy
with_scope :subject
condition(:packages_disabled) { !@subject.packages_enabled }
+ condition(:work_items_enabled, scope: :subject) { project&.work_items_feature_flag_enabled? }
+
features = %w[
merge_requests
issues
@@ -223,6 +225,10 @@ class ProjectPolicy < BasePolicy
Gitlab.config.registry.enabled
end
+ condition :packages_enabled do
+ Gitlab.config.packages.enabled
+ end
+
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should
# not.
rule { guest | admin }.enable :read_project_for_iids
@@ -290,10 +296,9 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident
- rule { can?(:create_issue) }.policy do
- enable :create_task
- enable :create_work_item
- end
+ rule { can?(:create_issue) }.enable :create_work_item
+
+ rule { can?(:create_issue) & work_items_enabled }.enable :create_task
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.
@@ -317,6 +322,7 @@ class ProjectPolicy < BasePolicy
enable :read_commit_status
enable :read_build
enable :read_container_image
+ enable :read_harbor_registry
enable :read_deploy_board
enable :read_pipeline
enable :read_pipeline_schedule
@@ -490,6 +496,7 @@ class ProjectPolicy < BasePolicy
enable :update_runners_registration_token
enable :admin_project_google_cloud
enable :admin_secure_files
+ enable :read_web_hooks
end
rule { public_project & metrics_dashboard_allowed }.policy do
@@ -792,6 +799,10 @@ class ProjectPolicy < BasePolicy
enable :view_package_registry_project_settings
end
+ rule { packages_enabled & can?(:admin_package) }.policy do
+ enable :view_package_registry_project_settings
+ end
+
private
def user_is_user?
diff --git a/app/policies/work_item_policy.rb b/app/policies/work_item_policy.rb
index ea7559592e1..2f3561f1135 100644
--- a/app/policies/work_item_policy.rb
+++ b/app/policies/work_item_policy.rb
@@ -13,4 +13,8 @@ class WorkItemPolicy < IssuePolicy
# need to make sure we also prevent this rule if read_issue
# is prevented
rule { ~can?(:read_issue) }.prevent :read_work_item
+
+ rule { can?(:reporter_access) }.policy do
+ enable :admin_parent_link
+ end
end
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 2dcc6cd5df3..74ac47fa439 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -69,7 +69,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def find_file_path
- url_helpers.project_find_file_path(project, ref_qualified_path)
+ url_helpers.project_find_file_path(project, blob.commit_id)
end
def blame_path
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 0be684901d5..513fcd90cf8 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -4,16 +4,6 @@ module Ci
class BuildPresenter < ProcessablePresenter
presents ::Ci::Build, as: :build
- def erased_by_user?
- # Build can be erased through API, therefore it does not have
- # `erased_by` user assigned in that case.
- erased? && erased_by
- end
-
- def erased_by_name
- erased_by.name if erased_by_user?
- end
-
def status_title(status = detailed_status)
if auto_canceled?
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
@@ -33,10 +23,6 @@ module Ci
end
end
- def tooltip_message
- "#{build.name} - #{detailed_status.status_tooltip}"
- end
-
def execute_in
scheduled? && scheduled_at && [0, scheduled_at - Time.now].max
end
diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb
deleted file mode 100644
index c803abfab6a..00000000000
--- a/app/presenters/ci/legacy_stage_presenter.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class LegacyStagePresenter < Gitlab::View::Presenter::Delegated
- presents ::Ci::LegacyStage, as: :legacy_stage
-
- def latest_ordered_statuses
- preload_statuses(legacy_stage.statuses.latest_ordered)
- end
-
- def retried_ordered_statuses
- preload_statuses(legacy_stage.statuses.retried_ordered)
- end
-
- private
-
- def preload_statuses(statuses)
- Preloaders::CommitStatusPreloader.new(statuses).execute(Ci::StagePresenter::PRELOADED_RELATIONS)
-
- statuses
- end
- end
-end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 454e5c0e44a..ec1dc96c2e3 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -52,16 +52,6 @@ module Clusters
end
end
- def gitlab_managed_apps_logs_path
- return unless logs_project && can_read_cluster?
-
- if cluster.elastic_stack_adapter&.available?
- elasticsearch_project_logs_path(logs_project, cluster_id: cluster.id, format: :json)
- else
- k8s_project_logs_path(logs_project, cluster_id: cluster.id, format: :json)
- end
- end
-
def read_only_kubernetes_platform_fields?
!cluster.provided_by_user?
end
diff --git a/app/presenters/clusters/integration_presenter.rb b/app/presenters/clusters/integration_presenter.rb
index f7be59f00f3..af735e1c18b 100644
--- a/app/presenters/clusters/integration_presenter.rb
+++ b/app/presenters/clusters/integration_presenter.rb
@@ -2,7 +2,7 @@
module Clusters
class IntegrationPresenter < Gitlab::View::Presenter::Delegated
- presents ::Clusters::Integrations::Prometheus, ::Clusters::Integrations::ElasticStack, as: :integration
+ presents ::Clusters::Integrations::Prometheus, as: :integration
def application_type
integration.class.name.demodulize.underscore
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 675288da35b..815a4da25ab 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -16,8 +16,11 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator',
forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run',
pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines',
+ insufficient_upstream_permissions: 'This job could not be executed because of insufficient permissions to track the upstream project.',
+ upstream_bridge_project_not_found: 'This job could not be executed because upstream bridge project could not be found.',
invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid',
downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found',
+ protected_environment_failure: 'The environment this job is deploying to is protected. Only users with permission may successfully run this job.',
insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline',
bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines',
downstream_pipeline_creation_failed: 'The downstream pipeline could not be created',
@@ -62,5 +65,3 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
ActionController::Base.helpers.link_to('How do I fix it?', help_page_path(path, anchor: anchor))
end
end
-
-CommitStatusPresenter.prepend_mod_with('CommitStatusPresenter')
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
index 81a954761ea..6230e61d2be 100644
--- a/app/presenters/gitlab/blame_presenter.rb
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -66,7 +66,7 @@ module Gitlab
previous_commit_id = commit.parent_id
return unless previous_commit_id && !previous_path.nil?
- link_to project_blame_path(project, tree_join(previous_commit_id, previous_path)),
+ link_to project_blame_path(project, tree_join(previous_commit_id, previous_path), page: page),
title: _('View blame prior to this change'),
aria: { label: _('View blame prior to this change') },
class: 'version-link',
diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb
deleted file mode 100644
index ada8227a477..00000000000
--- a/app/presenters/invitation_presenter.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class InvitationPresenter < Gitlab::View::Presenter::Delegated
- presents nil, as: :invitation
-end
diff --git a/app/presenters/terraform/module_version_presenter.rb b/app/presenters/terraform/module_version_presenter.rb
new file mode 100644
index 00000000000..776a4d8ab82
--- /dev/null
+++ b/app/presenters/terraform/module_version_presenter.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Terraform
+ class ModuleVersionPresenter < Gitlab::View::Presenter::Simple
+ attr_accessor :package, :system
+
+ def initialize(package, system)
+ @package = package
+ @system = system
+ end
+
+ def name
+ package.name
+ end
+
+ def provider
+ system
+ end
+
+ def providers
+ [
+ provider
+ ]
+ end
+
+ def root
+ {
+ 'dependencies' => []
+ }
+ end
+
+ def source
+ package&.project&.web_url
+ end
+
+ def submodules
+ []
+ end
+
+ def version
+ package.version
+ end
+
+ def versions
+ [
+ version
+ ]
+ end
+ end
+end
diff --git a/app/serializers/README.md b/app/serializers/README.md
index d83c2061e0b..6bab3c83e1b 100644
--- a/app/serializers/README.md
+++ b/app/serializers/README.md
@@ -22,6 +22,10 @@ Using serializers, instead of `to_json` method, has several benefits:
* it makes it easier to reduce merge conflicts between CE -> EE
* it makes it easier to benefit from domain driven development techniques
+## Security considerations
+
+Consult the `Serialization` section of our [Secure Coding Guidelines](../../doc/development/secure_coding_guidelines.md#serialization) to help avoiding leaking sensitive attributes when using serializers.
+
## What is a serializer?
A serializer is a class that encapsulates all business rules for building a
diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb
index b6b11e54a16..813938c2a18 100644
--- a/app/serializers/ci/job_entity.rb
+++ b/app/serializers/ci/job_entity.rb
@@ -41,6 +41,8 @@ module Ci
expose :scheduled?, as: :scheduled
expose :scheduled_at, if: -> (*) { scheduled? }
expose :created_at
+ expose :queued_at
+ expose :queued_duration
expose :updated_at
expose :detailed_status, as: :status, with: DetailedStatusEntity
expose :callout_message, if: -> (*) { failed? && !job.script_failure? }
diff --git a/app/serializers/ci/job_serializer.rb b/app/serializers/ci/job_serializer.rb
index 01f9e223943..37213ee284a 100644
--- a/app/serializers/ci/job_serializer.rb
+++ b/app/serializers/ci/job_serializer.rb
@@ -3,10 +3,5 @@
module Ci
class JobSerializer < BaseSerializer
entity Ci::JobEntity
-
- def represent_status(resource)
- data = represent(resource, { only: [:status] })
- data.fetch(:status, {})
- end
end
end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index df72a994143..8e256863bcd 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -19,21 +19,7 @@ class ClusterEntity < Grape::Entity
Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter
end
- expose :gitlab_managed_apps_logs_path, if: -> (*) { logging_enabled? } do |cluster|
- Clusters::ClusterPresenter.new(cluster, current_user: request.current_user).gitlab_managed_apps_logs_path # rubocop: disable CodeReuse/Presenter
- end
-
expose :kubernetes_errors do |cluster|
Clusters::KubernetesErrorEntity.new(cluster)
end
-
- expose :enable_advanced_logs_querying, if: -> (*) { logging_enabled? } do |cluster|
- cluster.elastic_stack_available?
- end
-
- private
-
- def logging_enabled?
- Feature.enabled?(:monitor_logging, object.project)
- end
end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index f71591612a6..30b8863efa2 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -10,8 +10,6 @@ class ClusterSerializer < BaseSerializer
:cluster_type,
:enabled,
:environment_scope,
- :gitlab_managed_apps_logs_path,
- :enable_advanced_logs_querying,
:id,
:kubernetes_errors,
:name,
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index ac99463bd64..3473b4aebc8 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -66,22 +66,6 @@ class EnvironmentEntity < Grape::Entity
environment.available? && can?(current_user, :stop_environment, environment)
end
- expose :logs_path, if: -> (*) { can_read_pod_logs? } do |environment|
- project_logs_path(environment.project, environment_name: environment.name)
- end
-
- expose :logs_api_path, if: -> (*) { can_read_pod_logs? } do |environment|
- if environment.elastic_stack_available?
- elasticsearch_project_logs_path(environment.project, environment_name: environment.name, format: :json)
- else
- k8s_project_logs_path(environment.project, environment_name: environment.name, format: :json)
- end
- end
-
- expose :enable_advanced_logs_querying, if: -> (*) { can_read_pod_logs? } do |environment|
- environment.elastic_stack_available?
- end
-
expose :can_delete do |environment|
can?(current_user, :destroy_environment, environment)
end
@@ -102,11 +86,6 @@ class EnvironmentEntity < Grape::Entity
can?(current_user, :update_environment, environment)
end
- def can_read_pod_logs?
- Feature.enabled?(:monitor_logging, environment.project) &&
- can?(current_user, :read_pod_logs, environment.project)
- end
-
def can_read_deploy_board?
can?(current_user, :read_deploy_board, environment.project)
end
diff --git a/app/serializers/error_tracking/error_entity.rb b/app/serializers/error_tracking/error_entity.rb
index 91388e7c3ad..49644ed2fe7 100644
--- a/app/serializers/error_tracking/error_entity.rb
+++ b/app/serializers/error_tracking/error_entity.rb
@@ -2,7 +2,11 @@
module ErrorTracking
class ErrorEntity < Grape::Entity
- expose :id, :title, :type, :user_count, :count,
+ expose :id do |error|
+ error.id.to_s
+ end
+
+ expose :title, :type, :user_count, :count,
:first_seen, :last_seen, :message, :culprit,
:external_url, :project_id, :project_name, :project_slug,
:short_id, :status, :frequency
diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb
index 170f660f334..91bd91dd941 100644
--- a/app/serializers/integrations/event_entity.rb
+++ b/app/serializers/integrations/event_entity.rb
@@ -18,12 +18,12 @@ module Integrations
IntegrationsHelper.integration_event_description(integration, event)
end
- expose :field, if: ->(_, _) { event_field } do
+ expose :field, if: ->(_, _) { integration.try(:configurable_channels?) } do
expose :name do |event|
- event_field[:name]
+ integration.event_channel_name(event)
end
expose :value do |event|
- integration.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+ integration.event_channel_value(event)
end
end
@@ -35,10 +35,6 @@ module Integrations
IntegrationsHelper.integration_event_field_name(event)
end
- def event_field
- @event_field ||= integration.event_field(event)
- end
-
def integration
request.integration
end
diff --git a/app/serializers/integrations/harbor_serializers/artifact_entity.rb b/app/serializers/integrations/harbor_serializers/artifact_entity.rb
new file mode 100644
index 00000000000..010380561eb
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/artifact_entity.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class ArtifactEntity < Grape::Entity
+ include ActionView::Helpers::SanitizeHelper
+
+ expose :harbor_id do |item|
+ item['id']
+ end
+
+ expose :digest do |item|
+ strip_tags(item['digest'])
+ end
+
+ expose :size do |item|
+ item['size']
+ end
+
+ expose :push_time do |item|
+ item['push_time']&.to_datetime&.utc
+ end
+
+ expose :tags do |item|
+ item['tags'].map { |tag| strip_tags(tag['name']) }
+ end
+ end
+ end
+end
diff --git a/app/serializers/integrations/harbor_serializers/artifact_serializer.rb b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb
new file mode 100644
index 00000000000..aaf78a72330
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/artifact_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class ArtifactSerializer < BaseSerializer
+ include WithPagination
+
+ entity ::Integrations::HarborSerializers::ArtifactEntity
+ end
+ end
+end
diff --git a/app/serializers/integrations/harbor_serializers/repository_entity.rb b/app/serializers/integrations/harbor_serializers/repository_entity.rb
new file mode 100644
index 00000000000..f03465fe8e2
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/repository_entity.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class RepositoryEntity < Grape::Entity
+ include ActionView::Helpers::SanitizeHelper
+
+ expose :harbor_id do |item|
+ item['id']
+ end
+
+ expose :name do |item|
+ strip_tags(item['name'])
+ end
+
+ expose :artifact_count do |item|
+ item['artifact_count']
+ end
+
+ expose :creation_time do |item|
+ item['creation_time']&.to_datetime&.utc
+ end
+
+ expose :update_time do |item|
+ item['update_time']&.to_datetime&.utc
+ end
+
+ expose :harbor_project_id do |item|
+ item['project_id']
+ end
+
+ expose :pull_count do |item|
+ item['pull_count']
+ end
+
+ expose :location do |item|
+ path = [
+ 'harbor/projects',
+ item['project_id'].to_s,
+ 'repositories',
+ item['name'].remove("#{options[:project_name]}/")
+ ].join('/')
+ path = validate_path(path)
+ strip_tags(Gitlab::Utils.append_path(options[:url], path))
+ end
+
+ private
+
+ def validate_path(path)
+ Gitlab::Utils.check_path_traversal!(path)
+ rescue ::Gitlab::Utils::PathTraversalAttackError
+ Gitlab::AppLogger.error("Path traversal attack detected #{path}")
+ ''
+ end
+ end
+ end
+end
diff --git a/app/serializers/integrations/harbor_serializers/repository_serializer.rb b/app/serializers/integrations/harbor_serializers/repository_serializer.rb
new file mode 100644
index 00000000000..9b9e089eab8
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/repository_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class RepositorySerializer < BaseSerializer
+ include WithPagination
+
+ entity ::Integrations::HarborSerializers::RepositoryEntity
+ end
+ end
+end
diff --git a/app/serializers/integrations/harbor_serializers/tag_entity.rb b/app/serializers/integrations/harbor_serializers/tag_entity.rb
new file mode 100644
index 00000000000..8c26bc1ecbd
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/tag_entity.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class TagEntity < Grape::Entity
+ include ActionView::Helpers::SanitizeHelper
+
+ expose :harbor_repository_id do |item|
+ item['repository_id']
+ end
+
+ expose :harbor_artifact_id do |item|
+ item['artifact_id']
+ end
+
+ expose :harbor_id do |item|
+ item['id']
+ end
+
+ expose :name do |item|
+ strip_tags(item['name'])
+ end
+
+ expose :pull_time do |item|
+ item['pull_time']&.to_datetime&.utc
+ end
+
+ expose :push_time do |item|
+ item['push_time']&.to_datetime&.utc
+ end
+
+ expose :signed do |item|
+ item['signed']
+ end
+
+ expose :immutable do |item|
+ item['immutable']
+ end
+ end
+ end
+end
diff --git a/app/serializers/integrations/harbor_serializers/tag_serializer.rb b/app/serializers/integrations/harbor_serializers/tag_serializer.rb
new file mode 100644
index 00000000000..7111e65e3e6
--- /dev/null
+++ b/app/serializers/integrations/harbor_serializers/tag_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HarborSerializers
+ class TagSerializer < BaseSerializer
+ include WithPagination
+
+ entity ::Integrations::HarborSerializers::TagEntity
+ end
+ end
+end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index eba2c49bc2e..ea43ed87d22 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -73,7 +73,7 @@ class IssueEntity < IssuableEntity
end
expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue|
- help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
+ help_page_path('user/project/settings/index.md', anchor: 'archive-a-project')
end
expose :issue_email_participants do |issue|
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 7d45484fc2f..2820c88b293 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -7,6 +7,7 @@ class MergeRequestBasicEntity < Grape::Entity
expose :state
expose :source_branch_exists?, as: :source_branch_exists
expose :rebase_in_progress?, as: :rebase_in_progress
+ expose :should_be_rebased?, as: :should_be_rebased
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
expose :assignees, using: API::Entities::UserBasic
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index 548ff577863..f278ccfce73 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -50,14 +50,6 @@ class StageEntity < Grape::Entity
stage.detailed_status(request.current_user)
end
- def grouped_statuses
- @grouped_statuses ||= stage.statuses.latest_ordered.group_by(&:status)
- end
-
- def grouped_retried_statuses
- @grouped_retried_statuses ||= stage.statuses.retried_ordered.group_by(&:status)
- end
-
def latest_statuses
Ci::HasStatus::ORDERED_STATUSES.flat_map do |ordered_status|
grouped_statuses.fetch(ordered_status, [])
@@ -69,4 +61,18 @@ class StageEntity < Grape::Entity
grouped_retried_statuses.fetch(ordered_status, [])
end
end
+
+ def grouped_statuses
+ @grouped_statuses ||= preload_metadata(stage.statuses.latest_ordered).group_by(&:status)
+ end
+
+ def grouped_retried_statuses
+ @grouped_retried_statuses ||= preload_metadata(stage.statuses.retried_ordered).group_by(&:status)
+ end
+
+ def preload_metadata(statuses)
+ Preloaders::CommitStatusPreloader.new(statuses).execute([:metadata])
+
+ statuses
+ end
end
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
index 6bdceb0f27b..f273e15b159 100644
--- a/app/services/alert_management/alerts/update_service.rb
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -12,7 +12,6 @@ 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
@@ -37,7 +36,7 @@ module AlertManagement
private
- attr_reader :alert, :param_errors, :status, :status_change_reason
+ attr_reader :alert, :param_errors, :status
def allowed?
current_user&.can?(:update_alert_management_alert, alert)
@@ -130,37 +129,16 @@ module AlertManagement
def handle_status_change
add_status_change_system_note
resolve_todos if alert.resolved?
- sync_to_incident if should_sync_to_incident?
end
def add_status_change_system_note
- SystemNoteService.change_alert_status(alert, current_user, status_change_reason)
+ SystemNoteService.change_alert_status(alert, current_user)
end
def resolve_todos
todo_service.resolve_todos_for_target(alert, current_user)
end
- def sync_to_incident
- ::Issues::UpdateService.new(
- project: project,
- current_user: current_user,
- 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?
- alert.issue &&
- alert.issue.supports_escalation? &&
- alert.issue.escalation_status &&
- alert.issue.escalation_status.status != alert.status
- end
-
def filter_duplicate
# Only need to check if changing to a not-resolved status
return if params[:status_event].blank? || params[:status_event] == :resolve
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 97debccfb18..26244a8bcc5 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -121,12 +121,15 @@ class AuditEventService
def log_security_event_to_database
return if Gitlab::Database.read_only?
- event = AuditEvent.new(base_payload.merge(details: @details))
+ event = build_event
save_or_track event
-
event
end
+ def build_event
+ AuditEvent.new(base_payload.merge(details: @details))
+ end
+
def stream_event_to_external_destinations(_event)
# Defined in EE
end
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index 2a32f0c74ac..9e49bd86ec0 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -63,21 +63,6 @@ module AutoMerge
end
end
- ##
- # NOTE: This method is to be removed when `disallow_to_create_merge_request_pipelines_in_target_project`
- # feature flag is removed.
- def self.can_add_to_merge_train?(merge_request)
- if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project)
- merge_request.for_same_project?
- else
- true
- end
- end
-
- def can_add_to_merge_train?(merge_request)
- self.class.can_add_to_merge_train?(merge_request)
- end
-
private
# Overridden in child classes
diff --git a/app/services/bulk_imports/create_pipeline_trackers_service.rb b/app/services/bulk_imports/create_pipeline_trackers_service.rb
index 5c9c68e62b5..af97aec09b5 100644
--- a/app/services/bulk_imports/create_pipeline_trackers_service.rb
+++ b/app/services/bulk_imports/create_pipeline_trackers_service.rb
@@ -47,7 +47,7 @@ module BulkImports
end
def non_patch_source_version
- Gitlab::VersionInfo.new(source_version.major, source_version.minor, 0)
+ source_version.without_patch
end
def log_skipped_pipeline(pipeline, minimum_version, maximum_version)
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
index 8bdb51320f9..f9146b3677a 100644
--- a/app/services/ci/build_report_result_service.rb
+++ b/app/services/ci/build_report_result_service.rb
@@ -22,7 +22,7 @@ module Ci
private
def generate_test_suite_report(build)
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
end
def tests_params(test_suite)
diff --git a/app/services/ci/external_pull_requests/create_pipeline_service.rb b/app/services/ci/external_pull_requests/create_pipeline_service.rb
index 66127c94d35..ffc129eccda 100644
--- a/app/services/ci/external_pull_requests/create_pipeline_service.rb
+++ b/app/services/ci/external_pull_requests/create_pipeline_service.rb
@@ -10,24 +10,12 @@ module Ci
return pull_request_not_open_error unless pull_request.open?
return pull_request_branch_error unless pull_request.actual_branch_head?
- create_pipeline_for(pull_request)
- end
-
- private
-
- def create_pipeline_for(pull_request)
Ci::ExternalPullRequests::CreatePipelineWorker.perform_async(
project.id, current_user.id, pull_request.id
)
end
- def create_params(pull_request)
- {
- ref: pull_request.source_ref,
- source_sha: pull_request.source_sha,
- target_sha: pull_request.target_sha
- }
- end
+ private
def pull_request_not_open_error
ServiceResponse.error(message: 'The pull request is not opened', payload: nil)
diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb
index 12b1f19f4b5..81f26e84ef8 100644
--- a/app/services/ci/generate_coverage_reports_service.rb
+++ b/app/services/ci/generate_coverage_reports_service.rb
@@ -32,5 +32,18 @@ module Ci
def latest?(base_pipeline, head_pipeline, data)
data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
end
+
+ private
+
+ def key(base_pipeline, head_pipeline)
+ [
+ base_pipeline&.id, last_update_timestamp(base_pipeline),
+ head_pipeline&.id, last_update_timestamp(head_pipeline)
+ ]
+ end
+
+ def last_update_timestamp(pipeline_hierarchy)
+ pipeline_hierarchy&.self_and_descendants&.maximum(:updated_at)
+ end
end
end
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index 635111130d6..05f8e804c67 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -5,10 +5,7 @@ module Ci
class CreateService < ::BaseService
include Gitlab::Utils::UsageData
- ArtifactsExistError = Class.new(StandardError)
-
LSIF_ARTIFACT_TYPE = 'lsif'
- METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders'
OBJECT_STORAGE_ERRORS = [
Errno::EIO,
@@ -74,10 +71,6 @@ module Ci
Ci::JobArtifact.max_artifact_size(type: type, project: project)
end
- def forbidden_type_error(type)
- error("#{type} artifacts are forbidden", :forbidden)
- end
-
def too_large_error
error('file size has reached maximum size limit', :payload_too_large)
end
@@ -160,10 +153,8 @@ module Ci
)
end
- def track_artifact_uploader(artifact)
- return unless artifact.file_type == 'metrics'
-
- track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id)
+ def track_artifact_uploader(_artifact)
+ # Overridden in EE
end
def parse_dotenv_artifact(artifact)
@@ -172,3 +163,5 @@ module Ci
end
end
end
+
+Ci::JobArtifacts::CreateService.prepend_mod
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index 49b65f13804..9d6b413ce59 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -184,10 +184,12 @@ module Ci
project_ids << artifact.project_id
end
- Gitlab::ProjectStatsRefreshConflictsLogger.warn_skipped_artifact_deletion_during_stats_refresh(
- method: 'Ci::JobArtifacts::DestroyBatchService#execute',
- project_ids: project_ids
- )
+ if project_ids.any?
+ Gitlab::ProjectStatsRefreshConflictsLogger.warn_skipped_artifact_deletion_during_stats_refresh(
+ method: 'Ci::JobArtifacts::DestroyBatchService#execute',
+ project_ids: project_ids
+ )
+ end
end
end
end
diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
index b0acb1d5a0b..c11a8f7a0fd 100644
--- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
@@ -9,17 +9,11 @@ module Ci
end
def execute
- return if pipeline.has_coverage_reports?
return if report.empty?
- pipeline.pipeline_artifacts.create!(
- project_id: pipeline.project_id,
- file_type: :code_coverage,
- file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_coverage),
- size: carrierwave_file["tempfile"].size,
- file: carrierwave_file,
- expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now
- )
+ Ci::PipelineArtifact.create_or_replace_for_pipeline!(**pipeline_artifact_params).tap do |pipeline_artifact|
+ Gitlab::AppLogger.info(log_params(pipeline_artifact))
+ end
end
private
@@ -32,6 +26,15 @@ module Ci
end
end
+ def pipeline_artifact_params
+ {
+ pipeline: pipeline,
+ file_type: :code_coverage,
+ file: carrierwave_file,
+ size: carrierwave_file['tempfile'].size
+ }
+ end
+
def carrierwave_file
strong_memoize(:carrier_wave_file) do
CarrierWaveStringFile.new_file(
@@ -41,6 +44,15 @@ module Ci
)
end
end
+
+ def log_params(pipeline_artifact)
+ {
+ project_id: pipeline.project_id,
+ pipeline_id: pipeline.id,
+ pipeline_artifact_id: pipeline_artifact.id,
+ message: "Created code coverage for pipeline."
+ }
+ end
end
end
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 4d1b2e07d7f..676c2ecb257 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -78,7 +78,7 @@ module Ci
def status_for_array(statuses, dag:)
result = Gitlab::Ci::Status::Composite
- .new(statuses, dag: dag, project: pipeline.project)
+ .new(statuses, dag: dag)
.status
result || 'success'
end
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
index fefbdb151ec..2deebc1d725 100644
--- a/app/services/ci/queue/build_queue_service.rb
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -24,25 +24,7 @@ module Ci
# rubocop:disable CodeReuse/ActiveRecord
def builds_for_group_runner
- if strategy.use_denormalized_data_strategy?
- strategy.builds_for_group_runner
- else
- # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
- groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
-
- hierarchy_groups = Gitlab::ObjectHierarchy
- .new(groups)
- .base_and_descendants
-
- projects = Project.where(namespace_id: hierarchy_groups)
- .with_group_runners_enabled
- .with_builds_enabled
- .without_deleted
-
- relation = new_builds.where(project: projects)
-
- order(relation)
- end
+ strategy.builds_for_group_runner
end
def builds_for_project_runner
@@ -80,11 +62,7 @@ module Ci
def strategy
strong_memoize(:strategy) do
- if ::Feature.enabled?(:ci_pending_builds_queue_source, runner)
- Queue::PendingBuildsStrategy.new(runner)
- else
- Queue::BuildsTableStrategy.new(runner)
- end
+ Queue::PendingBuildsStrategy.new(runner)
end
end
diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb
deleted file mode 100644
index c27c10bd18d..00000000000
--- a/app/services/ci/queue/builds_table_strategy.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module Queue
- class BuildsTableStrategy
- attr_reader :runner
-
- def initialize(runner)
- @runner = runner
- end
-
- # rubocop:disable CodeReuse/ActiveRecord
- def builds_for_shared_runner
- relation = new_builds
- # don't run projects which have not enabled shared runners and builds
- .joins('INNER JOIN projects ON ci_builds.project_id = projects.id')
- .where(projects: { shared_runners_enabled: true, pending_delete: false })
- .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
- .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0')
-
- if Feature.enabled?(:ci_queueing_disaster_recovery_disable_fair_scheduling, runner, type: :ops)
- # if disaster recovery is enabled, we fallback to FIFO scheduling
- relation.order('ci_builds.id ASC')
- else
- # Implement fair scheduling
- # this returns builds that are ordered by number of running builds
- # we prefer projects that don't use shared runners at all
- relation
- .joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id = project_builds.project_id")
- .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC')
- end
- end
-
- def builds_for_group_runner
- raise NotImplementedError
- end
-
- def builds_matching_tag_ids(relation, ids)
- # pick builds that does not have other tags than runner's one
- relation.matches_tag_ids(ids)
- end
-
- def builds_with_any_tags(relation)
- # pick builds that have at least one tag
- relation.with_any_tags
- end
-
- def order(relation)
- relation.order('id ASC')
- end
-
- def new_builds
- ::Ci::Build.pending.unstarted
- end
-
- def build_ids(relation)
- relation.pluck(:id)
- end
-
- def use_denormalized_data_strategy?
- false
- end
-
- private
-
- def running_builds_for_shared_runners
- ::Ci::Build.running
- .where(runner: ::Ci::Runner.instance_type)
- .group(:project_id)
- .select(:project_id, 'COUNT(*) AS running_builds')
- end
- # rubocop:enable CodeReuse/ActiveRecord
- end
- end
-end
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
index f2eba0681db..c8bdbba5e65 100644
--- a/app/services/ci/queue/pending_builds_strategy.rb
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -23,19 +23,11 @@ module Ci
end
def builds_matching_tag_ids(relation, ids)
- if use_denormalized_data_strategy?
- relation.for_tags(runner.tags_ids)
- else
- relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id'))
- end
+ relation.for_tags(runner.tags_ids)
end
def builds_with_any_tags(relation)
- if use_denormalized_data_strategy?
- relation.where('cardinality(tag_ids) > 0')
- else
- relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id'))
- end
+ relation.where('cardinality(tag_ids) > 0')
end
def order(relation)
@@ -50,23 +42,10 @@ module Ci
relation.pluck(:build_id)
end
- def use_denormalized_data_strategy?
- ::Feature.enabled?(:ci_queuing_use_denormalized_data_strategy)
- end
-
private
def builds_available_for_shared_runners
- if use_denormalized_data_strategy?
- new_builds.with_instance_runners
- else
- new_builds
- # don't run projects which have not enabled shared runners and builds
- .joins('INNER JOIN projects ON ci_pending_builds.project_id = projects.id')
- .where(projects: { shared_runners_enabled: true, pending_delete: false })
- .joins('LEFT JOIN project_features ON ci_pending_builds.project_id = project_features.project_id')
- .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0')
- end
+ new_builds.with_instance_runners
end
def builds_ordered_for_shared_runners(relation)
diff --git a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb
new file mode 100644
index 00000000000..e04079bfe27
--- /dev/null
+++ b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ReconcileExistingRunnerVersionsService
+ include BaseServiceUtility
+
+ VERSION_BATCH_SIZE = 100
+
+ def execute
+ insert_result = insert_runner_versions
+ total_deleted = cleanup_runner_versions(insert_result[:versions_from_runners])
+ total_updated = update_status_on_outdated_runner_versions(insert_result[:versions_from_runners])
+
+ success({
+ total_inserted: insert_result[:new_record_count],
+ total_updated: total_updated,
+ total_deleted: total_deleted
+ })
+ end
+
+ private
+
+ def upgrade_check
+ Gitlab::Ci::RunnerUpgradeCheck.instance
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def insert_runner_versions
+ versions_from_runners = Set[]
+ new_record_count = 0
+ Ci::Runner.distinct_each_batch(column: :version, of: VERSION_BATCH_SIZE) do |version_batch|
+ batch_versions = version_batch.pluck(:version).to_set
+ versions_from_runners += batch_versions
+
+ # Avoid hitting primary DB
+ already_existing_versions = Ci::RunnerVersion.where(version: batch_versions).pluck(:version)
+ new_versions = batch_versions - already_existing_versions
+
+ if new_versions.any?
+ new_record_count += Ci::RunnerVersion.insert_all(
+ new_versions.map { |v| { version: v } },
+ returning: :version,
+ unique_by: :version).count
+ end
+ end
+
+ { versions_from_runners: versions_from_runners, new_record_count: new_record_count }
+ end
+
+ def cleanup_runner_versions(versions_from_runners)
+ Ci::RunnerVersion.where.not(version: versions_from_runners).delete_all
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def outdated_runner_versions
+ Ci::RunnerVersion.potentially_outdated
+ end
+
+ def update_status_on_outdated_runner_versions(versions_from_runners)
+ total_updated = 0
+
+ outdated_runner_versions.each_batch(of: VERSION_BATCH_SIZE) do |version_batch|
+ updated = version_batch
+ .select { |runner_version| versions_from_runners.include?(runner_version['version']) }
+ .filter_map { |runner_version| runner_version_with_updated_status(runner_version) }
+
+ if updated.any?
+ total_updated += Ci::RunnerVersion.upsert_all(updated, unique_by: :version).count
+ end
+ end
+
+ total_updated
+ end
+
+ def runner_version_with_updated_status(runner_version)
+ version = runner_version['version']
+ suggestion = upgrade_check.check_runner_upgrade_status(version)
+ new_status = suggestion.each_key.first
+
+ if new_status != :error && new_status != runner_version['status'].to_sym
+ {
+ version: version,
+ status: Ci::RunnerVersion.statuses[new_status]
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb
index 196d2de1a65..6588cd7e248 100644
--- a/app/services/ci/runners/register_runner_service.rb
+++ b/app/services/ci/runners/register_runner_service.rb
@@ -8,7 +8,19 @@ module Ci
return unless runner_type_attrs
- ::Ci::Runner.create(attributes.merge(runner_type_attrs))
+ runner = ::Ci::Runner.new(attributes.merge(runner_type_attrs))
+
+ Ci::BulkInsertableTags.with_bulk_insert_tags do
+ Ci::Runner.transaction do
+ if runner.save
+ Gitlab::Ci::Tags::BulkInsert.bulk_insert_tags!([runner])
+ else
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ runner
end
private
diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb
index 7323ad417ea..2214a6a2729 100644
--- a/app/services/ci/test_failure_history_service.rb
+++ b/app/services/ci/test_failure_history_service.rb
@@ -81,7 +81,7 @@ module Ci
def generate_test_suite!(build)
# Returns an instance of Gitlab::Ci::Reports::TestSuite
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new)
end
def ci_unit_test_attrs(batch)
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index a525ea179e0..58927a90b6e 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -14,8 +14,6 @@ module Ci
# Add a build to the pending builds queue
#
def push(build, transition)
- return unless maintain_pending_builds_queue?
-
raise InvalidQueueTransition unless transition.to == 'pending'
transition.within_transaction do
@@ -33,8 +31,6 @@ module Ci
# Remove a build from the pending builds queue
#
def pop(build, transition)
- return unless maintain_pending_builds_queue?
-
raise InvalidQueueTransition unless transition.from == 'pending'
transition.within_transaction { remove!(build) }
@@ -57,7 +53,6 @@ module Ci
# Add shared runner build tracking entry (used for queuing).
#
def track(build, transition)
- return unless maintain_pending_builds_queue?
return unless build.shared_runner_build?
raise InvalidQueueTransition unless transition.to == 'running'
@@ -78,7 +73,6 @@ module Ci
# queuing).
#
def untrack(build, transition)
- return unless maintain_pending_builds_queue?
return unless build.shared_runner_build?
raise InvalidQueueTransition unless transition.from == 'running'
@@ -115,9 +109,5 @@ module Ci
runner.pick_build!(build)
end
end
-
- def maintain_pending_builds_queue?
- ::Ci::PendingBuild.maintain_denormalized_data?
- end
end
end
diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb
index 733b684bcc6..2118dbcc19e 100644
--- a/app/services/ci/update_pending_build_service.rb
+++ b/app/services/ci/update_pending_build_service.rb
@@ -15,8 +15,6 @@ module Ci
end
def execute
- return unless ::Ci::PendingBuild.maintain_denormalized_data?
-
@model.pending_builds.each_batch do |relation|
relation.update_all(@update_params)
end
diff --git a/app/services/clusters/integrations/create_service.rb b/app/services/clusters/integrations/create_service.rb
index 142f731a7d3..555df52d177 100644
--- a/app/services/clusters/integrations/create_service.rb
+++ b/app/services/clusters/integrations/create_service.rb
@@ -31,8 +31,6 @@ module Clusters
case params[:application_type]
when 'prometheus'
cluster.find_or_build_integration_prometheus
- when 'elastic_stack'
- cluster.find_or_build_integration_elastic_stack
else
raise ArgumentError, "invalid application_type: #{params[:application_type]}"
end
diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb
index abbfd4d66d4..f10ff4e6f19 100644
--- a/app/services/concerns/alert_management/alert_processing.rb
+++ b/app/services/concerns/alert_management/alert_processing.rb
@@ -38,7 +38,7 @@ module AlertManagement
if alert.resolve(incoming_payload.ends_at)
SystemNoteService.change_alert_status(alert, User.alert_bot)
- close_issue(alert.issue) if auto_close_incident?
+ close_issue(alert.issue_id) if auto_close_incident?
else
logger.warn(
message: 'Unable to update AlertManagement::Alert status to resolved',
@@ -52,22 +52,18 @@ module AlertManagement
alert.register_new_event!
end
- def close_issue(issue)
- return if issue.blank? || issue.closed?
+ def close_issue(issue_id)
+ return unless issue_id
- ::Issues::CloseService
- .new(project: project, current_user: User.alert_bot)
- .execute(issue, system_note: false)
-
- SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed?
+ ::IncidentManagement::CloseIncidentWorker.perform_async(issue_id)
end
def process_new_alert
+ return if resolving_alert?
+
if alert.save
alert.execute_integrations
SystemNoteService.create_new_alert(alert, alert_source)
-
- process_resolved_alert if resolving_alert?
else
logger.warn(
message: "Unable to create AlertManagement::Alert from #{alert_source}",
@@ -78,7 +74,7 @@ module AlertManagement
end
def process_incident_issues
- return if alert.issue || alert.resolved?
+ return if alert.issue_id || alert.resolved?
::IncidentManagement::ProcessAlertWorkerV2.perform_async(alert.id)
end
diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb
index acaa773fd49..ae1e1d1e66c 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -63,7 +63,7 @@ module Integrations
return { error: s_('TestHooks|Ensure the project has deployments.') } unless deployment.present?
- Gitlab::DataBuilder::Deployment.build(deployment, Time.current)
+ Gitlab::DataBuilder::Deployment.build(deployment, deployment.status, Time.current)
end
def releases_events_data
diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb
new file mode 100644
index 00000000000..5665b07dce1
--- /dev/null
+++ b/app/services/concerns/work_items/widgetable_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module WidgetableService
+ def execute_widgets(work_item:, callback:, widget_params: {})
+ work_item.widgets.each do |widget|
+ widget_service(widget).try(callback, params: widget_params[widget.class.api_symbol])
+ end
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def widget_service(widget)
+ @widget_services ||= {}
+ return @widget_services[widget] if @widget_services.has_key?(widget)
+
+ @widget_services[widget] = widget_service_class(widget)&.new(widget: widget, current_user: current_user)
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def widget_service_class(widget)
+ "WorkItems::Widgets::#{widget.type.capitalize}Service::#{self.class.name.demodulize}".constantize
+ rescue NameError
+ nil
+ end
+ end
+end
diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_build_service.rb
index b3e2d2edb59..76d871161e3 100644
--- a/app/services/deployments/create_for_build_service.rb
+++ b/app/services/deployments/create_for_build_service.rb
@@ -11,8 +11,18 @@ module Deployments
# TODO: Move all buisness logic in `Seed::Deployment` to this class after
# `create_deployment_in_separate_transaction` feature flag has been removed.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/348778
+
+ # If build.persisted_environment is a BatchLoader, we need to remove
+ # the method proxy in order to clone into new item here
+ # https://github.com/exAspArk/batch-loader/issues/31
+ environment = if build.persisted_environment.respond_to?(:__sync)
+ build.persisted_environment.__sync
+ else
+ build.persisted_environment
+ end
+
deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
- .new(build, build.persisted_environment).to_resource
+ .new(build, environment).to_resource
return unless deployment
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index ca7208dba96..eae736ae10d 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -75,6 +75,7 @@ module ErrorTracking
# For now we implement the bare minimum for rendering the list in UI.
list_opts = {
filters: { status: opts[:issue_status] },
+ query: opts[:search_term],
sort: opts[:sort],
limit: opts[:limit],
cursor: opts[:cursor]
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 2ab4bb47462..019246dfc9f 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -25,14 +25,18 @@ class EventCreateService
def open_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :created).tap do
track_event(event_action: :created, event_target: MergeRequest, author_id: current_user.id)
- track_mr_snowplow_event(merge_request, current_user, :create)
+ track_snowplow_event(merge_request, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
+ :create, 'merge_requests_users')
end
end
def close_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :closed).tap do
track_event(event_action: :closed, event_target: MergeRequest, author_id: current_user.id)
- track_mr_snowplow_event(merge_request, current_user, :close)
+ track_snowplow_event(merge_request, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
+ :close, 'merge_requests_users')
end
end
@@ -43,7 +47,9 @@ class EventCreateService
def merge_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :merged).tap do
track_event(event_action: :merged, event_target: MergeRequest, author_id: current_user.id)
- track_mr_snowplow_event(merge_request, current_user, :merge)
+ track_snowplow_event(merge_request, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
+ :merge, 'merge_requests_users')
end
end
@@ -67,7 +73,9 @@ class EventCreateService
create_record_event(note, current_user, :commented).tap do
if note.is_a?(DiffNote) && note.for_merge_request?
track_event(event_action: :commented, event_target: MergeRequest, author_id: current_user.id)
- track_mr_snowplow_event(note, current_user, :comment)
+ track_snowplow_event(note, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION,
+ :comment, 'merge_requests_users')
end
end
end
@@ -100,12 +108,27 @@ class EventCreateService
records = create.zip([:created].cycle) + update.zip([:updated].cycle)
return [] if records.empty?
+ if create.any?
+ track_snowplow_event(create.first, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
+ :create, 'design_users')
+ end
+
+ if update.any?
+ track_snowplow_event(update.first, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
+ :update, 'design_users')
+ end
+
create_record_events(records, current_user)
end
def destroy_designs(designs, current_user)
return [] unless designs.present?
+ track_snowplow_event(designs.first, current_user,
+ Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION,
+ :destroy, 'design_users')
create_record_events(designs.zip([:destroyed].cycle), current_user)
end
@@ -230,14 +253,14 @@ class EventCreateService
Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(**params)
end
- def track_mr_snowplow_event(record, current_user, action)
+ def track_snowplow_event(record, current_user, category, action, label)
return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
project = record.project
Gitlab::Tracking.event(
- Gitlab::UsageDataCounters::TrackUniqueEvents::MERGE_REQUEST_ACTION.to_s,
+ category.to_s,
action.to_s,
- label: 'merge_requests_users',
+ label: label,
project: project,
namespace: project.namespace,
user: current_user
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index 86dc6188f0a..59db1a5f12f 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -15,6 +15,10 @@ module FeatureFlags
protected
+ def update_last_feature_flag_updated_at!
+ Operations::FeatureFlagsClient.update_last_feature_flag_updated_at!(project)
+ end
+
def audit_event(feature_flag)
message = audit_message(feature_flag)
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index ebbe71f39c7..6ea40345191 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -10,6 +10,8 @@ module FeatureFlags
feature_flag = project.operations_feature_flags.new(params)
if feature_flag.save
+ update_last_feature_flag_updated_at!
+
success(feature_flag: feature_flag)
else
error(feature_flag.errors.full_messages, 400)
diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb
index 817a80940c0..0fdc890b8a3 100644
--- a/app/services/feature_flags/destroy_service.rb
+++ b/app/services/feature_flags/destroy_service.rb
@@ -13,6 +13,8 @@ module FeatureFlags
ApplicationRecord.transaction do
if feature_flag.destroy
+ update_last_feature_flag_updated_at!
+
success(feature_flag: feature_flag)
else
error(feature_flag.errors.full_messages)
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
index bcfd2c15189..a465ca1dd5f 100644
--- a/app/services/feature_flags/update_service.rb
+++ b/app/services/feature_flags/update_service.rb
@@ -29,6 +29,8 @@ module FeatureFlags
audit_event = audit_event(feature_flag)
if feature_flag.save
+ update_last_feature_flag_updated_at!
+
success(feature_flag: feature_flag, audit_event: audit_event)
else
error(feature_flag.errors.full_messages, :bad_request)
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index aa471d3a69f..7de56c037ed 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -81,6 +81,8 @@ module Git
branch_update_hooks if updating_branch?
branch_change_hooks if creating_branch? || updating_branch?
branch_remove_hooks if removing_branch?
+
+ track_process_commit_limit_overflow
end
def branch_create_hooks
@@ -123,6 +125,12 @@ module Git
end
end
+ def track_process_commit_limit_overflow
+ return if threshold_commits.count <= PROCESS_COMMIT_LIMIT
+
+ Gitlab::Metrics.add_event(:process_commit_limit_overflow)
+ end
+
# Schedules processing of commit messages
def enqueue_process_commit_messages
referencing_commits = limited_commits.select(&:matches_cross_reference_regex?)
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 91f14251608..464d79d9991 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -39,13 +39,13 @@ module Git
def enqueue_update_mrs
return if params[:merge_request_branches]&.exclude?(branch_name)
- # TODO: pass params[:push_options] to worker
UpdateMergeRequestsWorker.perform_async(
project.id,
current_user.id,
oldrev,
newrev,
- ref
+ ref,
+ params.slice(:push_options).deep_stringify_keys
)
end
diff --git a/app/services/google_cloud/base_service.rb b/app/services/google_cloud/base_service.rb
new file mode 100644
index 00000000000..016ab15408f
--- /dev/null
+++ b/app/services/google_cloud/base_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class BaseService < ::BaseService
+ protected
+
+ def google_oauth2_token
+ @params[:google_oauth2_token]
+ end
+
+ def gcp_project_id
+ @params[:gcp_project_id]
+ end
+
+ def environment_name
+ @params[:environment_name]
+ end
+
+ def google_api_client
+ @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
+ end
+
+ def unique_gcp_project_ids
+ filter_params = { key: 'GCP_PROJECT_ID' }
+ ::Ci::VariablesFinder.new(project, filter_params).execute.map(&:value).uniq
+ end
+
+ def group_vars_by_environment(keys)
+ filtered_vars = project.variables.filter { |variable| keys.include? variable.key }
+ filtered_vars.each_with_object({}) do |variable, grouped|
+ grouped[variable.environment_scope] ||= {}
+ grouped[variable.environment_scope][variable.key] = variable.value
+ end
+ end
+
+ def create_or_replace_project_vars(environment_scope, key, value, is_protected, is_masked = false)
+ change_params = {
+ variable_params: {
+ key: key,
+ value: value,
+ environment_scope: environment_scope,
+ protected: is_protected,
+ masked: is_masked
+ }
+ }
+ existing_variable = find_existing_variable(environment_scope, key)
+
+ if existing_variable
+ change_params[:action] = :update
+ change_params[:variable] = existing_variable
+ else
+ change_params[:action] = :create
+ end
+
+ ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
+ end
+
+ private
+
+ def find_existing_variable(environment_scope, key)
+ filter_params = { key: key, filter: { environment_scope: environment_scope } }
+ ::Ci::VariablesFinder.new(project, filter_params).execute.first
+ end
+ 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 51d08cc5b55..9617161b8e9 100644
--- a/app/services/google_cloud/create_service_accounts_service.rb
+++ b/app/services/google_cloud/create_service_accounts_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module GoogleCloud
- class CreateServiceAccountsService < :: BaseService
+ class CreateServiceAccountsService < ::GoogleCloud::BaseService
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)
@@ -23,22 +23,6 @@ module GoogleCloud
private
- def google_oauth2_token
- @params[:google_oauth2_token]
- end
-
- def gcp_project_id
- @params[:gcp_project_id]
- end
-
- def environment_name
- @params[:environment_name]
- end
-
- def google_api_client
- @google_api_client_instance ||= GoogleApi::CloudPlatform::Client.new(google_oauth2_token, nil)
- end
-
def service_accounts_service
GoogleCloud::ServiceAccountsService.new(project)
end
diff --git a/app/services/google_cloud/enable_cloud_run_service.rb b/app/services/google_cloud/enable_cloud_run_service.rb
index 643f2b2b6d2..4fd92f423c5 100644
--- a/app/services/google_cloud/enable_cloud_run_service.rb
+++ b/app/services/google_cloud/enable_cloud_run_service.rb
@@ -1,15 +1,13 @@
# frozen_string_literal: true
module GoogleCloud
- class EnableCloudRunService < :: BaseService
+ class EnableCloudRunService < ::GoogleCloud::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)
@@ -19,16 +17,5 @@ module GoogleCloud
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/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb
index 467f818bcc7..f79df707a08 100644
--- a/app/services/google_cloud/gcp_region_add_or_replace_service.rb
+++ b/app/services/google_cloud/gcp_region_add_or_replace_service.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
module GoogleCloud
- class GcpRegionAddOrReplaceService < ::BaseService
+ class GcpRegionAddOrReplaceService < ::GoogleCloud::BaseService
def execute(environment, region)
- gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY
+ gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY
change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } }
filter_params = { key: gcp_region_key, filter: { environment_scope: environment } }
diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
index 077f815e60c..be0c7a783c9 100644
--- a/app/services/google_cloud/generate_pipeline_service.rb
+++ b/app/services/google_cloud/generate_pipeline_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module GoogleCloud
- class GeneratePipelineService < :: BaseService
+ class GeneratePipelineService < ::GoogleCloud::BaseService
ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN'
ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE'
diff --git a/app/services/google_cloud/service_accounts_service.rb b/app/services/google_cloud/service_accounts_service.rb
index b791f07cd65..e90fd112e2e 100644
--- a/app/services/google_cloud/service_accounts_service.rb
+++ b/app/services/google_cloud/service_accounts_service.rb
@@ -8,7 +8,7 @@ module GoogleCloud
##
# This service deals with GCP Service Accounts in GitLab
- class ServiceAccountsService < ::BaseService
+ class ServiceAccountsService < ::GoogleCloud::BaseService
##
# Find GCP Service Accounts in a GitLab project
#
@@ -17,7 +17,7 @@ module GoogleCloud
# aligning GitLab project and ref to GCP projects
def find_for_project
- group_vars_by_ref.map do |environment_scope, value|
+ group_vars_by_environment(GCP_KEYS).map do |environment_scope, value|
{
ref: environment_scope,
gcp_project: value['GCP_PROJECT_ID'],
@@ -28,50 +28,24 @@ module GoogleCloud
end
def add_for_project(ref, gcp_project_id, service_account, service_account_key, is_protected)
- project_var_create_or_replace(
+ create_or_replace_project_vars(
ref,
'GCP_PROJECT_ID',
gcp_project_id,
is_protected
)
- project_var_create_or_replace(
+ create_or_replace_project_vars(
ref,
'GCP_SERVICE_ACCOUNT',
service_account,
is_protected
)
- project_var_create_or_replace(
+ create_or_replace_project_vars(
ref,
'GCP_SERVICE_ACCOUNT_KEY',
service_account_key,
is_protected
)
end
-
- private
-
- def group_vars_by_ref
- filtered_vars = project.variables.filter { |variable| GCP_KEYS.include? variable.key }
- filtered_vars.each_with_object({}) do |variable, grouped|
- grouped[variable.environment_scope] ||= {}
- grouped[variable.environment_scope][variable.key] = variable.value
- end
- end
-
- def project_var_create_or_replace(environment_scope, key, value, is_protected)
- change_params = { variable_params: { key: key, value: value, environment_scope: environment_scope, protected: is_protected } }
- filter_params = { key: key, filter: { environment_scope: environment_scope } }
-
- existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
-
- if existing_variable
- change_params[:action] = :update
- change_params[:variable] = existing_variable
- else
- change_params[:action] = :create
- end
-
- ::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
- end
end
end
diff --git a/app/services/google_cloud/setup_cloudsql_instance_service.rb b/app/services/google_cloud/setup_cloudsql_instance_service.rb
new file mode 100644
index 00000000000..73650ee752f
--- /dev/null
+++ b/app/services/google_cloud/setup_cloudsql_instance_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class SetupCloudsqlInstanceService < ::GoogleCloud::BaseService
+ INSTANCE_STATE_RUNNABLE = 'RUNNABLE'
+ OPERATION_STATE_DONE = 'DONE'
+ DEFAULT_DATABASE_NAME = 'main_db'
+ DEFAULT_DATABASE_USER = 'main_user'
+
+ def execute
+ return error('Unauthorized user') unless Ability.allowed?(current_user, :admin_project_google_cloud, project)
+
+ get_instance_response = google_api_client.get_cloudsql_instance(gcp_project_id, instance_name)
+
+ if get_instance_response.state != INSTANCE_STATE_RUNNABLE
+ return error("CloudSQL instance not RUNNABLE: #{get_instance_response.to_json}")
+ end
+
+ database_response = google_api_client.create_cloudsql_database(gcp_project_id, instance_name, database_name)
+
+ if database_response.status != OPERATION_STATE_DONE
+ return error("Database creation failed: #{database_response.to_json}")
+ end
+
+ user_response = google_api_client.create_cloudsql_user(gcp_project_id, instance_name, username, password)
+
+ if user_response.status != OPERATION_STATE_DONE
+ return error("User creation failed: #{user_response.to_json}")
+ end
+
+ primary_ip_address = get_instance_response.ip_addresses.first.ip_address
+ connection_name = get_instance_response.connection_name
+
+ save_ci_var('GCP_PROJECT_ID', gcp_project_id)
+ save_ci_var('GCP_CLOUDSQL_INSTANCE_NAME', instance_name)
+ save_ci_var('GCP_CLOUDSQL_CONNECTION_NAME', connection_name)
+ save_ci_var('GCP_CLOUDSQL_PRIMARY_IP_ADDRESS', primary_ip_address)
+ save_ci_var('GCP_CLOUDSQL_VERSION', database_version)
+ save_ci_var('GCP_CLOUDSQL_DATABASE_NAME', database_name)
+ save_ci_var('GCP_CLOUDSQL_DATABASE_USER', username)
+ save_ci_var('GCP_CLOUDSQL_DATABASE_PASS', password, true)
+
+ success
+ rescue Google::Apis::Error => err
+ error(message: err.to_json)
+ end
+
+ private
+
+ def instance_name
+ @params[:instance_name]
+ end
+
+ def database_version
+ @params[:database_version]
+ end
+
+ def database_name
+ @params.fetch(:database_name, DEFAULT_DATABASE_NAME)
+ end
+
+ def username
+ @params.fetch(:username, DEFAULT_DATABASE_USER)
+ end
+
+ def password
+ SecureRandom.hex(16)
+ end
+
+ def save_ci_var(key, value, is_masked = false)
+ create_or_replace_project_vars(environment_name, key, value, @params[:is_protected], is_masked)
+ end
+ end
+end
diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb
index a689b088854..9d5990f2c8a 100644
--- a/app/services/gravatar_service.rb
+++ b/app/services/gravatar_service.rb
@@ -2,6 +2,7 @@
class GravatarService
def execute(email, size = nil, scale = 2, username: nil)
+ return if Gitlab::FIPS.enabled?
return unless Gitlab::CurrentSettings.gravatar_enabled?
identifier = email.presence || username.presence
diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb
index 06136aff50e..9705f3a560d 100644
--- a/app/services/groups/base_service.rb
+++ b/app/services/groups/base_service.rb
@@ -13,11 +13,11 @@ module Groups
private
def handle_namespace_settings
- settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)
+ settings_params = params.slice(*::NamespaceSetting.allowed_namespace_settings_params)
return if settings_params.empty?
- ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp|
+ ::NamespaceSetting.allowed_namespace_settings_params.each do |nsp|
params.delete(nsp)
end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 639f7c68c40..35716f7742a 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -13,7 +13,7 @@ module Groups
remove_unallowed_params
set_visibility_level
- @group = Group.new(params.except(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS))
+ @group = Group.new(params.except(*::NamespaceSetting.allowed_namespace_settings_params))
@group.build_namespace_settings
handle_namespace_settings
diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb
index 0e7fd7e0817..4d74b5f32e2 100644
--- a/app/services/groups/group_links/destroy_service.rb
+++ b/app/services/groups/group_links/destroy_service.rb
@@ -16,6 +16,8 @@ module Groups
groups_to_refresh = links.map(&:shared_with_group)
groups_to_refresh.uniq.each do |group|
+ next if Feature.enabled?(:skip_group_share_unlink_auth_refresh, group.root_ancestor)
+
group.refresh_members_authorized_projects(blocking: false, direct_members_only: true)
end
else
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index a0021ae2ccb..29e3a9473ab 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -162,6 +162,12 @@ module Groups
projects_to_update
.update_all(visibility_level: @new_parent_group.visibility_level)
+
+ update_project_settings(@updated_project_ids)
+ end
+
+ # Overridden in EE
+ def update_project_settings(updated_project_ids)
end
def update_two_factor_authentication
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 b7f8b268f18..d11492e062a 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
@@ -6,7 +6,6 @@ module IncidentManagement
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, params: params)
end
@@ -19,29 +18,24 @@ module IncidentManagement
private
- attr_reader :issuable, :escalation_status, :alert
+ attr_reader :issuable, :escalation_status
def after_update
- sync_status_to_alert
add_status_system_note
+ add_timeline_event
end
- def sync_status_to_alert
- return unless alert
- return if alert.status == escalation_status.status
+ def add_status_system_note
+ return unless escalation_status.status_previously_changed?
- ::AlertManagement::Alerts::UpdateService.new(
- alert,
- current_user,
- status: escalation_status.status_name,
- status_change_reason: " by changing the incident status of #{issuable.to_reference(project)}"
- ).execute
+ SystemNoteService.change_incident_status(issuable, current_user, params[:status_change_reason])
end
- def add_status_system_note
+ def add_timeline_event
return unless escalation_status.status_previously_changed?
- SystemNoteService.change_incident_status(issuable, current_user, params[:status_change_reason])
+ IncidentManagement::TimelineEvents::CreateService
+ .change_incident_status(issuable, current_user, escalation_status)
end
end
end
diff --git a/app/services/incident_management/issuable_escalation_statuses/build_service.rb b/app/services/incident_management/issuable_escalation_statuses/build_service.rb
index 9ebcf72a0c9..b3c57da03cb 100644
--- a/app/services/incident_management/issuable_escalation_statuses/build_service.rb
+++ b/app/services/incident_management/issuable_escalation_statuses/build_service.rb
@@ -5,30 +5,17 @@ module IncidentManagement
class BuildService < ::BaseProjectService
def initialize(issue)
@issue = issue
- @alert = issue.alert_management_alert
super(project: issue.project)
end
def execute
- return issue.escalation_status if issue.escalation_status
-
- issue.build_incident_management_issuable_escalation_status(alert_params)
+ issue.escalation_status || issue.build_incident_management_issuable_escalation_status
end
private
- attr_reader :issue, :alert
-
- def alert_params
- return {} unless alert
-
- {
- status_event: alert.status_event_for(alert.status_name)
- }
- end
+ attr_reader :issue
end
end
end
-
-IncidentManagement::IssuableEscalationStatuses::BuildService.prepend_mod
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 1d0504a6e80..58777848151 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
@@ -5,7 +5,7 @@ module IncidentManagement
class PrepareUpdateService < ::BaseProjectService
include Gitlab::Utils::StrongMemoize
- SUPPORTED_PARAMS = %i[status status_change_reason].freeze
+ SUPPORTED_PARAMS = %i[status].freeze
def initialize(issuable, current_user, params)
@issuable = issuable
diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb
index 5e5feed65c2..3cb67ccf2b1 100644
--- a/app/services/incident_management/timeline_events/create_service.rb
+++ b/app/services/incident_management/timeline_events/create_service.rb
@@ -4,6 +4,7 @@ module IncidentManagement
module TimelineEvents
DEFAULT_ACTION = 'comment'
DEFAULT_EDITABLE = false
+ DEFAULT_AUTO_CREATED = false
class CreateService < TimelineEvents::BaseService
def initialize(incident, user, params)
@@ -11,6 +12,42 @@ module IncidentManagement
@incident = incident
@user = user
@params = params
+ @auto_created = !!params.fetch(:auto_created, DEFAULT_AUTO_CREATED)
+ end
+
+ class << self
+ def create_incident(incident, user)
+ note = "@#{user.username} created the incident"
+ occurred_at = incident.created_at
+ action = 'issues'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
+
+ def reopen_incident(incident, user)
+ note = "@#{user.username} reopened the incident"
+ occurred_at = incident.updated_at
+ action = 'issues'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
+
+ def resolve_incident(incident, user)
+ note = "@#{user.username} resolved the incident"
+ occurred_at = incident.updated_at
+ action = 'status'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
+
+ def change_incident_status(incident, user, escalation_status)
+ status = escalation_status.status_name.to_s.titleize
+ note = "@#{user.username} changed the incident status to **#{status}**"
+ occurred_at = incident.updated_at
+ action = 'status'
+
+ new(incident, user, note: note, occurred_at: occurred_at, action: action, auto_created: true).execute
+ end
end
def execute
@@ -32,8 +69,8 @@ module IncidentManagement
if timeline_event.save
add_system_note(timeline_event)
-
track_usage_event(:incident_management_timeline_event_created, user.id)
+
success(timeline_event)
else
error_in_save(timeline_event)
@@ -42,9 +79,16 @@ module IncidentManagement
private
- attr_reader :project, :user, :incident, :params
+ attr_reader :project, :user, :incident, :params, :auto_created
+
+ def allowed?
+ return true if auto_created
+
+ super
+ end
def add_system_note(timeline_event)
+ return if auto_created
return unless Feature.enabled?(:incident_timeline, project)
SystemNoteService.add_timeline_event(timeline_event)
diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb
index 83497b123dd..8217c8125c2 100644
--- a/app/services/incident_management/timeline_events/update_service.rb
+++ b/app/services/incident_management/timeline_events/update_service.rb
@@ -17,7 +17,6 @@ module IncidentManagement
end
def execute
- return error_non_editable unless timeline_event.editable?
return error_no_permissions unless allowed?
if timeline_event.update(update_params)
@@ -59,8 +58,8 @@ module IncidentManagement
:none
end
- def error_non_editable
- error(_('You cannot edit this timeline event.'))
+ def allowed?
+ user&.can?(:edit_incident_management_timeline_event, timeline_event)
end
end
end
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
deleted file mode 100644
index 279d3051848..00000000000
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-module Issuable
- module Clone
- class AttributesRewriter < ::Issuable::Clone::BaseService
- def initialize(current_user, original_entity, new_entity)
- @current_user = current_user
- @original_entity = original_entity
- @new_entity = new_entity
- end
-
- def execute
- update_attributes = { labels: cloneable_labels }
-
- milestone = matching_milestone(original_entity.milestone&.title)
- update_attributes[:milestone] = milestone if milestone.present?
-
- new_entity.update(update_attributes)
-
- copy_resource_label_events
- copy_resource_milestone_events
- copy_resource_state_events
- end
-
- private
-
- def matching_milestone(title)
- return if title.blank? || !new_entity.supports_milestone?
-
- params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id }
-
- milestones = MilestonesFinder.new(params).execute
- milestones.first
- end
-
- def cloneable_labels
- params = {
- project_id: new_entity.project&.id,
- group_id: group&.id,
- title: original_entity.labels.select(:title),
- include_ancestor_groups: true
- }
-
- params[:only_group_labels] = true if new_parent.is_a?(Group)
-
- LabelsFinder.new(current_user, params).execute
- end
-
- def copy_resource_label_events
- copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event|
- event.attributes
- .except('id', 'reference', 'reference_html')
- .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action])
- end
- end
-
- def copy_resource_milestone_events
- return unless milestone_events_supported?
-
- copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event|
- if event.remove?
- event_attributes_with_milestone(event, nil)
- else
- matching_destination_milestone = matching_milestone(event.milestone_title)
-
- event_attributes_with_milestone(event, matching_destination_milestone) if matching_destination_milestone.present?
- end
- end
- end
-
- def copy_resource_state_events
- return unless state_events_supported?
-
- copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event|
- event.attributes
- .except(*blocked_state_event_attributes)
- .merge(entity_key => new_entity.id,
- 'state' => ResourceStateEvent.states[event.state])
- end
- end
-
- # Overriden on EE::Issuable::Clone::AttributesRewriter
- def blocked_state_event_attributes
- ['id']
- end
-
- def event_attributes_with_milestone(event, milestone)
- event.attributes
- .except('id')
- .merge(entity_key => new_entity.id,
- 'milestone_id' => milestone&.id,
- 'action' => ResourceMilestoneEvent.actions[event.action],
- 'state' => ResourceMilestoneEvent.states[event.state])
- end
-
- def copy_events(table_name, events_to_copy)
- events_to_copy.find_in_batches do |batch|
- events = batch.map do |event|
- yield(event)
- end.compact
-
- ApplicationRecord.legacy_bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert
- end
- end
-
- def entity_key
- new_entity.class.name.underscore.foreign_key
- end
-
- def milestone_events_supported?
- both_respond_to?(:resource_milestone_events)
- end
-
- def state_events_supported?
- both_respond_to?(:resource_state_events)
- end
-
- def both_respond_to?(method)
- original_entity.respond_to?(method) &&
- new_entity.respond_to?(method)
- end
- end
- end
-end
-
-Issuable::Clone::AttributesRewriter.prepend_mod_with('Issuable::Clone::AttributesRewriter')
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index ce9918a4b56..98c50347719 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -3,13 +3,13 @@
module Issuable
module Clone
class BaseService < IssuableBaseService
- attr_reader :original_entity, :new_entity, :target_project
+ attr_reader :original_entity, :new_entity
alias_method :old_project, :project
- def execute(original_entity, target_project = nil)
+ def execute(original_entity, target_parent)
@original_entity = original_entity
- @target_project = target_project
+ @target_parent = target_parent
# Using transaction because of a high resources footprint
# on rewriting notes (unfolding references)
@@ -25,19 +25,21 @@ module Issuable
private
- def copy_award_emoji
- AwardEmojis::CopyService.new(original_entity, new_entity).execute
- end
+ attr_reader :target_parent
- def copy_notes
- Notes::CopyService.new(current_user, original_entity, new_entity).execute
+ def rewritten_old_entity_attributes(include_milestone: true)
+ Gitlab::Issuable::Clone::AttributesRewriter.new(
+ current_user,
+ original_entity,
+ target_parent
+ ).execute(include_milestone: include_milestone)
end
def update_new_entity
update_new_entity_description
- update_new_entity_attributes
copy_award_emoji
copy_notes
+ copy_resource_events
end
def update_new_entity_description
@@ -52,8 +54,16 @@ module Issuable
new_entity.update!(update_description_params)
end
- def update_new_entity_attributes
- AttributesRewriter.new(current_user, original_entity, new_entity).execute
+ def copy_award_emoji
+ AwardEmojis::CopyService.new(original_entity, new_entity).execute
+ end
+
+ def copy_notes
+ Notes::CopyService.new(current_user, original_entity, new_entity).execute
+ end
+
+ def copy_resource_events
+ Gitlab::Issuable::Clone::CopyResourceEventsService.new(current_user, original_entity, new_entity).execute
end
def update_old_entity
@@ -74,14 +84,8 @@ module Issuable
new_entity.resource_parent
end
- def group
- if new_entity.project&.group && current_user.can?(:read_group, new_entity.project.group)
- new_entity.project.group
- end
- end
-
def relative_position
- return if original_entity.project.root_ancestor.id != target_project.root_ancestor.id
+ return if original_entity.project.root_ancestor.id != target_parent.root_ancestor.id
original_entity.relative_position
end
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 4a2078a4e60..9b41c88159f 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -23,7 +23,8 @@ module Issuable
with_csv_lines.each do |row, line_no|
issuable_attributes = {
title: row[:title],
- description: row[:description]
+ description: row[:description],
+ due_date: row[:due_date]
}
if create_issuable(issuable_attributes).persisted?
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 03115416607..acd6d45af7a 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -162,8 +162,6 @@ class IssuableBaseService < ::BaseProjectService
return unless result.success? && result[:escalation_status].present?
- @escalation_status_change_reason = result[:escalation_status].delete(:status_change_reason)
-
params[:incident_management_issuable_escalation_status_attributes] = result[:escalation_status]
end
@@ -231,7 +229,7 @@ class IssuableBaseService < ::BaseProjectService
before_create(issuable)
issuable_saved = issuable.with_transaction_returning_status do
- issuable.save
+ transaction_create(issuable)
end
if issuable_saved
@@ -282,8 +280,9 @@ class IssuableBaseService < ::BaseProjectService
assign_requested_labels(issuable)
assign_requested_assignees(issuable)
assign_requested_crm_contacts(issuable)
+ widget_params = filter_widget_params
- if issuable.changed? || params.present?
+ if issuable.changed? || params.present? || widget_params.present?
issuable.assign_attributes(allowed_update_params(params))
if has_title_or_description_changed?(issuable)
@@ -303,7 +302,7 @@ class IssuableBaseService < ::BaseProjectService
ensure_milestone_available(issuable)
issuable_saved = issuable.with_transaction_returning_status do
- issuable.save(touch: should_touch)
+ transaction_update(issuable, { save_with_touch: should_touch })
end
if issuable_saved
@@ -332,6 +331,16 @@ class IssuableBaseService < ::BaseProjectService
issuable
end
+ def transaction_update(issuable, opts = {})
+ touch = opts[:save_with_touch] || false
+
+ issuable.save(touch: touch)
+ end
+
+ def transaction_create(issuable)
+ issuable.save
+ end
+
def update_task(issuable)
filter_params(issuable)
@@ -590,6 +599,10 @@ class IssuableBaseService < ::BaseProjectService
issuable_sla.update(issuable_closed: issuable.closed?)
end
+
+ def filter_widget_params
+ params.delete(:widget_params)
+ end
end
IssuableBaseService.prepend_mod_with('IssuableBaseService')
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 0887f04760c..aca98596a02 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -8,6 +8,7 @@ module IssuableLinks
@issuable = issuable
@current_user = user
@params = params.dup
+ @errors = []
end
def execute
@@ -22,7 +23,6 @@ module IssuableLinks
return error(issuables_not_found_message, 404)
end
- @errors = []
references = create_links
if @errors.present?
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 1ebf9bb6ba2..75bd2b88e86 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -81,8 +81,9 @@ module Issues
::Issue
end
- def allowed_issue_params
- allowed_params = [
+ def public_params
+ # Additional params may be assigned later (in a CreateService for example)
+ public_issue_params = [
:title,
:description,
:confidential
@@ -90,17 +91,17 @@ module Issues
params[:work_item_type] = WorkItems::Type.find_by(id: params[:work_item_type_id]) if params[:work_item_type_id].present? # rubocop: disable CodeReuse/ActiveRecord
- allowed_params << :milestone_id if can?(current_user, :admin_issue, project)
- allowed_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type])
- allowed_params << :work_item_type if create_issue_type_allowed?(project, params[:work_item_type]&.base_type)
+ public_issue_params << :milestone_id if can?(current_user, :admin_issue, project)
+ public_issue_params << :issue_type if create_issue_type_allowed?(project, params[:issue_type])
+ public_issue_params << :work_item_type if create_issue_type_allowed?(project, params[:work_item_type]&.base_type)
- params.slice(*allowed_params)
+ params.slice(*public_issue_params)
end
def build_issue_params
{ author: current_user }
.merge(issue_params_with_info_from_discussions)
- .merge(allowed_issue_params)
+ .merge(public_params)
.with_indifferent_access
end
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index c675f957cd7..896b15a14b8 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -41,9 +41,12 @@ module Issues
def update_new_entity
# we don't call `super` because we want to be able to decide whether or not to copy all comments over.
update_new_entity_description
- update_new_entity_attributes
copy_award_emoji
- copy_notes if with_notes
+
+ if with_notes
+ copy_notes
+ copy_resource_events
+ end
end
def update_old_entity
@@ -62,14 +65,18 @@ module Issues
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+ new_params = new_params.merge(rewritten_old_entity_attributes)
+ new_params.delete(:created_at)
+ new_params.delete(:updated_at)
# spam checking is not necessary, as no new content is being created. Passing nil for
# spam_params will cause SpamActionService to skip checking and return a success response.
spam_params = nil
- # Skip creation of system notes for existing attributes of the issue. The system notes of the old
- # issue are copied over so we don't want to end up with duplicate notes.
- CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true)
+ # Skip creation of system notes for existing attributes of the issue when cloning with notes.
+ # The system notes of the old issue are copied over so we don't want to end up with duplicate notes.
+ # When cloning without notes, we want to generate system notes for the attributes that were copied.
+ CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: with_notes)
end
def queue_copy_designs
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index ff45091c7e6..d08e4d12a92 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -97,7 +97,10 @@ module Issues
status = issue.incident_management_issuable_escalation_status || issue.build_incident_management_issuable_escalation_status
- SystemNoteService.change_incident_status(issue, current_user, ' by closing the incident') if status.resolve
+ return unless status.resolve
+
+ SystemNoteService.change_incident_status(issue, current_user, ' by closing the incident')
+ IncidentManagement::TimelineEvents::CreateService.resolve_incident(issue, current_user)
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 edf6d75b632..30d4cb68840 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -13,6 +13,7 @@ module Issues
# in the caller (for example, an issue created via email) and the required arguments to the
# SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil.
def initialize(project:, current_user: nil, params: {}, spam_params:, build_service: nil)
+ @extra_params = params.delete(:extra_params) || {}
super(project: project, current_user: current_user, params: params)
@spam_params = spam_params
@build_service = build_service || BuildService.new(project: project, current_user: current_user, params: params)
@@ -46,7 +47,7 @@ module Issues
issue.run_after_commit do
NewIssueWorker.perform_async(issue.id, user.id)
Issues::PlacementWorker.perform_async(nil, issue.project_id)
- Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.namespace.id)
+ Namespaces::OnboardingIssueCreatedWorker.perform_async(issue.project.namespace_id)
end
end
@@ -56,7 +57,8 @@ module Issues
handle_add_related_issue(issue)
resolve_discussions_with_issue(issue)
create_escalation_status(issue)
- try_to_associate_contact(issue)
+ create_timeline_event(issue)
+ try_to_associate_contacts(issue)
super
end
@@ -85,12 +87,18 @@ module Issues
private
- attr_reader :spam_params
+ attr_reader :spam_params, :extra_params
def create_escalation_status(issue)
::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation?
end
+ def create_timeline_event(issue)
+ return unless issue.incident?
+
+ IncidentManagement::TimelineEvents::CreateService.create_incident(issue, current_user)
+ end
+
def user_agent_detail_service
UserAgentDetailService.new(spammable: @issue, spam_params: spam_params)
end
@@ -101,11 +109,14 @@ module Issues
IssueLinks::CreateService.new(issue, issue.author, { target_issuable: @add_related_issue }).execute
end
- def try_to_associate_contact(issue)
+ def try_to_associate_contacts(issue)
return unless issue.external_author
return unless current_user.can?(:set_issue_crm_contacts, issue)
- set_crm_contacts(issue, [issue.external_author])
+ contacts = [issue.external_author]
+ contacts.concat extra_params[:cc] unless extra_params[:cc].nil?
+
+ set_crm_contacts(issue, contacts)
end
end
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index d210ba2a76c..edab62b1fdf 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -76,6 +76,7 @@ module Issues
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+ new_params = new_params.merge(rewritten_old_entity_attributes)
# spam checking is not necessary, as no new content is being created. Passing nil for
# spam_params will cause SpamActionService to skip checking and return a success response.
spam_params = nil
diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb
index 8b08c1f8ddb..2ecd3e561c9 100644
--- a/app/services/issues/related_branches_service.rb
+++ b/app/services/issues/related_branches_service.rb
@@ -5,26 +5,23 @@
module Issues
class RelatedBranchesService < Issues::BaseService
def execute(issue)
- branch_names = branches_with_iid_of(issue) - branches_with_merge_request_for(issue)
- branch_names.map { |branch_name| branch_data(branch_name) }
+ branch_names_with_mrs = branches_with_merge_request_for(issue)
+ branches = branches_with_iid_of(issue).reject { |b| branch_names_with_mrs.include?(b[:name]) }
+
+ branches.map { |branch| branch_data(branch) }
end
private
- def branch_data(branch_name)
+ def branch_data(branch)
{
- name: branch_name,
- pipeline_status: pipeline_status(branch_name)
+ name: branch[:name],
+ pipeline_status: pipeline_status(branch)
}
end
- def pipeline_status(branch_name)
- branch = project.repository.find_branch(branch_name)
- target = branch&.dereferenced_target
-
- return unless target
-
- pipeline = project.latest_pipeline(branch_name, target.sha)
+ def pipeline_status(branch)
+ pipeline = project.latest_pipeline(branch[:name], branch[:target])
pipeline.detailed_status(current_user) if can?(current_user, :read_pipeline, pipeline)
end
@@ -36,8 +33,16 @@ module Issues
end
def branches_with_iid_of(issue)
- project.repository.branch_names.select do |branch|
- branch =~ /\A#{issue.iid}-(?!\d+-stable)/i
+ branch_ref_regex = /\A#{Gitlab::Git::BRANCH_REF_PREFIX}#{issue.iid}-(?!\d+-stable)/i
+
+ return [] unless project.repository.exists?
+
+ project.repository.list_refs(
+ [Gitlab::Git::BRANCH_REF_PREFIX + "#{issue.iid}-*"]
+ ).each_with_object([]) do |ref, results|
+ if ref.name.match?(branch_ref_regex)
+ results << { name: ref.name.delete_prefix(Gitlab::Git::BRANCH_REF_PREFIX), target: ref.target }
+ end
end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 4abd1dfbf4e..e003ecacb3f 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -32,11 +32,18 @@ module Issues
end
def perform_incident_management_actions(issue)
+ return unless issue.incident?
+
+ create_timeline_event(issue)
end
def create_note(issue, state = issue.state)
SystemNoteService.change_status(issue, issue.project, current_user, state, nil)
end
+
+ def create_timeline_event(issue)
+ IncidentManagement::TimelineEvents::CreateService.reopen_incident(issue, current_user)
+ end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index d9210169005..afc61eed287 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -199,8 +199,7 @@ module Issues
::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(
issue,
- current_user,
- status_change_reason: @escalation_status_change_reason # Defined in IssuableBaseService before save
+ current_user
).execute
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 57d9da4cefd..38bebc1d09d 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -84,7 +84,7 @@ module Members
end
def add_members
- @members = source.add_users(
+ @members = source.add_members(
invites,
params[:access_level],
expires_at: params[:expires_at],
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index 276093a00a9..f59a3ed77eb 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -13,9 +13,9 @@ module Members
Gitlab::Access.sym_options_with_owner
end
- def add_users( # rubocop:disable Metrics/ParameterLists
+ def add_members( # rubocop:disable Metrics/ParameterLists
source,
- users,
+ invitees,
access_level,
current_user: nil,
expires_at: nil,
@@ -24,41 +24,49 @@ module Members
ldap: nil,
blocking_refresh: nil
)
- return [] unless users.present?
+ return [] unless invitees.present?
# If this user is attempting to manage Owner members and doesn't have permission, do not allow
return [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user)
- emails, users, existing_members = parse_users_list(source, users)
+ emails, users, existing_members = parse_users_list(source, invitees)
Member.transaction do
- (emails + users).map! do |user|
- new(source,
- user,
- access_level,
- existing_members: existing_members,
- current_user: current_user,
- expires_at: expires_at,
- tasks_to_be_done: tasks_to_be_done,
- tasks_project_id: tasks_project_id,
- ldap: ldap,
- blocking_refresh: blocking_refresh)
- .execute
+ common_arguments = {
+ source: source,
+ access_level: access_level,
+ existing_members: existing_members,
+ current_user: current_user,
+ expires_at: expires_at,
+ tasks_to_be_done: tasks_to_be_done,
+ tasks_project_id: tasks_project_id,
+ ldap: ldap,
+ blocking_refresh: blocking_refresh
+ }
+
+ members = emails.map do |email|
+ new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute
end
+
+ members += users.map do |user|
+ new(invitee: user, **common_arguments).execute
+ end
+
+ members
end
end
- def add_user( # rubocop:disable Metrics/ParameterLists
+ def add_member( # rubocop:disable Metrics/ParameterLists
source,
- user,
+ invitee,
access_level,
current_user: nil,
expires_at: nil,
ldap: nil,
blocking_refresh: nil
)
- add_users(source,
- [user],
+ add_members(source,
+ [invitee],
access_level,
current_user: current_user,
expires_at: expires_at,
@@ -113,11 +121,11 @@ module Members
end
end
- def initialize(source, user, access_level, **args)
- @source = source
- @user = user
- @access_level = self.class.parsed_access_level(access_level)
+ def initialize(invitee:, builder: StandardMemberBuilder, **args)
+ @invitee = invitee
+ @builder = builder
@args = args
+ @access_level = self.class.parsed_access_level(args[:access_level])
end
private_class_method :new
@@ -133,7 +141,7 @@ module Members
private
delegate :new_record?, to: :member
- attr_reader :source, :user, :access_level, :member, :args
+ attr_reader :invitee, :access_level, :member, :args, :builder
def assign_member_attributes
member.attributes = member_attributes
@@ -170,7 +178,7 @@ module Members
# Populates the attributes of a member.
#
# This logic resides in a separate method so that EE can extend this logic,
- # without having to patch the `add_user` method directly.
+ # without having to patch the `add_members` method directly.
def member_attributes
{
created_by: member.created_by || current_user,
@@ -182,14 +190,14 @@ module Members
def commit_changes
if member.request?
approve_request
- else
+ elsif member.changed?
# Calling #save triggers callbacks even if there is no change on object.
# This previously caused an incident due to the hard to predict
# behaviour caused by the large number of callbacks.
# See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/6351
# and https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80920#note_911569038
# for details.
- member.save if member.changed?
+ member.save
end
end
@@ -241,43 +249,19 @@ module Members
end
def find_or_build_member
- @user = parse_user_param
-
- @member = if user.is_a?(User)
- find_or_initialize_member_by_user
- else
- source.members.build(invite_email: user)
- end
+ @member = builder.new(source, invitee, existing_members).execute
@member.blocking_refresh = args[:blocking_refresh]
end
- # This method is used to find users that have been entered into the "Add members" field.
- # These can be the User objects directly, their IDs, their emails, or new emails to be invited.
- def parse_user_param
- case user
- when User
- user
- when Integer
- # might not return anything - this needs enhancement
- User.find_by(id: user) # rubocop:todo CodeReuse/ActiveRecord
- else
- # must be an email or at least we'll consider it one
- source.users_by_emails([user])[user] || user
- end
- end
-
- def find_or_initialize_member_by_user
- # We have to use `members_and_requesters` here since the given `members` is modified in the models
- # to act more like a scope(removing the requested_at members) and therefore ActiveRecord has issues with that
- # on build and refreshing that relation.
- existing_members[user.id] || source.members_and_requesters.build(user_id: user.id) # rubocop:disable CodeReuse/ActiveRecord
- end
-
def ldap
args[:ldap] || false
end
+ def source
+ args[:source]
+ end
+
def existing_members
args[:existing_members] || {}
end
diff --git a/app/services/members/invite_member_builder.rb b/app/services/members/invite_member_builder.rb
new file mode 100644
index 00000000000..e925121bb1e
--- /dev/null
+++ b/app/services/members/invite_member_builder.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Members
+ class InviteMemberBuilder < StandardMemberBuilder
+ def execute
+ if user_by_email
+ find_or_initialize_member_by_user(user_by_email.id)
+ else
+ source.members_and_requesters.find_or_initialize_by(invite_email: invitee) # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+
+ private
+
+ def user_by_email
+ source.users_by_emails([invitee])[invitee]
+ end
+ end
+end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 1bf209ab79d..6d23a9bc2dc 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -31,8 +31,8 @@ module Members
return if params[:email].blank?
- # we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails
- # ideally we wouldn't need this, but we can't really change the add_users method
+ # we need the below due to add_member hitting Members::CreatorService.parse_users_list and ignoring invalid emails
+ # ideally we wouldn't need this, but we can't really change the add_members method
invalid_emails.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
end
diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb
index cde1d0462e8..f45132749f9 100644
--- a/app/services/members/projects/creator_service.rb
+++ b/app/services/members/projects/creator_service.rb
@@ -32,7 +32,7 @@ module Members
end
def adding_the_creator_as_owner_in_a_personal_project?
- # this condition is reached during testing setup a lot due to use of `.add_user`
+ # this condition is reached during testing setup a lot due to use of `.add_member`
member.project.personal_namespace_holder?(member.user)
end
diff --git a/app/services/members/standard_member_builder.rb b/app/services/members/standard_member_builder.rb
new file mode 100644
index 00000000000..24e71f80d7e
--- /dev/null
+++ b/app/services/members/standard_member_builder.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Members
+ class StandardMemberBuilder
+ def initialize(source, invitee, existing_members)
+ @source = source
+ @invitee = invitee
+ @existing_members = existing_members
+ end
+
+ def execute
+ find_or_initialize_member_by_user(invitee.id)
+ end
+
+ private
+
+ attr_reader :source, :invitee, :existing_members
+
+ def find_or_initialize_member_by_user(user_id)
+ existing_members[user_id] || source.members_and_requesters.build(user_id: user_id) # rubocop:disable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb
index e3f0758699b..b8d817a15f3 100644
--- a/app/services/merge_requests/approval_service.rb
+++ b/app/services/merge_requests/approval_service.rb
@@ -16,7 +16,7 @@ module MergeRequests
mark_pending_todos_as_done(merge_request)
execute_approval_hooks(merge_request, current_user)
remove_attention_requested(merge_request)
- merge_request_activity_counter.track_approve_mr_action(user: current_user)
+ merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request)
success
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 2b6a66b9dee..9bd38478796 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -128,13 +128,8 @@ module MergeRequests
if draft_event = params.delete(:wip_event)
# We update the title that is provided in the params or we use the mr title
title = params[:title] || merge_request.title
- # Supports both `wip` and `draft` permutations of draft_event
- # This support can be removed >= %15.2
- #
params[:title] = case draft_event
- when 'wip' then MergeRequest.draft_title(title)
when 'draft' then MergeRequest.draft_title(title)
- when 'unwip' then MergeRequest.draftless_title(title)
when 'ready' then MergeRequest.draftless_title(title)
end
end
@@ -190,8 +185,11 @@ module MergeRequests
def create_pipeline_for(merge_request, user, async: false)
if async
- # TODO: pass push_options to worker
- MergeRequests::CreatePipelineWorker.perform_async(project.id, user.id, merge_request.id)
+ MergeRequests::CreatePipelineWorker.perform_async(
+ project.id,
+ user.id,
+ merge_request.id,
+ params.slice(:push_options).deep_stringify_keys)
else
MergeRequests::CreatePipelineService
.new(project: project, current_user: user, params: params.slice(:push_options))
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index 37c734613e7..c6a91a3b61e 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -50,12 +50,8 @@ module MergeRequests
end
def can_create_pipeline_in_target_project?(merge_request)
- if ::Feature.enabled?(:ci_disallow_to_create_merge_request_pipelines_in_target_project, merge_request.target_project)
- merge_request.for_same_project?
- else
- can?(current_user, :create_pipeline, merge_request.target_project) &&
- can_update_source_branch_in_target_project?(merge_request)
- end
+ can?(current_user, :create_pipeline, merge_request.target_project) &&
+ can_update_source_branch_in_target_project?(merge_request)
end
def can_update_source_branch_in_target_project?(merge_request)
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index 414f253deb8..c139b2e11dd 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -37,11 +37,6 @@ module Namespaces
interval_days: [1, 5, 10],
completed_actions: [:git_write, :pipeline_created, :trial_started],
incomplete_actions: [:user_added]
- },
- experience: {
- interval_days: [30],
- completed_actions: [:created, :git_write],
- incomplete_actions: []
}
}.freeze
diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb
index 4f1bb0dc877..0a7f25f1af3 100644
--- a/app/services/notification_recipients/builder/base.rb
+++ b/app/services/notification_recipients/builder/base.rb
@@ -47,6 +47,8 @@ module NotificationRecipients
end
users = Array(users).compact
+ preload_users_namespace_bans(users)
+
recipients.concat(users.map { |u| make_recipient(u, type, reason) })
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -240,6 +242,14 @@ module NotificationRecipients
add_recipients(label.subscribers(project), :subscription, NotificationReason::SUBSCRIBED)
end
end
+
+ private
+
+ def preload_users_namespace_bans(_users)
+ # overridden in EE
+ end
end
end
end
+
+NotificationRecipients::Builder::Base.prepend_mod_with('NotificationRecipients::Builder::Base')
diff --git a/app/services/packages/cleanup/execute_policy_service.rb b/app/services/packages/cleanup/execute_policy_service.rb
new file mode 100644
index 00000000000..b432f6d0acb
--- /dev/null
+++ b/app/services/packages/cleanup/execute_policy_service.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+module Packages
+ module Cleanup
+ class ExecutePolicyService
+ include Gitlab::Utils::StrongMemoize
+
+ MAX_EXECUTION_TIME = 250.seconds
+
+ DUPLICATED_FILES_BATCH_SIZE = 10_000
+ MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE = 200
+
+ def initialize(policy)
+ @policy = policy
+ @counts = {
+ marked_package_files_total_count: 0,
+ unique_package_id_and_file_name_total_count: 0
+ }
+ end
+
+ def execute
+ cleanup_duplicated_files
+ end
+
+ private
+
+ def cleanup_duplicated_files
+ return if @policy.keep_n_duplicated_package_files_disabled?
+
+ result = installable_package_files.each_batch(of: DUPLICATED_FILES_BATCH_SIZE) do |package_files|
+ break :timeout if cleanup_duplicated_files_on(package_files) == :timeout
+ end
+
+ response_success(timeout: result == :timeout)
+ end
+
+ def cleanup_duplicated_files_on(package_files)
+ unique_package_id_and_file_name_from(package_files).each do |package_id, file_name|
+ result = remove_duplicated_files_for(package_id: package_id, file_name: file_name)
+ @counts[:marked_package_files_total_count] += result.payload[:marked_package_files_count]
+ @counts[:unique_package_id_and_file_name_total_count] += 1
+
+ break :timeout unless result.success?
+ end
+ end
+
+ def unique_package_id_and_file_name_from(package_files)
+ # This is a highly custom query for this service, that's why it's not in the model.
+ # rubocop: disable CodeReuse/ActiveRecord
+ package_files.group(:package_id, :file_name)
+ .having("COUNT(*) > #{@policy.keep_n_duplicated_package_files}")
+ .pluck(:package_id, :file_name)
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+
+ def remove_duplicated_files_for(package_id:, file_name:)
+ base = ::Packages::PackageFile.for_package_ids(package_id)
+ .installable
+ .with_file_name(file_name)
+ ids_to_keep = base.recent
+ .limit(@policy.keep_n_duplicated_package_files)
+ .pluck_primary_key
+
+ duplicated_package_files = base.id_not_in(ids_to_keep)
+ ::Packages::MarkPackageFilesForDestructionService.new(duplicated_package_files)
+ .execute(batch_deadline: batch_deadline, batch_size: MARK_PACKAGE_FILES_FOR_DESTRUCTION_SERVICE_BATCH_SIZE)
+ end
+
+ def project
+ @policy.project
+ end
+
+ def installable_package_files
+ ::Packages::PackageFile.installable
+ .for_package_ids(
+ ::Packages::Package.installable
+ .for_projects(project.id)
+ )
+ end
+
+ def batch_deadline
+ strong_memoize(:batch_deadline) do
+ MAX_EXECUTION_TIME.from_now
+ end
+ end
+
+ def response_success(timeout:)
+ ServiceResponse.success(
+ message: "Packages cleanup policy executed for project #{project.id}",
+ payload: {
+ timeout: timeout,
+ counts: @counts
+ }
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb
index 2022a63a725..fbbc8159ca0 100644
--- a/app/services/packages/debian/create_package_file_service.rb
+++ b/app/services/packages/debian/create_package_file_service.rb
@@ -3,12 +3,15 @@
module Packages
module Debian
class CreatePackageFileService
+ include ::Packages::FIPS
+
def initialize(package, params)
@package = package
@params = params
end
def execute
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
raise ArgumentError, "Invalid package" unless package.present?
# Debian package file are first uploaded to incoming with empty metadata,
diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb
index 43a4db5bdfc..30480834748 100644
--- a/app/services/packages/debian/extract_changes_metadata_service.rb
+++ b/app/services/packages/debian/extract_changes_metadata_service.rb
@@ -4,6 +4,7 @@ module Packages
module Debian
class ExtractChangesMetadataService
include Gitlab::Utils::StrongMemoize
+ include ::Packages::FIPS
ExtractionError = Class.new(StandardError)
@@ -13,6 +14,8 @@ module Packages
end
def execute
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
+
{
file_type: file_type,
architecture: metadata[:architecture],
diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb
index 33bf877a153..7db27f9234d 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -4,6 +4,7 @@ module Packages
module Debian
class GenerateDistributionService
include Gitlab::Utils::StrongMemoize
+ include ::Packages::FIPS
include ExclusiveLeaseGuard
ONE_HOUR = 1.hour.freeze
@@ -70,6 +71,8 @@ module Packages
end
def execute
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
+
try_obtain_lease do
@distribution.transaction do
# We consider `apt-get update` can take at most one hour
diff --git a/app/services/packages/mark_package_files_for_destruction_service.rb b/app/services/packages/mark_package_files_for_destruction_service.rb
index 3672b44b409..e7fdd88843a 100644
--- a/app/services/packages/mark_package_files_for_destruction_service.rb
+++ b/app/services/packages/mark_package_files_for_destruction_service.rb
@@ -9,18 +9,41 @@ module Packages
@package_files = package_files
end
- def execute
- @package_files.each_batch(of: BATCH_SIZE) do |batched_package_files|
- batched_package_files.update_all(status: :pending_destruction)
+ def execute(batch_deadline: nil, batch_size: BATCH_SIZE)
+ timeout = false
+ updates_count = 0
+ min_batch_size = [batch_size, BATCH_SIZE].min
+
+ @package_files.each_batch(of: min_batch_size) do |batched_package_files|
+ if batch_deadline && Time.zone.now > batch_deadline
+ timeout = true
+ break
+ end
+
+ updates_count += batched_package_files.update_all(status: :pending_destruction)
end
- service_response_success('Package files are now pending destruction')
+ payload = { marked_package_files_count: updates_count }
+
+ return response_error(payload) if timeout
+
+ response_success(payload)
end
private
- def service_response_success(message)
- ServiceResponse.success(message: message)
+ def response_success(payload)
+ ServiceResponse.success(
+ message: 'Package files are now pending destruction',
+ payload: payload
+ )
+ end
+
+ def response_error(payload)
+ ServiceResponse.error(
+ message: 'Timeout while marking package files as pending destruction',
+ payload: payload
+ )
end
end
end
diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb
index 5d7e967ceb0..b464ce4504a 100644
--- a/app/services/packages/pypi/create_package_service.rb
+++ b/app/services/packages/pypi/create_package_service.rb
@@ -16,6 +16,8 @@ module Packages
raise ActiveRecord::RecordInvalid, meta
end
+ params.delete(:md5_digest) if Gitlab::FIPS.enabled?
+
Packages::Pypi::Metadatum.upsert(meta.attributes)
::Packages::CreatePackageFileService.new(created_package, file_params).execute
diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb
index 95e99daeb6c..dcee4c5b665 100644
--- a/app/services/pages/delete_service.rb
+++ b/app/services/pages/delete_service.rb
@@ -21,7 +21,8 @@ module Pages
def publish_deleted_event
event = Pages::PageDeletedEvent.new(data: {
project_id: project.id,
- namespace_id: project.namespace_id
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id
})
Gitlab::EventStore.publish(event)
diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb
deleted file mode 100644
index e4b6ad31e33..00000000000
--- a/app/services/pod_logs/base_service.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module PodLogs
- class BaseService < ::BaseService
- include ReactiveCaching
- include Stepable
-
- attr_reader :cluster, :namespace, :params
-
- CACHE_KEY_GET_POD_LOG = 'get_pod_log'
- K8S_NAME_MAX_LENGTH = 253
-
- self.reactive_cache_work_type = :external_dependency
-
- def id
- cluster.id
- end
-
- def initialize(cluster, namespace, params: {})
- @cluster = cluster
- @namespace = namespace
- @params = filter_params(params.dup.stringify_keys).to_hash
- end
-
- def execute
- with_reactive_cache(
- CACHE_KEY_GET_POD_LOG,
- namespace,
- params
- ) do |result|
- result
- end
- end
-
- def calculate_reactive_cache(request, _namespace, _params)
- case request
- when CACHE_KEY_GET_POD_LOG
- execute_steps
- else
- exception = StandardError.new('Unknown reactive cache request')
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, request: request)
- error(_('Unknown cache key'))
- end
- end
-
- private
-
- def valid_params
- %w(pod_name container_name)
- end
-
- def success_return_keys
- %i(status logs pod_name container_name pods)
- end
-
- def check_arguments(result)
- return error(_('Cluster does not exist')) if cluster.nil?
- return error(_('Namespace is empty')) if namespace.blank?
-
- result[:pod_name] = params['pod_name'].presence
- result[:container_name] = params['container_name'].presence
-
- return error(_('Invalid pod_name')) if result[:pod_name] && !result[:pod_name].is_a?(String)
- return error(_('Invalid container_name')) if result[:container_name] && !result[:container_name].is_a?(String)
-
- success(result)
- end
-
- def get_raw_pods(result)
- raise NotImplementedError
- end
-
- def get_pod_names(result)
- result[:pods] = result[:raw_pods].map { |p| p[:name] }
-
- success(result)
- end
-
- def pod_logs(result)
- raise NotImplementedError
- end
-
- def filter_return_keys(result)
- result.slice(*success_return_keys)
- end
-
- def filter_params(params)
- params.slice(*valid_params)
- end
- end
-end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
deleted file mode 100644
index 28ccace62e5..00000000000
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# frozen_string_literal: true
-
-module PodLogs
- class ElasticsearchService < PodLogs::BaseService
- steps :check_arguments,
- :get_raw_pods,
- :get_pod_names,
- :check_times,
- :check_search,
- :check_cursor,
- :pod_logs,
- :filter_return_keys
-
- self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
-
- private
-
- def valid_params
- super + %w(search start_time end_time cursor)
- end
-
- def success_return_keys
- super + %i(cursor)
- end
-
- def get_raw_pods(result)
- client = cluster&.elasticsearch_client
- return error(_('Unable to connect to Elasticsearch')) unless client
-
- result[:raw_pods] = ::Gitlab::Elasticsearch::Logs::Pods.new(client).pods(namespace)
-
- success(result)
- rescue Elasticsearch::Transport::Transport::ServerError => e
- ::Gitlab::ErrorTracking.track_exception(e)
-
- error(_('Elasticsearch returned status code: %{status_code}') % {
- # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound"
- # there is no method on the exception other than the class name to determine the type of error encountered.
- status_code: e.class.name.split('::').last
- })
- end
-
- def check_times(result)
- result[:start_time] = params['start_time'] if params.key?('start_time') && Time.iso8601(params['start_time'])
- result[:end_time] = params['end_time'] if params.key?('end_time') && Time.iso8601(params['end_time'])
-
- success(result)
- rescue ArgumentError
- error(_('Invalid start or end time format'))
- end
-
- def check_search(result)
- result[:search] = params['search'] if params.key?('search')
-
- return error(_('Invalid search parameter')) if result[:search] && !result[:search].is_a?(String)
-
- success(result)
- end
-
- def check_cursor(result)
- result[:cursor] = params['cursor'] if params.key?('cursor')
-
- return error(_('Invalid cursor parameter')) if result[:cursor] && !result[:cursor].is_a?(String)
-
- success(result)
- end
-
- def pod_logs(result)
- client = cluster&.elasticsearch_client
- return error(_('Unable to connect to Elasticsearch')) unless client
-
- response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs(
- namespace,
- pod_name: result[:pod_name],
- container_name: result[:container_name],
- search: result[:search],
- start_time: result[:start_time],
- end_time: result[:end_time],
- cursor: result[:cursor],
- chart_above_v2: cluster.elastic_stack_adapter.chart_above_v2?
- )
-
- result.merge!(response)
-
- success(result)
- rescue Elasticsearch::Transport::Transport::ServerError => e
- ::Gitlab::ErrorTracking.track_exception(e)
-
- error(_('Elasticsearch returned status code: %{status_code}') % {
- # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound"
- # there is no method on the exception other than the class name to determine the type of error encountered.
- status_code: e.class.name.split('::').last
- })
- rescue ::Gitlab::Elasticsearch::Logs::Lines::InvalidCursor
- error(_('Invalid cursor value provided'))
- end
- end
-end
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
deleted file mode 100644
index 28b1a179635..00000000000
--- a/app/services/pod_logs/kubernetes_service.rb
+++ /dev/null
@@ -1,151 +0,0 @@
-# frozen_string_literal: true
-
-module PodLogs
- class KubernetesService < PodLogs::BaseService
- LOGS_LIMIT = 500
- REPLACEMENT_CHAR = "\u{FFFD}"
-
- EncodingHelperError = Class.new(StandardError)
-
- steps :check_arguments,
- :get_raw_pods,
- :get_pod_names,
- :check_pod_name,
- :check_container_name,
- :pod_logs,
- :encode_logs_to_utf8,
- :split_logs,
- :filter_return_keys
-
- self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
-
- private
-
- def get_raw_pods(result)
- result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace).map do |pod|
- {
- name: pod.metadata.name,
- container_names: pod.spec.containers.map(&:name)
- }
- end
-
- success(result)
- end
-
- def check_pod_name(result)
- # If pod_name is not received as parameter, get the pod logs of the first
- # pod of this namespace.
- result[:pod_name] ||= result[:pods].first
-
- unless result[:pod_name]
- return error(_('No pods available'))
- end
-
- unless result[:pod_name].length.to_i <= K8S_NAME_MAX_LENGTH
- return error(_('pod_name cannot be larger than %{max_length}'\
- ' chars' % { max_length: K8S_NAME_MAX_LENGTH }))
- end
-
- unless result[:pod_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
- return error(_('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
- end
-
- unless result[:pods].include?(result[:pod_name])
- return error(_('Pod does not exist'))
- end
-
- success(result)
- end
-
- def check_container_name(result)
- pod_details = result[:raw_pods].find { |p| p[:name] == result[:pod_name] }
- container_names = pod_details[:container_names]
-
- # select first container if not specified
- result[:container_name] ||= container_names.first
-
- unless result[:container_name]
- return error(_('No containers available'))
- end
-
- unless result[:container_name].length.to_i <= K8S_NAME_MAX_LENGTH
- return error(_('container_name cannot be larger than'\
- ' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH }))
- end
-
- unless result[:container_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
- return error(_('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
- end
-
- unless container_names.include?(result[:container_name])
- return error(_('Container does not exist'))
- end
-
- success(result)
- end
-
- def pod_logs(result)
- result[:logs] = cluster.kubeclient.get_pod_log(
- result[:pod_name],
- namespace,
- container: result[:container_name],
- tail_lines: LOGS_LIMIT,
- timestamps: true
- ).body
-
- success(result)
- rescue Kubeclient::ResourceNotFoundError
- error(_('Pod not found'))
- rescue Kubeclient::HttpError => e
- ::Gitlab::ErrorTracking.track_exception(e)
-
- error(_('Kubernetes API returned status code: %{error_code}') % {
- error_code: e.error_code
- })
- end
-
- # Check https://gitlab.com/gitlab-org/gitlab/issues/34965#note_292261879
- # for more details on why this is necessary.
- def encode_logs_to_utf8(result)
- return success(result) if result[:logs].nil?
- return success(result) if result[:logs].encoding == Encoding::UTF_8
-
- result[:logs] = encode_utf8(result[:logs])
-
- success(result)
- rescue EncodingHelperError
- error(_('Unable to convert Kubernetes logs encoding to UTF-8'))
- end
-
- def split_logs(result)
- result[:logs] = result[:logs].strip.lines(chomp: true).map do |line|
- # message contains a RFC3339Nano timestamp, then a space, then the log line.
- # resolution of the nanoseconds can vary, so we split on the first space
- values = line.split(' ', 2)
- {
- timestamp: values[0],
- message: values[1],
- pod: result[:pod_name]
- }
- end
-
- success(result)
- end
-
- def encode_utf8(logs)
- utf8_logs = Gitlab::EncodingHelper.encode_utf8(logs.dup, replace: REPLACEMENT_CHAR)
-
- # Gitlab::EncodingHelper.encode_utf8 can return '' or nil if an exception
- # is raised while encoding. We prefer to return an error rather than wrongly
- # display blank logs.
- no_utf8_logs = logs.present? && utf8_logs.blank?
- unexpected_encoding = utf8_logs&.encoding != Encoding::UTF_8
-
- if no_utf8_logs || unexpected_encoding
- raise EncodingHelperError, 'Could not convert Kubernetes logs to UTF-8'
- end
-
- utf8_logs
- end
- end
-end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index af9c338b59e..03844c2dc7e 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -10,7 +10,7 @@ class PreviewMarkdownService < BaseService
text: text,
users: users,
suggestions: suggestions,
- commands: commands.join(' ')
+ commands: commands.join('<br>')
)
end
diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb
index 2ed4346e5ca..9dc957b5be2 100644
--- a/app/services/projects/after_rename_service.rb
+++ b/app/services/projects/after_rename_service.rb
@@ -46,6 +46,7 @@ module Projects
update_repository_configuration
rename_transferred_documents
log_completion
+ publish_event
end
def first_ensure_no_registry_tags_are_present
@@ -132,6 +133,18 @@ module Projects
raise RenameFailedError, error
end
+
+ def publish_event
+ event = Projects::ProjectPathChangedEvent.new(data: {
+ project_id: project.id,
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id,
+ old_path: full_path_before,
+ new_path: full_path_after
+ })
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb
index f7c1240a3ba..b324ea27360 100644
--- a/app/services/projects/blame_service.rb
+++ b/app/services/projects/blame_service.rb
@@ -12,6 +12,8 @@ module Projects
@page = extract_page(params)
end
+ attr_reader :page
+
def blame
Gitlab::Blame.new(blob, commit, range: blame_range)
end
@@ -19,15 +21,14 @@ module Projects
def pagination
return unless pagination_enabled?
- Kaminari.paginate_array([], total_count: blob_lines_count)
+ Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page)
+ .tap { |pagination| pagination.max_paginates_per(per_page) }
.page(page)
- .per(per_page)
- .limit(per_page)
end
private
- attr_reader :blob, :commit, :page
+ attr_reader :blob, :commit
def blame_range
return unless pagination_enabled?
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index c7f284bec9b..9bc8bb428fb 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -129,6 +129,8 @@ module Projects
create_readme if @initialize_with_readme
create_sast_commit if @initialize_with_sast
+
+ publish_event
end
def create_project_settings
@@ -294,6 +296,16 @@ module Projects
params[:topic_list] ||= topic_list if topic_list
end
+
+ def publish_event
+ event = Projects::ProjectCreatedEvent.new(data: {
+ project_id: project.id,
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id
+ })
+
+ Gitlab::EventStore.publish(event)
+ end
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index bc5be5bdff3..06a44b07f9f 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -132,7 +132,7 @@ module Projects
destroy_web_hooks!
destroy_project_bots!
destroy_ci_records!
- destroy_mr_diff_commits!
+ destroy_mr_diff_relations!
# Rails attempts to load all related records into memory before
# destroying: https://github.com/rails/rails/issues/22510
@@ -153,23 +153,28 @@ module Projects
# cascading deletes may exceed statement timeouts, causing failures.
# (see https://gitlab.com/gitlab-org/gitlab/-/issues/346166)
#
+ # Removing merge_request_diff_files records may also cause timeouts, so they
+ # can be deleted in batches as well.
+ #
# rubocop: disable CodeReuse/ActiveRecord
- def destroy_mr_diff_commits!
+ def destroy_mr_diff_relations!
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
+ [MergeRequestDiffCommit, MergeRequestDiffFile].each do |model|
+ loop do
+ inner_query = model
+ .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 = model
+ .where("(#{model.table_name}.merge_request_diff_id, #{model.table_name}.relative_order) IN (?)", inner_query) # rubocop:disable GitlabSecurity/SqlInjection
+ .delete_all
+
+ break if deleted_rows == 0
+ end
end
end
end
@@ -212,7 +217,7 @@ module Projects
# produces smaller and faster queries to the database.
def destroy_web_hooks!
project.hooks.find_each do |web_hook|
- result = ::WebHooks::DestroyService.new(current_user).sync_destroy(web_hook)
+ result = ::WebHooks::DestroyService.new(current_user).execute(web_hook)
unless result[:status] == :success
raise_error(s_('DeleteProject|Failed to remove webhooks. Please try again or contact administrator.'))
@@ -263,8 +268,12 @@ module Projects
end
def publish_project_deleted_event_for(project)
- data = { project_id: project.id, namespace_id: project.namespace_id }
- event = Projects::ProjectDeletedEvent.new(data: data)
+ event = Projects::ProjectDeletedEvent.new(data: {
+ project_id: project.id,
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id
+ })
+
Gitlab::EventStore.publish(event)
end
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 3e8d6563709..70a04cd556a 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -5,7 +5,10 @@ module Projects
def execute(fork_to_project = nil)
forked_project = fork_to_project ? link_existing_project(fork_to_project) : fork_new_project
- refresh_forks_count if forked_project&.saved?
+ if forked_project&.saved?
+ refresh_forks_count
+ stream_audit_event(forked_project)
+ end
forked_project
end
@@ -62,7 +65,10 @@ module Projects
# exception.
relations_block: -> (project) { build_fork_network_member(project) },
skip_disk_validation: skip_disk_validation,
- external_authorization_classification_label: @project.external_authorization_classification_label
+ external_authorization_classification_label: @project.external_authorization_classification_label,
+ suggestion_commit_message: @project.suggestion_commit_message,
+ merge_commit_template: @project.merge_commit_template,
+ squash_commit_template: @project.squash_commit_template
}
if @project.avatar.present? && @project.avatar.image?
@@ -133,5 +139,11 @@ module Projects
def target_mr_default_target_self
@target_mr_default_target_self ||= params[:mr_default_target_self]
end
+
+ def stream_audit_event(forked_project)
+ # Defined in EE
+ end
end
end
+
+Projects::ForkService.prepend_mod
diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb
index a836b96cac3..c271b0a2307 100644
--- a/app/services/projects/group_links/update_service.rb
+++ b/app/services/projects/group_links/update_service.rb
@@ -37,3 +37,5 @@ module Projects
end
end
end
+
+Projects::GroupLinks::UpdateService.prepend_mod
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
index 98ba5eb3f13..a45b78db383 100644
--- a/app/services/projects/move_deploy_keys_projects_service.rb
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -5,6 +5,10 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
+ # The SHA256 fingerprint should be there, but just in case it isn't
+ # we want to make sure it's generated. Otherwise we might delete keys.
+ ensure_sha256_fingerprints
+
Project.transaction do
move_deploy_keys_projects
remove_remaining_deploy_keys_projects if remove_remaining_elements
@@ -15,6 +19,11 @@ module Projects
private
+ def ensure_sha256_fingerprints
+ @project.deploy_keys.each(&:ensure_sha256_fingerprint!)
+ source_project.deploy_keys.each(&:ensure_sha256_fingerprint!)
+ end
+
def move_deploy_keys_projects
non_existent_deploy_keys_projects.update_all(project_id: @project.id)
end
@@ -23,7 +32,7 @@ module Projects
def non_existent_deploy_keys_projects
source_project.deploy_keys_projects
.joins(:deploy_key)
- .where.not(keys: { fingerprint: @project.deploy_keys.select(:fingerprint) })
+ .where.not(keys: { fingerprint_sha256: @project.deploy_keys.select(:fingerprint_sha256) })
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 7e4e0d7378e..b2166dc84c7 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -18,7 +18,6 @@ module Projects
.merge(grafana_integration_params)
.merge(prometheus_integration_params)
.merge(incident_management_setting_params)
- .merge(tracing_setting_params)
end
def alerting_setting_params
@@ -132,15 +131,6 @@ module Projects
{ incident_management_setting_attributes: attrs }
end
-
- def tracing_setting_params
- attr = params[:tracing_setting_attributes]
- return {} unless attr
-
- destroy = attr[:external_url].blank?
-
- { tracing_setting_attributes: attr.merge(_destroy: destroy) }
- end
end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 8ded2516b97..dd1c2b94e18 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -2,21 +2,17 @@
module Projects
class UpdatePagesService < BaseService
- InvalidStateError = Class.new(StandardError)
- WrongUploadedDeploymentSizeError = Class.new(StandardError)
- BLOCK_SIZE = 32.kilobytes
- PUBLIC_DIR = 'public'
-
# old deployment can be cached by pages daemon
# so we need to give pages daemon some time update cache
# 10 minutes is enough, but 30 feels safer
OLD_DEPLOYMENTS_DESTRUCTION_DELAY = 30.minutes.freeze
- attr_reader :build
+ attr_reader :build, :deployment_update
def initialize(project, build)
@project = project
@build = build
+ @deployment_update = ::Gitlab::Pages::DeploymentUpdate.new(project, build)
end
def execute
@@ -29,20 +25,20 @@ module Projects
job.run!
end
- validate_state!
- validate_max_size!
- validate_public_folder!
- validate_max_entries!
+ return error(deployment_update.errors.first.full_message) unless deployment_update.valid?
build.artifacts_file.use_file do |artifacts_path|
- create_pages_deployment(artifacts_path, build)
- success
+ deployment = create_pages_deployment(artifacts_path, build)
+
+ break error('The uploaded artifact size does not match the expected value') unless deployment
+
+ if deployment_update.valid?
+ update_project_pages_deployment(deployment)
+ success
+ else
+ error(deployment_update.errors.first.full_message)
+ end
end
- rescue InvalidStateError => e
- error(e.message)
- rescue WrongUploadedDeploymentSizeError => e
- error("Uploading artifacts to pages storage failed")
- raise e
rescue StandardError => e
error(e.message)
raise e
@@ -53,13 +49,14 @@ module Projects
def success
@commit_status.success
@project.mark_pages_as_deployed
+ publish_deployed_event
super
end
def error(message)
register_failure
log_error("Projects::UpdatePagesService: #{message}")
- @commit_status.allow_failure = !latest?
+ @commit_status.allow_failure = !deployment_update.latest?
@commit_status.description = message
@commit_status.drop(:script_failure)
super
@@ -75,24 +72,22 @@ module Projects
def create_pages_deployment(artifacts_path, build)
sha256 = build.job_artifacts_archive.file_sha256
-
- deployment = nil
File.open(artifacts_path) do |file|
- deployment = project.pages_deployments.create!(file: file,
- file_count: entries_count,
- file_sha256: sha256,
- ci_build_id: build.id
- )
-
- if deployment.size != file.size || deployment.file.size != file.size
- raise(WrongUploadedDeploymentSizeError)
- end
+ deployment = project.pages_deployments.create!(
+ file: file,
+ file_count: deployment_update.entries_count,
+ file_sha256: sha256,
+ ci_build_id: build.id
+ )
- validate_outdated_sha!
+ break if deployment.size != file.size || deployment.file.size != file.size
- project.update_pages_deployment!(deployment)
+ deployment
end
+ end
+ def update_project_pages_deployment(deployment)
+ project.update_pages_deployment!(deployment)
DestroyPagesDeploymentsWorker.perform_in(
OLD_DEPLOYMENTS_DESTRUCTION_DELAY,
project.id,
@@ -108,17 +103,6 @@ module Projects
build.artifacts_file.path
end
- def latest_sha
- project.commit(build.ref).try(:sha).to_s
- ensure
- # Close any file descriptors that were opened and free libgit2 buffers
- project.cleanup
- end
-
- def sha
- build.sha
- end
-
def register_attempt
pages_deployments_total_counter.increment
end
@@ -135,75 +119,14 @@ module Projects
@pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
end
- def validate_state!
- raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
- raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
-
- validate_outdated_sha!
- end
-
- def validate_outdated_sha!
- return if latest?
+ def publish_deployed_event
+ event = ::Pages::PageDeployedEvent.new(data: {
+ project_id: project.id,
+ namespace_id: project.namespace_id,
+ root_namespace_id: project.root_namespace.id
+ })
- # use pipeline_id in case the build is retried
- last_deployed_pipeline_id = project.pages_metadatum&.pages_deployment&.ci_build&.pipeline_id
-
- return unless last_deployed_pipeline_id
- return if last_deployed_pipeline_id <= build.pipeline_id
-
- raise InvalidStateError, 'build SHA is outdated for this ref'
- end
-
- def latest?
- # check if sha for the ref is still the most recent one
- # this helps in case when multiple deployments happens
- sha == latest_sha
- end
-
- def validate_max_size!
- if total_size > max_size
- raise InvalidStateError, "artifacts for pages are too large: #{total_size}"
- end
- end
-
- # Calculate page size after extract
- def total_size
- @total_size ||= build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true).total_size
- end
-
- def max_size_from_settings
- Gitlab::CurrentSettings.max_pages_size.megabytes
- end
-
- def max_size
- max_pages_size = max_size_from_settings
-
- return ::Gitlab::Pages::MAX_SIZE if max_pages_size == 0
-
- max_pages_size
- end
-
- def validate_max_entries!
- if pages_file_entries_limit > 0 && entries_count > pages_file_entries_limit
- raise InvalidStateError, "pages site contains #{entries_count} file entries, while limit is set to #{pages_file_entries_limit}"
- end
- end
-
- def validate_public_folder!
- raise InvalidStateError, 'Error: The `public/` folder is missing, or not declared in `.gitlab-ci.yml`.' unless total_size > 0
- end
-
- def entries_count
- # we're using the full archive and pages daemon needs to read it
- # so we want the total count from entries, not only "public/" directory
- # because it better approximates work we need to do before we can serve the site
- @entries_count = build.artifacts_metadata_entry("", recursive: true).entries.count
- end
-
- def pages_file_entries_limit
- project.actual_limits.pages_file_entries
+ Gitlab::EventStore.publish(event)
end
end
end
-
-Projects::UpdatePagesService.prepend_mod_with('Projects::UpdatePagesService')
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index fb810af3e6b..5708421014a 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -10,6 +10,7 @@ module Projects
def execute
build_topics
remove_unallowed_params
+ mirror_operations_access_level_changes
validate!
ensure_wiki_exists if enabling_wiki?
@@ -82,6 +83,21 @@ module Projects
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project)
end
+ # Temporary code to sync permissions changes as operations access setting
+ # is being split into monitor_access_level, deployments_access_level, infrastructure_access_level.
+ # To be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/364240
+ def mirror_operations_access_level_changes
+ return if Feature.enabled?(:split_operations_visibility_permissions, project)
+
+ operations_access_level = params.dig(:project_feature_attributes, :operations_access_level)
+
+ return if operations_access_level.nil?
+
+ [:monitor_access_level, :infrastructure_access_level, :feature_flags_access_level, :environments_access_level].each do |key|
+ params[:project_feature_attributes][key] = operations_access_level
+ end
+ end
+
def after_update
todos_features_changes = %w(
issues_access_level
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index d0d0737fd66..f604a57bcd1 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -10,8 +10,8 @@ module ProtectedBranches
{
name: params[:name],
allow_force_push: allow_force_push?,
- push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels,
- merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels
+ push_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:push, params).access_levels,
+ merge_access_levels_attributes: ::ProtectedRefs::AccessLevelParams.new(:merge, params).access_levels
}
end
diff --git a/app/services/protected_branches/access_level_params.rb b/app/services/protected_refs/access_level_params.rb
index 6f7a289d9b4..59fc17868d1 100644
--- a/app/services/protected_branches/access_level_params.rb
+++ b/app/services/protected_refs/access_level_params.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ProtectedBranches
+module ProtectedRefs
class AccessLevelParams
attr_reader :type, :params
@@ -34,4 +34,4 @@ module ProtectedBranches
end
end
-ProtectedBranches::AccessLevelParams.prepend_mod_with('ProtectedBranches::AccessLevelParams')
+ProtectedRefs::AccessLevelParams.prepend_mod_with('ProtectedRefs::AccessLevelParams')
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 4bcb15b2d9c..1d7c5d2c80a 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -69,28 +69,32 @@ module QuickActions
Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
end
+ # Find users for commands like /assign
+ #
+ # eg. /assign me and @jane and jack
def extract_users(params)
- return [] if params.blank?
-
- # 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.
- #
- # nb: underscores may be passed in escaped to protect them from markdown rendering
- args = params.split(/\s|,/).select(&:present?).uniq - ['and']
- args.map! { _1.gsub(/\\_/, '_') }
- usernames = (args - ['me']).map { _1.delete_prefix('@') }
- found = User.by_username(usernames).to_a.select { can?(:read_user, _1) }
- found_names = found.map(&:username).map(&:downcase).to_set
- missing = args.reject do |arg|
- arg == 'me' || found_names.include?(arg.downcase.delete_prefix('@'))
- end.map { "'#{_1}'" }
-
- failed_parse(format(_("Failed to find users for %{missing}"), missing: missing.to_sentence)) if missing.present?
-
- found + [current_user].select { args.include?('me') }
+ Gitlab::QuickActions::UsersExtractor
+ .new(current_user, project: project, group: group, target: quick_action_target, text: params)
+ .execute
+
+ rescue Gitlab::QuickActions::UsersExtractor::Error => err
+ extract_users_failed(err)
+ end
+
+ def extract_users_failed(err)
+ case err
+ when Gitlab::QuickActions::UsersExtractor::MissingError
+ failed_parse(format(_("Failed to find users for %{missing}"), missing: err.message))
+ when Gitlab::QuickActions::UsersExtractor::TooManyRefsError
+ failed_parse(format(_('Too many references. Quick actions are limited to at most %{max_count} user references'),
+ max_count: err.limit))
+ when Gitlab::QuickActions::UsersExtractor::TooManyFoundError
+ failed_parse(format(_("Too many users found. Quick actions are limited to at most %{max_count} users"),
+ max_count: err.limit))
+ else
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err)
+ failed_parse(_('Something went wrong'))
+ end
end
def find_milestones(project, params = {})
diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb
index 7a78b323453..447d4d979a6 100644
--- a/app/services/repositories/changelog_service.rb
+++ b/app/services/repositories/changelog_service.rb
@@ -41,6 +41,9 @@ module Repositories
# The `trailer` argument is the Git trailer to use for determining what
# commits to include in the changelog.
#
+ # The `config_file` arguments specifies the path to the configuration file as
+ # stored in the project's Git repository.
+ #
# The `file` arguments specifies the name/path of the file to commit the
# changes to. If the file doesn't exist, it's created automatically.
#
@@ -57,6 +60,7 @@ module Repositories
to: branch,
date: DateTime.now,
trailer: DEFAULT_TRAILER,
+ config_file: Gitlab::Changelog::Config::DEFAULT_FILE_PATH,
file: DEFAULT_FILE,
message: "Add changelog for version #{version}"
)
@@ -68,13 +72,14 @@ module Repositories
@date = date
@branch = branch
@trailer = trailer
+ @config_file = config_file
@file = file
@message = message
end
# rubocop: enable Metrics/ParameterLists
def execute(commit_to_changelog: true)
- config = Gitlab::Changelog::Config.from_git(@project, @user)
+ config = Gitlab::Changelog::Config.from_git(@project, @user, @config_file)
from = start_of_commit_range(config)
# For every entry we want to only include the merge request that
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index 316e6367aa7..eed03ba22fe 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -108,7 +108,7 @@ module ResourceAccessTokens
end
def create_membership(resource, user, access_level)
- resource.add_user(user, access_level, expires_at: params[:expires_at])
+ resource.add_member(user, access_level, expires_at: params[:expires_at])
end
def log_event(token)
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 28e487aa24d..cea7fc5769e 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -11,7 +11,7 @@ class SearchService
def initialize(current_user, params = {})
@current_user = current_user
- @params = Gitlab::Search::Params.new(params, detect_abuse: prevent_abusive_searches?)
+ @params = Gitlab::Search::Params.new(params, detect_abuse: true)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -91,12 +91,19 @@ class SearchService
end
end
- private
-
- def prevent_abusive_searches?
- Feature.enabled?(:prevent_abusive_searches, current_user)
+ def level
+ @level ||=
+ if project
+ 'project'
+ elsif group
+ 'group'
+ else
+ 'global'
+ end
end
+ private
+
def page
[1, params[:page].to_i].max
end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index b850592f7ba..89cb14e6fff 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -52,15 +52,22 @@ module ServicePing
ServicePing::DevopsReport.new(response).execute
end
- return unless Feature.enabled?(:measure_service_ping_metric_collection)
-
- submit_payload({ metadata: { metrics: metrics_collection_time(usage_data) } }, path: METADATA_PATH)
+ submit_payload(metadata(usage_data), path: METADATA_PATH)
end
private
attr_reader :payload, :skip_db_write
+ def metadata(service_ping_payload)
+ {
+ metadata: {
+ uuid: service_ping_payload[:uuid],
+ metrics: metrics_collection_time(service_ping_payload)
+ }
+ }
+ end
+
def metrics_collection_time(payload, parents = [])
return [] unless payload.is_a?(Hash)
diff --git a/app/services/system_notes/incidents_service.rb b/app/services/system_notes/incidents_service.rb
index d5da684a2d8..137994baa74 100644
--- a/app/services/system_notes/incidents_service.rb
+++ b/app/services/system_notes/incidents_service.rb
@@ -15,18 +15,14 @@ module SystemNotes
def add_timeline_event(timeline_event)
author = timeline_event.author
- anchor = "timeline_event_#{timeline_event.id}"
- path = url_helpers.project_issues_incident_path(project, noteable, anchor: anchor)
- body = "added an [incident timeline event](#{path})"
+ body = 'added an incident timeline event'
create_note(NoteSummary.new(noteable, project, author, body, action: 'timeline_event'))
end
def edit_timeline_event(timeline_event, author, was_changed:)
- anchor = "timeline_event_#{timeline_event.id}"
- path = url_helpers.project_issues_incident_path(project, noteable, anchor: anchor)
changed_text = CHANGED_TEXT.fetch(was_changed, '')
- body = "edited #{changed_text}[incident timeline event](#{path})"
+ body = "edited #{changed_text}incident timeline event"
create_note(NoteSummary.new(noteable, project, author, body, action: 'timeline_event'))
end
diff --git a/app/services/terraform/states/trigger_destroy_service.rb b/app/services/terraform/states/trigger_destroy_service.rb
index 3669bdcf716..3347d429bb4 100644
--- a/app/services/terraform/states/trigger_destroy_service.rb
+++ b/app/services/terraform/states/trigger_destroy_service.rb
@@ -12,9 +12,11 @@ module Terraform
return unauthorized_response unless can_destroy_state?
return state_locked_response if state.locked?
- state.update!(deleted_at: Time.current)
+ state.run_after_commit do
+ Terraform::States::DestroyWorker.perform_async(id)
+ end
- Terraform::States::DestroyWorker.perform_async(state.id)
+ state.update!(deleted_at: Time.current)
ServiceResponse.success
end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index 20594bec28d..4978f778870 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -34,6 +34,8 @@ module Users
return unless lease.try_obtain
@user.update_attribute(:last_activity_on, today)
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event('unique_active_user', values: @user.id)
end
end
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index f2f94563e56..cd2c7402713 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -48,7 +48,6 @@ class WebHookService
@force = force
@request_options = {
timeout: Gitlab.config.gitlab.webhook_timeout,
- use_read_total_timeout: true,
allow_local_requests: hook.allow_local_requests?
}
end
@@ -70,7 +69,7 @@ class WebHookService
start_time = Gitlab::Metrics::System.monotonic_time
response = if parsed_url.userinfo.blank?
- make_request(hook.url)
+ make_request(parsed_url.to_s)
else
make_request_with_auth
end
@@ -88,17 +87,19 @@ class WebHookService
rescue *Gitlab::HTTP::HTTP_ERRORS,
Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e
execution_duration = Gitlab::Metrics::System.monotonic_time - start_time
+ error_message = e.to_s
+
log_execution(
response: InternalErrorResponse.new,
execution_duration: execution_duration,
- error_message: e.to_s
+ error_message: error_message
)
Gitlab::AppLogger.error("WebHook Error after #{execution_duration.to_i.seconds}s => #{e}")
{
status: :error,
- message: e.to_s
+ message: error_message
}
end
@@ -118,7 +119,11 @@ class WebHookService
private
def parsed_url
- @parsed_url ||= URI.parse(hook.url)
+ @parsed_url ||= URI.parse(hook.interpolated_url)
+ rescue WebHook::InterpolationError => e
+ # Behavior-preserving fallback.
+ Gitlab::ErrorTracking.track_exception(e)
+ @parsed_url = URI.parse(hook.url)
end
def make_request(url, basic_auth = false)
@@ -131,7 +136,7 @@ class WebHookService
end
def make_request_with_auth
- post_url = hook.url.gsub("#{parsed_url.userinfo}@", '')
+ post_url = parsed_url.to_s.gsub("#{parsed_url.userinfo}@", '')
basic_auth = {
username: CGI.unescape(parsed_url.user),
password: CGI.unescape(parsed_url.password.presence || '')
diff --git a/app/services/web_hooks/destroy_service.rb b/app/services/web_hooks/destroy_service.rb
index ecb530f0d2a..54c6c7ea71b 100644
--- a/app/services/web_hooks/destroy_service.rb
+++ b/app/services/web_hooks/destroy_service.rb
@@ -21,8 +21,5 @@ module WebHooks
ServiceResponse.error(message: "Unable to destroy #{web_hook.model_name.human}")
end
end
-
- # Backwards compatibility with WebHooks::DestroyWorker
- alias_method :sync_destroy, :execute
end
end
diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb
index 0ee7c41469f..17dcf615830 100644
--- a/app/services/web_hooks/log_execution_service.rb
+++ b/app/services/web_hooks/log_execution_service.rb
@@ -44,6 +44,7 @@ module WebHooks
end
log_state_change
+ hook.update_last_failure
end
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
raise if raise_lock_error?
diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb
index 534d220a846..6a773a84225 100644
--- a/app/services/work_items/create_and_link_service.rb
+++ b/app/services/work_items/create_and_link_service.rb
@@ -25,7 +25,11 @@ module WorkItems
work_item = create_result[:work_item]
return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank?
- result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute
+ result = WorkItems::ParentLinks::CreateService.new(
+ @link_params[:parent_work_item],
+ @current_user,
+ { target_issuable: work_item }
+ ).execute
if result[:status] == :success
::ServiceResponse.success(payload: payload(work_item))
diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb
index 4203c96e676..ef1d47c560d 100644
--- a/app/services/work_items/create_from_task_service.rb
+++ b/app/services/work_items/create_from_task_service.rb
@@ -17,7 +17,7 @@ module WorkItems
current_user: @current_user,
params: @work_item_params.slice(:title, :work_item_type_id),
spam_params: @spam_params,
- link_params: { target_issuable: @work_item }
+ link_params: { parent_work_item: @work_item }
).execute
if create_and_link_result.error?
@@ -27,6 +27,7 @@ module WorkItems
replacement_result = TaskListReferenceReplacementService.new(
work_item: @work_item,
+ current_user: @current_user,
work_item_reference: create_and_link_result[:work_item].to_reference,
line_number_start: @work_item_params[:line_number_start],
line_number_end: @work_item_params[:line_number_end],
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index 705735fe403..c2ceb701a2f 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -1,19 +1,19 @@
# frozen_string_literal: true
module WorkItems
- class CreateService
+ class CreateService < Issues::CreateService
include ::Services::ReturnServiceResponses
+ include WidgetableService
- def initialize(project:, current_user: nil, params: {}, spam_params:)
- @create_service = ::Issues::CreateService.new(
+ def initialize(project:, current_user: nil, params: {}, spam_params:, widget_params: {})
+ super(
project: project,
current_user: current_user,
params: params,
spam_params: spam_params,
build_service: ::WorkItems::BuildService.new(project: project, current_user: current_user, params: params)
)
- @current_user = current_user
- @project = project
+ @widget_params = widget_params
end
def execute
@@ -21,13 +21,24 @@ module WorkItems
return error(_('Operation not allowed'), :forbidden)
end
- work_item = @create_service.execute
+ work_item = super
if work_item.valid?
success(payload(work_item))
else
error(work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(work_item))
end
+ rescue ::WorkItems::Widgets::BaseService::WidgetError => e
+ error(e.message, :unprocessable_entity)
+ end
+
+ def transaction_create(work_item)
+ super.tap do |save_result|
+ if save_result
+ execute_widgets(work_item: work_item, callback: :after_create_in_transaction,
+ widget_params: @widget_params)
+ end
+ end
end
private
diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb
new file mode 100644
index 00000000000..9940776e367
--- /dev/null
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module ParentLinks
+ class CreateService < IssuableLinks::CreateService
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def relate_issuables(work_item)
+ link = WorkItems::ParentLink.find_or_initialize_by(work_item: work_item)
+ link.work_item_parent = issuable
+
+ if link.changed? && link.save
+ create_notes(work_item)
+ end
+
+ link
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def linkable_issuables(work_items)
+ @linkable_issuables ||= begin
+ return [] unless can?(current_user, :admin_parent_link, issuable)
+
+ work_items.select do |work_item|
+ linkable?(work_item)
+ end
+ end
+ end
+
+ def linkable?(work_item)
+ can?(current_user, :admin_parent_link, work_item) &&
+ !previous_related_issuables.include?(work_item)
+ end
+
+ def previous_related_issuables
+ @related_issues ||= issuable.work_item_children.to_a
+ end
+
+ def extract_references
+ params[:issuable_references]
+ end
+
+ # TODO: Create system notes when work item's parent or children are updated
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213
+ def create_notes(work_item)
+ # no-op
+ end
+
+ def target_issuable_type
+ issuable.issue_type == 'issue' ? 'task' : issuable.issue_type
+ end
+
+ def issuables_not_found_message
+ _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} ID.' %
+ { issuable: target_issuable_type })
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/parent_links/destroy_service.rb b/app/services/work_items/parent_links/destroy_service.rb
new file mode 100644
index 00000000000..55870d44db9
--- /dev/null
+++ b/app/services/work_items/parent_links/destroy_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module ParentLinks
+ class DestroyService < IssuableLinks::DestroyService
+ attr_reader :link, :current_user, :parent, :child
+
+ def initialize(link, user)
+ @link = link
+ @current_user = user
+ @parent = link.work_item_parent
+ @child = link.work_item
+ end
+
+ private
+
+ # TODO: Create system notes when work item's parent or children are removed
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213
+ def create_notes
+ # no-op
+ end
+
+ def not_found_message
+ _('No Work Item Link found')
+ end
+
+ def permission_to_remove_relation?
+ can?(current_user, :admin_parent_link, child) && can?(current_user, :admin_parent_link, parent)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/task_list_reference_removal_service.rb b/app/services/work_items/task_list_reference_removal_service.rb
index e7ec73a96e0..9152580bef0 100644
--- a/app/services/work_items/task_list_reference_removal_service.rb
+++ b/app/services/work_items/task_list_reference_removal_service.rb
@@ -11,6 +11,7 @@ module WorkItems
@line_number_end = line_number_end
@lock_version = lock_version
@current_user = current_user
+ @task_reference = /#{Regexp.escape(@task.to_reference)}(?!\d)\+/
end
def execute
@@ -26,7 +27,9 @@ module WorkItems
line_matches_reference = (@line_number_start..@line_number_end).any? do |line_number|
markdown_line = source_lines[line_number - 1]
- /#{Regexp.escape(@task.to_reference)}(?!\d)/.match?(markdown_line)
+ if @task_reference.match?(markdown_line)
+ markdown_line.sub!(@task_reference, @task.title)
+ end
end
unless line_matches_reference
@@ -35,8 +38,6 @@ module WorkItems
)
end
- remove_task_lines!(source_lines)
-
::WorkItems::UpdateService.new(
project: @work_item.project,
current_user: @current_user,
@@ -51,13 +52,5 @@ module WorkItems
rescue ActiveRecord::StaleObjectError
::ServiceResponse.error(message: STALE_OBJECT_MESSAGE)
end
-
- private
-
- def remove_task_lines!(source_lines)
- source_lines.delete_if.each_with_index do |_line, index|
- index >= @line_number_start - 1 && index < @line_number_end
- end
- end
end
end
diff --git a/app/services/work_items/task_list_reference_replacement_service.rb b/app/services/work_items/task_list_reference_replacement_service.rb
index 1044a4feb88..b098d67561b 100644
--- a/app/services/work_items/task_list_reference_replacement_service.rb
+++ b/app/services/work_items/task_list_reference_replacement_service.rb
@@ -4,8 +4,9 @@ module WorkItems
class TaskListReferenceReplacementService
STALE_OBJECT_MESSAGE = 'Stale work item. Check lock version'
- def initialize(work_item:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:)
+ def initialize(work_item:, current_user:, work_item_reference:, line_number_start:, line_number_end:, title:, lock_version:)
@work_item = work_item
+ @current_user = current_user
@work_item_reference = work_item_reference
@line_number_start = line_number_start
@line_number_end = line_number_end
@@ -32,7 +33,11 @@ module WorkItems
source_lines[@line_number_start - 1] = markdown_task_first_line
remove_additional_lines!(source_lines)
- @work_item.update!(description: source_lines.join("\n"))
+ ::WorkItems::UpdateService.new(
+ project: @work_item.project,
+ current_user: @current_user,
+ params: { description: source_lines.join("\n"), lock_version: @lock_version }
+ ).execute(@work_item)
::ServiceResponse.success
rescue ActiveRecord::StaleObjectError
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index 0b420881b4b..98818fda263 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -2,16 +2,38 @@
module WorkItems
class UpdateService < ::Issues::UpdateService
+ include WidgetableService
+
def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget_params: {})
+ params[:widget_params] = true if widget_params.present?
+
super(project: project, current_user: current_user, params: params, spam_params: nil)
@widget_params = widget_params
end
+ def execute(work_item)
+ updated_work_item = super
+
+ if updated_work_item.valid?
+ success(payload(work_item))
+ else
+ error(updated_work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(updated_work_item))
+ end
+ rescue ::WorkItems::Widgets::BaseService::WidgetError => e
+ error(e.message, :unprocessable_entity)
+ end
+
private
def update(work_item)
- execute_widgets(work_item: work_item, callback: :update)
+ execute_widgets(work_item: work_item, callback: :update, widget_params: @widget_params)
+
+ super
+ end
+
+ def transaction_update(work_item, opts = {})
+ execute_widgets(work_item: work_item, callback: :before_update_in_transaction, widget_params: @widget_params)
super
end
@@ -22,10 +44,8 @@ module WorkItems
GraphqlTriggers.issuable_title_updated(work_item) if work_item.previous_changes.key?(:title)
end
- def execute_widgets(work_item:, callback:)
- work_item.widgets.each do |widget|
- widget.try(callback, params: @widget_params[widget.class.api_symbol])
- end
+ def payload(work_item)
+ { work_item: work_item }
end
end
end
diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb
new file mode 100644
index 00000000000..037733bbed5
--- /dev/null
+++ b/app/services/work_items/widgets/base_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class BaseService < ::BaseService
+ WidgetError = Class.new(StandardError)
+
+ attr_reader :widget, :current_user
+
+ def initialize(widget:, current_user:)
+ @widget = widget
+ @current_user = current_user
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/description_service/update_service.rb b/app/services/work_items/widgets/description_service/update_service.rb
new file mode 100644
index 00000000000..e63b6b2ee6c
--- /dev/null
+++ b/app/services/work_items/widgets/description_service/update_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module DescriptionService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def update(params: {})
+ return unless params.present? && params[:description]
+
+ widget.work_item.description = params[:description]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb
new file mode 100644
index 00000000000..085d6c6b0e7
--- /dev/null
+++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module HierarchyService
+ class BaseService < WorkItems::Widgets::BaseService
+ private
+
+ def handle_hierarchy_changes(params)
+ return feature_flag_error unless feature_flag_enabled?
+ return incompatible_args_error if incompatible_args?(params)
+
+ if params.key?(:parent)
+ update_work_item_parent(params.delete(:parent))
+ elsif params.key?(:children)
+ update_work_item_children(params.delete(:children))
+ else
+ invalid_args_error
+ end
+ end
+
+ def update_work_item_parent(parent)
+ if parent.nil?
+ remove_parent
+ else
+ set_parent(parent)
+ end
+ end
+
+ def set_parent(parent)
+ ::WorkItems::ParentLinks::CreateService
+ .new(parent, current_user, { target_issuable: widget.work_item })
+ .execute
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def remove_parent
+ link = ::WorkItems::ParentLink.find_by(work_item: widget.work_item)
+ return success unless link.present?
+
+ ::WorkItems::ParentLinks::DestroyService.new(link, current_user).execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def update_work_item_children(children)
+ ::WorkItems::ParentLinks::CreateService
+ .new(widget.work_item, current_user, { issuable_references: children })
+ .execute
+ end
+
+ def feature_flag_enabled?
+ Feature.enabled?(:work_items_hierarchy, widget.work_item&.project)
+ end
+
+ def incompatible_args?(params)
+ params[:children] && params[:parent]
+ end
+
+ def feature_flag_error
+ error(_('`work_items_hierarchy` feature flag disabled for this project'))
+ end
+
+ def incompatible_args_error
+ error(_('A Work Item can be a parent or a child, but not both.'))
+ end
+
+ def invalid_args_error
+ error(_("One or more arguments are invalid: %{args}." % { args: params.keys.to_sentence } ))
+ end
+
+ def service_response!(result)
+ return result unless result[:status] == :error
+
+ raise WidgetError, result[:message]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/hierarchy_service/create_service.rb b/app/services/work_items/widgets/hierarchy_service/create_service.rb
new file mode 100644
index 00000000000..c97812fade2
--- /dev/null
+++ b/app/services/work_items/widgets/hierarchy_service/create_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module HierarchyService
+ class CreateService < WorkItems::Widgets::HierarchyService::BaseService
+ def after_create_in_transaction(params:)
+ return unless params.present?
+
+ service_response!(handle_hierarchy_changes(params))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/hierarchy_service/update_service.rb b/app/services/work_items/widgets/hierarchy_service/update_service.rb
new file mode 100644
index 00000000000..48b540f919e
--- /dev/null
+++ b/app/services/work_items/widgets/hierarchy_service/update_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module HierarchyService
+ class UpdateService < WorkItems::Widgets::HierarchyService::BaseService
+ def before_update_in_transaction(params:)
+ return unless params.present?
+
+ service_response!(handle_hierarchy_changes(params))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/weight_service/update_service.rb b/app/services/work_items/widgets/weight_service/update_service.rb
new file mode 100644
index 00000000000..cd62a25358f
--- /dev/null
+++ b/app/services/work_items/widgets/weight_service/update_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module WeightService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def update(params: {})
+ return unless params.present? && params[:weight]
+
+ widget.work_item.weight = params[:weight]
+ end
+ end
+ end
+ end
+end
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 370d3cea07c..68eb33d6552 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('eks_')
%section.settings.as-eks.no-animate#js-eks-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Amazon EKS')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml
new file mode 100644
index 00000000000..2dcd9d0d2c0
--- /dev/null
+++ b/app/views/admin/application_settings/_error_tracking.html.haml
@@ -0,0 +1,40 @@
+- expanded = integration_expanded?('error_tracking_')
+
+%section.settings.as-error-tracking.no-animate#js-error-tracking-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('GitLab Error Tracking')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Allows projects to track errors using an Opstrace integration.').html_safe % { link: help_page_path('operations/error_tracking.md') }
+ = link_to _('Learn more.'), help_page_path('operations/error_tracking.md'), target: '_blank', rel: 'noopener noreferrer'
+
+ .settings-content
+
+ %fieldset
+ .sub-section
+ %h4= _('Access Token')
+ .form-group
+ .form-text
+ %p.text-secondary
+ = s_("ErrorTracking|Access token is %{token_in_code_tag}").html_safe % { token_in_code_tag: content_tag(:code, Gitlab::CurrentSettings.error_tracking_access_token, id: 'error-tracking-access-token') }
+ .form-inline
+ = button_to _("Reset error tracking access token"), reset_error_tracking_access_token_admin_application_settings_path,
+ method: :put, class: 'gl-button btn btn-danger btn-sm',
+ data: { confirm: _('Are you sure you want to reset the error tracking access token?') }
+
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f|
+ = form_errors(@application_setting, pajamas_alert: true) if expanded
+
+ %fieldset
+ .sub-section
+ %h4= _('Configure Error Tracking')
+ .form-group
+ = f.gitlab_ui_checkbox_component :error_tracking_enabled,
+ _('Enable GitLab Error Tracking')
+ .form-group
+ = f.label :error_tracking_api_url, _('Opstrace endpoint for Error Tracking integration'), class: 'label-light'
+ = f.text_field :error_tracking_api_url, class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 4d0faf69958..f287dba9866 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -1,6 +1,6 @@
%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('ExternalAuthorization|External authorization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml
index b5a63aa0847..d63eb2bd09d 100644
--- a/app/views/admin/application_settings/_floc.html.haml
+++ b/app/views/admin/application_settings/_floc.html.haml
@@ -2,7 +2,7 @@
%section.settings.no-animate#js-floc-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('FloC|Federated Learning of Cohorts')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index eb47d177701..cc1e3f968cb 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -2,7 +2,7 @@
%section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Gitpod')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
@@ -13,7 +13,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
index 7f305b9ad9c..f17f63c7df7 100644
--- a/app/views/admin/application_settings/_grafana.html.haml
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect_application_key.html.haml
index e395741dcaa..68a82288573 100644
--- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml
+++ b/app/views/admin/application_settings/_jira_connect_application_key.html.haml
@@ -2,7 +2,7 @@
%section.settings.no-animate#js-jira_connect-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('JiraConnect|GitLab for Jira App')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
@@ -12,7 +12,7 @@
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index b1dd8a282ec..c0ac924407f 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('kroki_')
%section.settings.as-kroki.no-animate#js-kroki-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Kroki')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
index e84fdc56f93..cbe7e1c5bb6 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('mailgun_')
%section.settings.as-mailgun.no-animate#js-mailgun-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Mailgun')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
@@ -9,7 +9,7 @@
= _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') }
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f|
- = form_errors(@application_setting) if expanded
+ = form_errors(@application_setting, pajamas_alert: true) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index c0fabb1d42e..b31576b5c48 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -1,7 +1,7 @@
- if Gitlab.config.packages.enabled
%section.settings.as-package.no-animate#js-package-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Package Registry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 4e37c4c3c98..a7f73edcf69 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 57931544e65..8be37ff1dda 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('plantuml_')
%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('PlantUML')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
@@ -10,7 +10,7 @@
= link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f|
- = form_errors(@application_setting) if expanded
+ = form_errors(@application_setting, pajamas_alert: true) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index 59681c0278e..d8dffd6bc16 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-prometheus-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 856db32e088..db4d1cb323c 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index ef8d3ccc8ab..40d847f4949 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.sub-section
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index dad8d5f3fae..156a6bbcfa6 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index d962d050ebc..a8e109ce377 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index fccf039533b..2365daa2c70 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -1,3 +1,3 @@
-= form_errors(@application_setting)
+= form_errors(@application_setting, pajamas_alert: true)
#js-signup-form{ data: signup_form_data }
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index e9387ab3f26..8684b909853 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('snowplow_')
%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { qa_selector: 'snowplow_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Snowplow')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index a0cbbecb943..43ff2bc02f5 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -3,7 +3,7 @@
%section.settings.as-sourcegraph.no-animate#js-sourcegraph-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sourcegraph')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index bb512940be2..7f3125d91ba 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
%h5
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index c53f63e124b..5703fbb463e 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -1,5 +1,5 @@
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 205e14fb8ab..397b47eefaa 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -1,7 +1,7 @@
- expanded = integration_expanded?('hide_third_party_')
%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Customer experience improvement and third-party offers')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
@@ -9,7 +9,7 @@
= _('Control whether to display customer experience improvement content and third-party offers in GitLab.')
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
- = form_errors(@application_setting) if expanded
+ = form_errors(@application_setting, pajamas_alert: true) if expanded
%fieldset
.form-group
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index 96dcd7e1111..d35fba7d3b2 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
= render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection')
@@ -24,7 +24,7 @@
.form-group
= f.label :import_sources, s_('AdminSettings|Import sources'), class: 'label-bold gl-mb-0'
%span.form-text.gl-mt-0.gl-mb-3#import-sources-help
- = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub')
+ = _('Code can be imported from enabled sources during project creation. OmniAuth must be configured for GitHub')
= link_to sprite_icon('question-o'), help_page_path("integration/github")
, Bitbucket
= link_to sprite_icon('question-o'), help_page_path("integration/bitbucket")
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 5816bd42a83..224d9fbe953 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -1,7 +1,7 @@
- parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe
= gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f|
- = form_errors(@appearance)
+ = form_errors(@appearance, pajamas_alert: true)
.row
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 5e3f0d6f2aa..0adb6cbbcf0 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -1,6 +1,6 @@
- expanded = local_assigns.fetch(:expanded)
-%h4
+%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Variables')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index b635e7198cb..f0f7e6868da 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -14,7 +14,7 @@
%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Continuous Integration and Deployment')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -29,7 +29,7 @@
- if Gitlab.config.registry.enabled
%section.settings.as-registry.no-animate#js-registry-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Container Registry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -41,7 +41,7 @@
- if Feature.enabled?(:runner_registration_control)
%section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('Runners|Runner registration')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? 'Collapse' : 'Expand'
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 36b9ad189d8..d7559fcd48b 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -4,18 +4,18 @@
%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Visibility and access controls')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Set default and restrict visibility levels. Configure import sources and git access protocol.')
+ = _('Set visibility of project contents. Configure import sources and Git access protocols.')
.settings-content
= render 'visibility_and_access'
%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Account and limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -26,7 +26,7 @@
%section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Diff limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -37,7 +37,7 @@
%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sign_up_restrictions_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sign-up restrictions')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -48,7 +48,7 @@
%section.settings.as-signin.no-animate#js-signin-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sign-in restrictions')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -60,7 +60,7 @@
%section.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Terms of Service and Privacy Policy')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -74,7 +74,7 @@
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Web terminal')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -86,7 +86,7 @@
%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Web IDE')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -94,7 +94,7 @@
= _('Manage Web IDE features.')
.settings-content
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f|
- = form_errors(@application_setting)
+ = form_errors(@application_setting, pajamas_alert: true)
%fieldset
.form-group
@@ -113,8 +113,11 @@
= render_if_exists 'admin/application_settings/slack'
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417
= render_if_exists 'admin/application_settings/dingtalk_integration'
+-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/640
+= render_if_exists 'admin/application_settings/feishu_integration'
= render 'admin/application_settings/third_party_offers'
= render 'admin/application_settings/snowplow'
+= render 'admin/application_settings/error_tracking' if Feature.enabled?(:gitlab_error_tracking)
= render 'admin/application_settings/eks'
= render 'admin/application_settings/floc'
= render_if_exists 'admin/application_settings/add_license'
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 7cc0ff2c28e..d4476bf838a 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -6,7 +6,7 @@
%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Metrics - Prometheus')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -17,7 +17,7 @@
%section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Metrics - Grafana')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -30,7 +30,7 @@
%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Profiling - Performance bar')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -44,7 +44,7 @@
%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } }
.settings-header#usage-statistics
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Usage statistics')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -56,7 +56,7 @@
- if Feature.enabled?(:configure_sentry_in_application_settings)
%section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sentry_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sentry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index f3264f733ab..485b3a9828b 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -4,7 +4,7 @@
%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Performance optimization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -15,7 +15,7 @@
%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('User and IP rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -27,7 +27,7 @@
%section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'packages_limits_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Package registry rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -39,7 +39,7 @@
%section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Files API Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -50,7 +50,7 @@
%section.settings.as-search-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Search rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -61,7 +61,7 @@
%section.settings.as-deprecated-limits.no-animate#js-deprecated-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Deprecated API rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -73,7 +73,7 @@
%section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'git_lfs_limits_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Git LFS Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -85,7 +85,7 @@
%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= s_('OutboundRequests|Outbound requests')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
@@ -98,7 +98,7 @@
%section.settings.as-protected-paths.no-animate#js-protected-paths-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Protected paths')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -111,7 +111,7 @@
%section.settings.as-issue-limits.no-animate#js-issue-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Issues Rate Limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -123,7 +123,7 @@
%section.settings.as-note-limits.no-animate#js-note-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Notes rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -135,7 +135,7 @@
%section.settings.as-users-api-limits.no-animate#js-users-api-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Users API rate limit')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -147,7 +147,7 @@
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Import and export rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -159,7 +159,7 @@
%section.settings.as-pipeline-limits.no-animate#js-pipeline-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Pipeline creation rate limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index 858f96fc0d0..bd92f7d490c 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -4,7 +4,7 @@
%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Email')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -15,7 +15,7 @@
%section.settings.as-whats-new-page.no-animate#js-whats-new-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("What's new")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -26,7 +26,7 @@
%section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sign-in and Help page')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -38,7 +38,7 @@
%section.settings.as-pages.no-animate#js-pages-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Pages')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -49,7 +49,7 @@
%section.settings.as-realtime.no-animate#js-realtime-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Polling interval multiplier')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -61,7 +61,7 @@
%section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Gitaly timeouts')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -74,7 +74,7 @@
%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Localization')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -85,7 +85,7 @@
%section.settings.as-sidekiq-job-limits.no-animate#js-sidekiq-job-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sidekiq job size limits')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml
index b15fcd93d1a..af9145bf1e7 100644
--- a/app/views/admin/application_settings/reporting.html.haml
+++ b/app/views/admin/application_settings/reporting.html.haml
@@ -4,7 +4,7 @@
%section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Spam and Anti-bot Protection')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -18,7 +18,7 @@
%section.settings.as-abuse.no-animate#js-abuse-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Abuse reports')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -27,3 +27,5 @@
= link_to _('Learn more.'), help_page_path('user/admin_area/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'abuse'
+
+= render_if_exists 'admin/application_settings/git_abuse_rate_limit'
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index 785261b4c7b..12063ea700b 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -4,7 +4,7 @@
%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Default branch')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -15,7 +15,7 @@
%section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Repository mirroring')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? 'Collapse' : 'Expand'
@@ -27,7 +27,7 @@
%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'repository_storage_settings_content' } }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Repository storage')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -39,7 +39,7 @@
%section.settings.as-repository-check.no-animate#js-repository-check-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Repository maintenance')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@@ -54,7 +54,7 @@
%section.settings.as-repository-static-objects.no-animate#js-repository-static-objects-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('External storage for repository static objects')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index fd73d4c5671..e0926221bcc 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [:admin, @application], url: @url, html: {role: 'form'} do |f|
- = form_errors(application)
+ = form_errors(application, pajamas_alert: true)
= content_tag :div, class: 'form-group row' do
.col-sm-2.col-form-label
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index dfd3b87c674..865b60a74b8 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -2,7 +2,7 @@
= render 'preview'
= gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
- = form_errors(@broadcast_message)
+ = form_errors(@broadcast_message, pajamas_alert: true)
.form-group.row.mt-4
.col-sm-2.col-form-label
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 43a8d56d584..7bcc97914e5 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -1,40 +1,52 @@
= gitlab_ui_form_for [:admin, @group] do |f|
= form_errors(@group, pajamas_alert: true)
- = render 'shared/group_form', f: f
- = render 'shared/group_form_description', f: f
-
- = render 'shared/admin/admin_note_form', f: f
-
- = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
- = render_if_exists 'admin/namespace_plan', f: f
-
- .form-group.row.group-description-holder
- .col-sm-2.col-form-label
- = f.label :avatar, _("Group avatar")
- .col-sm-10
- = render 'shared/choose_avatar_button', f: f
-
- = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
-
- .form-group.row
- .offset-sm-2.col-sm-10
- = render 'shared/allow_request_access', form: f
-
- = render 'groups/group_admin_settings', f: f
-
- = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f
+ .gl-border-b.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Naming, visibility')
+ %p
+ = _('Update your group name, description, avatar, and visibility.')
+ = link_to _('Learn more about groups.'), help_page_path('user/group/index')
+ .col-lg-8
+ = render 'shared/group_form', f: f
+ = render 'shared/group_form_description', f: f
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label'
+ = render 'shared/choose_avatar_button', f: f
+ = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
+
+ .gl-border-b.gl-pb-3.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Permissions and group features')
+ %p
+ = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.')
+ .col-lg-8
+ = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'admin/namespace_plan', f: f
+ .form-group.gl-form-group{ role: 'group' }
+ = render 'shared/allow_request_access', form: f
+ = render 'groups/group_admin_settings', f: f
+ = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f
+ .gl-mb-3
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Admin notes')
+ .col-lg-8
+ = render 'shared/admin/admin_note_form', f: f
- if @group.new_record?
- .form-group.row
- .offset-sm-2.col-sm-10
- = render Pajamas::AlertComponent.new(dismissible: false) do |c|
- = c.body do
- = render 'shared/group_tips'
- .form-actions
+ = render Pajamas::AlertComponent.new(dismissible: false) do |c|
+ = c.body do
+ = render 'shared/group_tips'
+ .gl-mt-5
= f.submit _('Create group'), class: "gl-button btn btn-confirm"
= link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel"
- else
- .form-actions
+ .gl-mt-5
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
= link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index ba7687db9c7..40c4d292e9d 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -1,5 +1,5 @@
= form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f|
- = form_errors(@identity)
+ = form_errors(@identity, pajamas_alert: true)
.form-group.row
.col-sm-2.col-form-label
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index b4dd92bf15c..2bab802b2c1 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -3,7 +3,6 @@
- page_title _("Identities"), @user.name, _("Users")
= render 'admin/users/head'
-= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn-confirm'
- if @identities.present?
.table-holder
%table.table
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 7a34972dfbf..049f3d61294 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -29,9 +29,9 @@
%h4.page-title.d-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
= sprite_icon('clock', size: 18, css_class: 'pod-icon gl-mr-3')
- = _('Uptime')
+ = _('System started')
.data
- %h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
+ %h2= time_ago_with_tooltip(Rails.application.config.booted_at)
.col-sm
.bg-light.info-well.p-3
%h4.page-title.d-flex
diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
index 462943263df..869194a21f6 100644
--- a/app/views/admin/topics/_topic.html.haml
+++ b/app/views/admin/topics/_topic.html.haml
@@ -18,3 +18,4 @@
.controls.gl-flex-shrink-0.gl-ml-5
= link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default'
+ = link_to _('Remove'), admin_topic_path(topic), aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{topic_name}?") % { topic_name: title }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger'
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index cf951ae0265..b255354f2c1 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,52 +1,48 @@
-%fieldset
- %legend.gl-border-bottom-0
- = s_('AdminUsers|Access')
- .form-group.row
- .col-12
- = f.label :projects_limit
- = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input'
-
- .form-group.row
- .col-12.gl-pt-0
- = f.label :can_create_group
- = f.gitlab_ui_checkbox_component :can_create_group, ''
-
- .form-group.row
- .col-12.gl-pt-0
- = f.label :access_level
- - editing_current_user = (current_user == @user)
-
- = f.gitlab_ui_radio_component :access_level, :regular,
- s_('AdminUsers|Regular'),
- radio_options: { disabled: editing_current_user },
- help_text: s_('AdminUsers|Regular users have access to their groups and projects.')
-
- = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
-
- - 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|Administrator'),
- radio_options: { disabled: editing_current_user },
- help_text: help_text
-
-
- .form-group.row
- .col-12.gl-pt-0
- = f.label :external
- .hidden{ data: user_internal_regex_data }
- .col-12.gl-display-flex.gl-align-items-baseline
- = f.gitlab_ui_checkbox_component :external, s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
- %row.hidden#warning_external_automatically_set
- = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
-
- .form-group.row
- - @user.credit_card_validation || @user.build_credit_card_validation
- = f.fields_for :credit_card_validation do |ff|
- .col-12.gl-pt-0
- = ff.label s_('AdminUsers|Validate user account')
- .col-12.gl-display-flex.gl-align-items-baseline
- = ff.gitlab_ui_checkbox_component :credit_card_validated_at,
- s_('AdminUsers|User is validated and can use free CI minutes on shared runners.'),
- help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.'),
- checkbox_options: { checked: @user.credit_card_validated_at.present? }
+.gl-border-b.gl-pb-3.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = s_('AdminUsers|Access')
+ .col-lg-8
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :projects_limit, class: 'gl-display-block col-form-label'
+ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group')
+
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_('AdminUsers|Access level')
+ - editing_current_user = (current_user == @user)
+
+ = f.gitlab_ui_radio_component :access_level, :regular,
+ s_('AdminUsers|Regular'),
+ radio_options: { disabled: editing_current_user },
+ help_text: s_('AdminUsers|Regular users have access to their groups and projects.')
+
+ = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
+
+ - 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|Administrator'),
+ radio_options: { disabled: editing_current_user },
+ help_text: help_text
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.gitlab_ui_checkbox_component :external,
+ s_('AdminUsers|External'),
+ help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.')
+ .hidden{ data: user_internal_regex_data }
+ .gl-display-flex.gl-align-items-baseline
+ %row.hidden#warning_external_automatically_set
+ = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning
+
+ .form-group.gl-form-group{ role: 'group' }
+ - @user.credit_card_validation || @user.build_credit_card_validation
+ = f.fields_for :credit_card_validation do |ff|
+ = ff.gitlab_ui_checkbox_component :credit_card_validated_at,
+ s_('AdminUsers|Validate user account'),
+ help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'),
+ checkbox_options: { checked: @user.credit_card_validated_at.present? }
diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml
index 10f654e0f71..dce008afb26 100644
--- a/app/views/admin/users/_admin_notes.html.haml
+++ b/app/views/admin/users/_admin_notes.html.haml
@@ -1,6 +1,9 @@
-%fieldset
- %legend.gl-border-bottom-0= _('Admin notes')
- .form-group.row
- .col-12
- = f.label :note, s_('Admin|Note')
- = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
+.gl-mb-3
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Admin notes')
+ .col-lg-8
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :note, s_('Admin|Note')
+ = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 7995bc1b6f4..5ac15694922 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -2,41 +2,42 @@
= gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f|
= form_errors(@user, pajamas_alert: true)
- %fieldset
- %legend.gl-border-bottom-0= _('Account')
- .form-group.row
- .col-12
- = f.label "#{:name} (required)"
- = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label "#{:username} (required)"
- = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label "#{:email} (required)"
- = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
+ .gl-border-b.gl-pb-3.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Account')
+ .col-lg-8
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :name, _('Name'), class: 'gl-display-block col-form-label'
+ = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
- - if @user.new_record?
- %fieldset
- %legend.gl-border-bottom-0= _('Password')
- .form-group.row
- .col-12
- %strong
- = _('Reset link will be generated and sent to the user. %{break} User will be forced to set the password on first sign in.').html_safe % { break: '<br />'.html_safe }
- - else
- %fieldset
- %legend.gl-border-bottom-0= _('Password')
- .form-group.row
- .col-12
- = f.label :password
- .col-12
- = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label :password_confirmation
- .col-12
- = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :username, _('Username'), class: 'gl-display-block col-form-label'
+ = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :email, _('Email'), class: 'gl-display-block col-form-label'
+ = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input'
+
+ .gl-border-b.gl-pb-3.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Password')
+ .col-lg-8
+ - if @user.new_record?
+ = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
+ = c.body do
+ = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.')
+ - else
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :password, _('Password'), class: 'gl-display-block col-form-label'
+ = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation'
+ = render_if_exists 'shared/password_requirements_list'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label'
+ = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input'
= render partial: 'access_levels', locals: { f: f }
@@ -44,30 +45,31 @@
= render_if_exists 'admin/users/limits', f: f
- %fieldset
- %legend.gl-border-bottom-0= _('Profile')
- .form-group.row
- .col-12
- = f.label :avatar
- .col-12
- = f.file_field :avatar
+ .gl-border-b.gl-pb-6.gl-mb-6
+ .row
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Profile')
+ .col-lg-8
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label'
+ = f.file_field :avatar
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label'
+ = f.text_field :skype, class: 'form-control gl-form-input'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label'
+ = f.text_field :linkedin, class: 'form-control gl-form-input'
+
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label'
+ = f.text_field :twitter, class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label :skype
- = f.text_field :skype, class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label :linkedin
- = f.text_field :linkedin, class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label :twitter
- = f.text_field :twitter, class: 'form-control gl-form-input'
- .form-group.row
- .col-12
- = f.label :website_url
- = f.text_field :website_url, class: 'form-control gl-form-input'
+ .form-group.gl-form-group{ role: 'group' }
+ = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label'
+ = f.text_field :website_url, class: 'form-control gl-form-input'
= render 'admin/users/admin_notes', f: f
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index 529692df0b6..ed453b42725 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -27,15 +27,18 @@
= render_if_exists 'admin/users/gma_user_badge'
.gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2
+ .gl-p-2
+ #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
- if @user != current_user
- .gl-p-2
- - if impersonation_enabled? && @user.can?(:log_in)
+ - if impersonation_enabled? && @user.can?(:log_in)
+ .gl-p-2
= link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-default gl-button", data: { qa_selector: 'impersonate_user_link' }
- - if can_force_email_confirmation?(@user)
- = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do
+ - if can_force_email_confirmation?(@user)
+ .gl-p-2
+ = render Pajamas::ButtonComponent.new(variant: :default, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do
= _('Confirm user')
.gl-p-2
- #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
+ = link_to _('New identity'), new_admin_user_identity_path(@user), class: "btn btn-primary gl-button"
= gl_tabs_nav do
= gl_tab_link_to _("Account"), admin_user_path(@user)
= gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user)
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 2dbafb517be..8c77cb394ba 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -64,6 +64,6 @@
= gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { right: true }
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
- = gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
+ = render Pajamas::SpinnerComponent.new(size: :lg, class: 'gl-my-7')
= paginate_collection @users
diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml
index 62ae551fee7..4a3062def8c 100644
--- a/app/views/clusters/clusters/_integrations.html.haml
+++ b/app/views/clusters/clusters/_integrations.html.haml
@@ -14,15 +14,3 @@
s_('ClusterIntegration|Enable Prometheus integration'),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
= prometheus_form.submit _('Save changes'), class: 'btn gl-button btn-confirm'
-
- - if Feature.enabled?(:monitor_logging, @project)
- .sub-section.form-group
- = gitlab_ui_form_for @elastic_stack_integration, as: :integration, namespace: :elastic_stack, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |elastic_stack_form|
- = elastic_stack_form.hidden_field :application_type
- .form-group.gl-form-group
- - help_text = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Elasticsearch for pod logs.')
- - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations", anchor: "elastic-stack-cluster-integration"), target: '_blank', rel: 'noopener noreferrer')
- = elastic_stack_form.gitlab_ui_checkbox_component :enabled,
- s_('ClusterIntegration|Enable Elastic Stack integration'),
- help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
- = elastic_stack_form.submit _('Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml
index cedece5ad93..572f2d6d9a2 100644
--- a/app/views/clusters/clusters/_namespace.html.haml
+++ b/app/views/clusters/clusters/_namespace.html.haml
@@ -1,5 +1,5 @@
- managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
-- non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, logs, and Web terminals.')
+- non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, and Web terminals.')
- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer'
.js-namespace-prefixed
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
index bed671832f3..3e0a8a4f88b 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml
@@ -2,10 +2,11 @@
- help_path = local_assigns.fetch(:help_path)
- label = local_assigns.fetch(:label)
- last = local_assigns.fetch(:last, false)
-- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-w-half"]
+- classes = ["btn btn-confirm gl-button btn-confirm-secondary gl-flex-direction-column gl-flex-basis-0 gl-flex-grow-1 gl-min-w-0"]
- conditional_classes = [("gl-mr-5" unless last)]
= link_to help_path, class: classes + conditional_classes do
- .svg-content.gl-p-3= image_tag logo_path, alt: label, class: "gl-w-64 gl-h-64"
- %span
+ %span.gl-display-flex.gl-align-items-center.gl-m-3.gl-h-64
+ = image_tag logo_path, alt: label, class: "gl-w-15 gl-max-h-full gl-max-w-full"
+ %span.gl-white-space-normal
= label
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
index e4128ee22a4..7039ce57bd9 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
@@ -1,14 +1,18 @@
- gke_label = s_('ClusterIntegration|Google GKE')
- eks_label = s_('ClusterIntegration|Amazon EKS')
+- civo_label = s_('ClusterIntegration|Civo Kubernetes')
- create_cluster_label = s_('ClusterIntegration|Where do you want to create a cluster?')
-- eks_help_path = help_page_path('user/infrastructure/clusters/connect/new_eks_cluster')
-- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_cluster')
+- eks_help_path = help_page_path('user/infrastructure/clusters/connect/new_eks_cluster.md')
+- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_cluster.md')
+- civo_help_path = help_page_path('user/infrastructure/clusters/connect/new_civo_cluster.md')
-.gl-p-5
+.gl-py-5.gl-md-pl-5.gl-md-pr-5
%h4.gl-mb-5
= create_cluster_label
.gl-display-flex
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
locals: { label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg', help_path: eks_help_path }
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
+ locals: { label: civo_label, logo_path: 'illustrations/third-party-logos/civo.svg', help_path: civo_help_path }
+ = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
locals: { label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', help_path: gke_help_path, last: true }
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index 7c948260d4b..ed6cecdcc3d 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -6,4 +6,4 @@
.content_list
.loading
- = gl_loading_icon(size: 'md')
+ = render Pajamas::SpinnerComponent.new(size: :md)
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index e0b8850357e..0658d548eab 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -39,7 +39,7 @@
%p
= _('Public projects are an easy way to allow everyone to have read-only access.')
- = link_to "https://docs.gitlab.com/", class: link_classes do
+ = link_to Gitlab::Saas::doc_url, class: link_classes do
.blank-state-icon
= custom_icon("lightbulb", size: 50)
.blank-state-body.gl-sm-pl-0.gl-pl-6
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index c932b416b66..8d82116bf10 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -12,7 +12,7 @@
- if todo.author
= link_to_author(todo, self_added: todo.self_added?)
- else
- (removed)
+ = _('(removed)')
%span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } }
= todo_action_name(todo)
@@ -45,17 +45,17 @@
.todo-body
.todo-note.break-word
.md
- = first_line_in_markdown(todo, :body, 150, project: todo.project)
+ = first_line_in_markdown(todo, :body, 150, project: todo.project, group: todo.group)
.todo-actions.gl-ml-3
- if todo.pending?
= link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do
= gl_loading_icon(inline: true)
- Done
+ = _('Done')
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do
= gl_loading_icon(inline: true)
- Undo
+ = _('Undo')
- else
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do
= gl_loading_icon(inline: true)
- Add a to do
+ = _('Add a to do')
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 56bd30fac73..498fb08969c 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -1,17 +1,18 @@
= render 'devise/shared/tab_single', tab_title: _('Change your password')
.login-box
.login-body
- = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
- .devise-errors
+ = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f|
+ .devise-errors.gl-px-5
= render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
- .form-group
+ .form-group.gl-px-5
= f.label _('New password'), for: "user_password"
- = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
- .form-group
+ = f.password_field :password, autocomplete: 'new-password', class: "form-control gl-form-input top js-password-complexity-validation", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
+ = render_if_exists 'shared/password_requirements_list'
+ .form-group.gl-px-5
= f.label _('Confirm new password'), for: "user_password_confirmation"
= f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
- .clearfix
+ .clearfix.gl-px-5.gl-pb-5
= f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
.clearfix.prepend-top-20
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 4cde24f4afa..d06043c1750 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -15,5 +15,5 @@
= check_box_tag :remember_me, '1', false, id: 'remember_me'
%span= _('Remember me')
- .submit-container.move-submit-down.gl-px-5
+ .submit-container.move-submit-down.gl-px-5.gl-pb-5
= submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml
new file mode 100644
index 00000000000..6cafcb941b4
--- /dev/null
+++ b/app/views/devise/sessions/email_verification.haml
@@ -0,0 +1,19 @@
+%div
+ = render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account')
+ .login-box.gl-p-5
+ .login-body
+ = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f|
+ %p
+ = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe }
+ %div
+ = f.label :verification_token, s_('IdentityVerification|Verification code')
+ = f.text_field :verification_token, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: s_('IdentityVerification|Please enter a valid code'), inputmode: 'numeric', maxlength: 6, pattern: '[0-9]{6}'
+ %p.gl-field-error.gl-mt-2
+ = resource.errors.full_messages.to_sentence
+ .gl-mt-5
+ = f.submit s_('IdentityVerification|Verify code'), class: 'gl-button btn btn-confirm'
+ - unless send_rate_limited?(resource)
+ = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0'
+ %p.gl-p-5.gl-text-secondary
+ - support_link_start = '<a href="https://about.gitlab.com/support/" target="_blank" rel="noopener noreferrer">'.html_safe
+ = s_("IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}").html_safe % { link_start: support_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index c669f3efec6..9a09f6bee38 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,7 +1,9 @@
- page_title _("Sign in")
- 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"
#signin-container
- if any_form_based_providers_enabled?
diff --git a/app/views/devise/sessions/successful_verification.haml b/app/views/devise/sessions/successful_verification.haml
new file mode 100644
index 00000000000..8af80fbdceb
--- /dev/null
+++ b/app/views/devise/sessions/successful_verification.haml
@@ -0,0 +1,11 @@
+= content_for :meta_tags do
+ %meta{ 'http-equiv': 'refresh', content: "3; url=#{@redirect_url}" }
+.gl-text-center.gl-max-w-62.gl-mx-auto
+ .svg-content.svg-80
+ = image_tag 'illustrations/success-sm.svg'
+ %h2
+ = s_('IdentityVerification|Verification successful')
+ %p.gl-pt-2
+ - redirect_url_start = '<a href="%{url}"">'.html_safe % { url: @redirect_url }
+ - redirect_url_end = '</a>'.html_safe
+ = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end }
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 57135c6cdfc..1868cfa06e9 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -45,21 +45,24 @@
= f.label :email, class: 'label-bold'
= f.email_field :email,
value: @invite_email,
- class: 'form-control gl-form-input middle',
+ class: 'form-control gl-form-input middle js-validate-email',
data: { qa_selector: 'new_user_email_field' },
required: true,
title: _('Please provide a valid email address.')
%p.gl-field-hint.text-secondary= _('We recommend a work email address.')
+ -# This is used for providing entry to Jihu on email verification
+ = render_if_exists 'devise/shared/signup_email_additional_info'
.form-group.gl-mb-5#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password,
- class: 'form-control gl-form-input bottom',
+ class: 'form-control gl-form-input bottom js-password-complexity-validation',
data: { qa_selector: 'new_user_password_field' },
autocomplete: 'new-password',
required: true,
pattern: ".{#{@minimum_password_length},}",
title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
+ = render_if_exists 'shared/password_requirements_list'
= render_if_exists 'devise/shared/phone_verification', form: f
%div
- if show_recaptcha_sign_up?
diff --git a/app/views/errors/not_found.html.haml b/app/views/errors/not_found.html.haml
index 291adbc0ae8..54291cd9abc 100644
--- a/app/views/errors/not_found.html.haml
+++ b/app/views/errors/not_found.html.haml
@@ -11,5 +11,6 @@
= form_tag search_path, method: :get, class: 'form-inline-flex' do |f|
.field
= search_field_tag :search, '', placeholder: _('Search for projects, issues, etc.'), class: 'form-control'
- = button_tag _('Search'), class: 'gl-button btn btn-sm btn-success', name: nil, type: 'submit'
+ = render Pajamas::ButtonComponent.new(variant: :confirm, size: :small, type: :submit) do
+ = _('Search')
= render 'errors/footer'
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index e114e4609f8..3090c823677 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -2,14 +2,18 @@
.container
= render partial: "shared/errors/graphic_422", formats: :svg
- %h3 Sign-in using #{@provider} auth failed
+ %h3
+ = _('Sign-in using %{provider} auth failed') % { provider: @provider }
- %p.light.subtitle Sign-in failed because #{@error}.
+ %p.light.subtitle
+ = _('Sign-in failed because %{error}.') % { error: @error }
- %p Try logging in using your username or email. If you have forgotten your password, try recovering it
+ %p
+ = _('Try logging in using your username or email. If you have forgotten your password, try recovering it')
- = link_to "Sign in", new_session_path(:user), class: 'gl-button btn primary'
- = link_to "Recover password", new_password_path(:user), class: 'gl-button btn secondary'
+ = link_to _('Sign in'), new_session_path(:user), class: 'gl-button btn primary'
+ = link_to _('Recover password'), new_password_path(:user), class: 'gl-button btn secondary'
%hr
- %p.light If none of the options work, try contacting a GitLab administrator.
+ %p.light
+ = _('If none of the options work, try contacting a GitLab administrator.')
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 614d9610f31..757c0a836f3 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -6,4 +6,4 @@
.content_list
.loading
- = gl_loading_icon(size: 'md')
+ = render Pajamas::SpinnerComponent.new(size: :md)
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 0a170ebdb24..687a1fb32bf 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -1,34 +1,29 @@
-.form-group.row
- .col-sm-2.col-form-label.pt-0
- = f.label :lfs_enabled, _('Large File Storage')
- .col-sm-10
- = f.gitlab_ui_checkbox_component :lfs_enabled, checkbox_options: { checked: @group.lfs_enabled? } do |c|
- = c.label do
- = _('Allow projects within this group to use Git LFS')
- = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2'
- = c.help_text do
- = _('This setting can be overridden in each project.')
-.form-group.row
- .col-sm-2.col-form-label
- = f.label s_('ProjectCreationLevel|Allowed to create projects')
- .col-sm-10
- = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control'
+%fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = _('Large File Storage')
+ = f.gitlab_ui_checkbox_component :lfs_enabled, checkbox_options: { checked: @group.lfs_enabled? } do |c|
+ = c.label do
+ = _('Projects in this group can use Git LFS')
+ = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2'
+ = c.help_text do
+ = _('This setting can be overridden in each project.')
+.form-group.gl-form-group{ role: 'group' }
+ = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'gl-display-block col-form-label'
+ = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'gl-form-select custom-select'
-.form-group.row
- .col-sm-2.col-form-label
- = f.label s_('SubgroupCreationlevel|Allowed to create subgroups')
- .col-sm-10
- = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, @group.subgroup_creation_level), {}, class: 'form-control'
+.form-group.gl-form-group{ role: 'group' }
+ = f.label s_('SubgroupCreationlevel|Allowed to create subgroups'), class: 'gl-display-block col-form-label'
+ = f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, @group.subgroup_creation_level), {}, class: 'gl-form-select custom-select'
-.form-group.row
- .col-sm-2.col-form-label.pt-0
- = f.label :require_two_factor_authentication, _('Two-factor authentication')
- .col-sm-10
- - label = _("Require all users in this group to set up two-factor authentication")
- - help_link = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group'), class: 'gl-ml-2'
- = f.gitlab_ui_checkbox_component :require_two_factor_authentication, '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link }
-.form-group.row
- .offset-sm-2.col-sm-10
- .form-check
- = f.text_field :two_factor_grace_period, class: 'form-control'
- .form-text.text-muted= _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.")
+%fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = _('Two-factor authentication')
+ - label = _("All users in this group must set up two-factor authentication")
+ - help_link = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group'), class: 'gl-ml-2'
+ = f.gitlab_ui_checkbox_component :require_two_factor_authentication, '%{label}%{help_link}'.html_safe % { label: label, help_link: help_link }
+
+.form-group.gl-form-group{ role: 'group' }
+ = f.label :two_factor_grace_period, _('Two-factor authentication grace period'), class: 'gl-display-block col-form-label'
+ = f.text_field :two_factor_grace_period, class: 'form-control gl-form-input gl-form-input-sm'
+ %small.form-text.text-gl-muted
+ = _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.")
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 654ee70dbee..a9234753aa2 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -1,4 +1,4 @@
-= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f|
+= form_with url: configure_import_bulk_imports_path(namespace_id: params[:parent_id]), class: 'group-form gl-show-field-errors' do |f|
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
.gl-display-flex.gl-align-items-center
%h4.gl-display-flex
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 04170c30a20..022777eea27 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -2,46 +2,18 @@
- group_path = root_url
- group_path << parent.full_path + '/' if parent
-= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f|
+= form_for '', url: import_gitlab_group_path, namespace: 'import_group', class: 'group-form gl-show-field-errors', multipart: true do |f|
.gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
%h4
= _('Import group from file')
= render Pajamas::AlertComponent.new(variant: :warning,
+ alert_options: { class: 'gl-mb-5' },
dismissible: false) do |c|
= c.body do
- 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 }
-
- .form-group.gl-display-flex.gl-flex-direction-column.gl-mt-5
- = f.label :name, _('New group name'), for: 'import_group_name'
- = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8',
- required: true,
- title: _('Please fill in a descriptive name for your group.'),
- autofocus: true,
- id: 'import_group_name'
-
- .form-group.gl-display-flex.gl-flex-direction-column
- = f.label :import_group_path, _('New group URL'), for: 'import_group_path'
- .input-group.gl-field-error-anchor.col-xs-12.col-sm-8.gl-p-0
- .group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' }
- .input-group-text
- %span
- = root_url
- - if parent
- %strong= parent.full_path + '/'
- = f.hidden_field :parent_id, value: parent&.id
- = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path js-autofill-group-path',
- id: 'import_group_path',
- required: true,
- pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
- title: group_url_error_message,
- 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 path availability...')
+ = render 'shared/groups/group_name_and_path_fields', f: f
.form-group
= f.label :file, s_('GroupsNew|Upload file')
.gl-font-weight-normal
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 83211505f36..0527d38159b 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -2,12 +2,12 @@
= render 'shared/group_form', f: f, autofocus: true
.row
- .form-group.col-sm-12.gl-mb-0
+ .form-group.gl-form-group.col-sm-12
%label.label-bold
= _('Visibility level')
%p
= _('Who will be able to see this group?')
- = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer'
= render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
- if Gitlab.config.mattermost.enabled
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
index ef6410ad439..6063d160fab 100644
--- a/app/views/groups/_shared_projects.html.haml
+++ b/app/views/groups/_shared_projects.html.haml
@@ -3,5 +3,5 @@
%p= _("There are no projects shared with this group yet")
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
- .js-groups-list-holder
+ .js-groups-list-holder{ data: { current_group_visibility: group.visibility } }
= gl_loading_icon
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 635a74d8179..d9fef8940eb 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -7,18 +7,16 @@
.col-lg-12
.gl-display-flex.gl-flex-wrap
- if can_admin_group_member?(@group)
- .gl-w-half.gl-xs-w-full
- %h4
- = _('Group members')
- %p
- = group_member_header_subtext(@group)
- .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') } }
- .js-invite-members-trigger{ data: { variant: 'confirm',
- classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
- trigger_source: 'group-members-page',
- display_text: _('Invite members') } }
+ %h4
+ = _('Group members')
+ %p.gl-w-full.order-md-1
+ = group_member_header_subtext(@group)
+ .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3
+ .js-invite-group-trigger{ data: { classes: 'gl-md-w-auto gl-w-full', display_text: _('Invite a group') } }
+ .js-invite-members-trigger{ data: { variant: 'confirm',
+ classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3',
+ trigger_source: 'group-members-page',
+ display_text: _('Invite members') } }
= render 'groups/invite_groups_modal', group: @group
= render 'groups/invite_members_modal', group: @group
@@ -28,6 +26,7 @@
members: @members,
invited: @invited_members,
access_requests: @requesters,
+ banned: @banned || [],
include_relations: @include_relations,
search: params[:search_groups]).to_json } }
= gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml
index 6a1e66520b5..a8a52b2aba7 100644
--- a/app/views/groups/harbor/repositories/index.html.haml
+++ b/app/views/groups/harbor/repositories/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Harbor Registry")
- @content_class = "limit-container-width" unless fluid_layout
-#js-harbor-registry-list-group{ data: { endpoint: group_harbor_registries_path(@group),
+#js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index b33d1443706..33fcda6129c 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -3,6 +3,8 @@
- page_title _("Merge requests")
- if issuables_count_for_state(:merge_requests, :all) == 0
+ = render 'shared/issuable/search_bar', type: :merge_requests
+
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml
index 5a9d2ca858e..65e797a2e82 100644
--- a/app/views/groups/runners/show.html.haml
+++ b/app/views/groups/runners/show.html.haml
@@ -1,6 +1,10 @@
- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
-- if Feature.enabled?(:group_runner_view_ui)
- #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group)} }
+- if Feature.enabled?(:group_runner_view_ui, @group)
+ - title = "##{@runner.id} (#{@runner.short_sha})"
+ - breadcrumb_title title
+ - page_title title
+
+ #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} }
- else
= render 'shared/runners/runner_details', runner: @runner
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index ad0780e869c..527791dfc04 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -1,6 +1,6 @@
-= form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f|
+= gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' }
- = form_errors(@group)
+ = form_errors(@group, pajamas_alert: true)
%fieldset
.row
@@ -30,6 +30,6 @@
- if @group.avatar?
%hr
= 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 s_('Groups|Save changes'), class: 'btn gl-button btn-confirm mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
+ .form-group.gl-form-group
+ = 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 s_('Groups|Save changes'), class: 'btn gl-button btn-confirm js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 319af7be22e..a60ab43f566 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -1,33 +1,33 @@
= gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-permissions-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-permissions-settings' }
- = form_errors(@group)
+ = form_errors(@group, pajamas_alert: true)
%fieldset
%h5= _('Permissions')
- if @group.root?
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :prevent_sharing_groups_outside_hierarchy,
- s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) },
+ s_('GroupSettings|Members cannot invite groups outside of %{group} and its subgroups').html_safe % { group: link_to_group(@group) },
help_text: prevent_sharing_groups_outside_hierarchy_help_text(@group),
checkbox_options: { disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group) }
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :share_with_group_lock,
- s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: link_to_group(@group) },
+ s_('GroupSettings|Projects in %{group} cannot be shared with other groups').html_safe % { group: link_to_group(@group) },
checkbox_options: { disabled: !can_change_share_with_group_lock?(@group) },
help_text: share_with_group_lock_help_text(@group)
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :emails_disabled,
- s_('GroupSettings|Disable email notifications'),
+ s_('GroupSettings|Email notifications are disabled'),
checkbox_options: { checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group) },
help_text: s_('GroupSettings|Overrides user notification preferences for all members of the group, subgroups, and projects.')
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :mentions_disabled,
- s_('GroupSettings|Disable group mentions'),
+ s_('GroupSettings|Group mentions are disabled'),
checkbox_options: { checked: @group.mentions_disabled? },
- help_text: s_('GroupSettings|Prevents group members from being notified if the group is mentioned.')
+ help_text: s_('GroupSettings|Group members are not notified if the group is mentioned.')
= render 'groups/settings/resource_access_token_creation', f: f, group: @group
= render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
@@ -48,8 +48,8 @@
%h5= _('Customer relations')
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :crm_enabled,
- s_('GroupSettings|Enable customer relations'),
+ s_('GroupSettings|Customer relations is enabled'),
checkbox_options: { checked: @group.crm_enabled? },
- help_text: s_('GroupSettings|Allows creating organizations and contacts and associating them with issues.')
+ help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.')
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml
index 36b714535d2..ef535b8a21c 100644
--- a/app/views/groups/settings/_project_creation_level.html.haml
+++ b/app/views/groups/settings/_project_creation_level.html.haml
@@ -1,3 +1,3 @@
.form-group
- = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'label-bold'
+ = f.label s_('ProjectCreationLevel|Roles allowed to create projects'), class: 'label-bold'
= f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control', data: { qa_selector: 'project_creation_level_dropdown' }
diff --git a/app/views/groups/settings/_resource_access_token_creation.html.haml b/app/views/groups/settings/_resource_access_token_creation.html.haml
index 160f8ae1e07..d304dba3250 100644
--- a/app/views/groups/settings/_resource_access_token_creation.html.haml
+++ b/app/views/groups/settings/_resource_access_token_creation.html.haml
@@ -6,6 +6,5 @@
- link_start_project = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_access_tokens_link }
- link_start_group = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_access_tokens_link }
= f.gitlab_ui_checkbox_component :resource_access_token_creation_allowed,
- s_('GroupSettings|Allow project and group access token creation'),
- checkbox_options: { checked: group.namespace_settings.resource_access_token_creation_allowed?, data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' } },
- help_text: s_('GroupSettings|Users can create %{link_start_project}project access tokens%{link_end} and %{link_start_group}group access tokens%{link_end} in this group.').html_safe % { link_start_project: link_start_project, link_start_group: link_start_group, link_end: '</a>'.html_safe }
+ s_('GroupSettings|Users can create %{link_start_project}project access tokens%{link_end} and %{link_start_group}group access tokens%{link_end} in this group').html_safe % { link_start_project: link_start_project, link_start_group: link_start_group, link_end: '</a>'.html_safe },
+ checkbox_options: { checked: group.namespace_settings.resource_access_token_creation_allowed?, data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' } }
diff --git a/app/views/groups/settings/_subgroup_creation_level.html.haml b/app/views/groups/settings/_subgroup_creation_level.html.haml
index f36ad192bad..d92610367ae 100644
--- a/app/views/groups/settings/_subgroup_creation_level.html.haml
+++ b/app/views/groups/settings/_subgroup_creation_level.html.haml
@@ -1,3 +1,3 @@
.form-group
- = f.label s_('SubgroupCreationLevel|Allowed to create subgroups'), class: 'label-bold'
+ = f.label s_('SubgroupCreationLevel|Roles allowed to create subgroups'), class: 'label-bold'
= f.select :subgroup_creation_level, options_for_select(::Gitlab::Access.subgroup_creation_options, group.subgroup_creation_level), {}, class: 'form-control'
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index e6c88977cb1..7fe5a7a665b 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -6,7 +6,7 @@
%p= _('Transfer group to another parent group.')
= 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
+ - learn_more_link_start = '<a href="https://docs.gitlab.com/ee/user/project/repository/index.html#what-happens-when-a-repository-path-changes" 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.')
diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml
index f86bcb24e63..03813f6f8a2 100644
--- a/app/views/groups/settings/_two_factor_auth.html.haml
+++ b/app/views/groups/settings/_two_factor_auth.html.haml
@@ -8,14 +8,14 @@
.form-group
= f.gitlab_ui_checkbox_component :require_two_factor_authentication,
- _('Require all users in this group to set up two-factor authentication'),
+ _('All users in this group must set up two-factor authentication'),
checkbox_options: { data: { qa_selector: 'require_2fa_checkbox' } }
.form-group
- = f.label :two_factor_grace_period, _('Time before enforced')
+ = f.label :two_factor_grace_period, _('Delay 2FA enforcement (hours)')
= f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto gl-form-input gl-mb-3'
- .form-text.text-muted= _('Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.')
+ .form-text.text-muted= _("The maximum amount of time users have to set up two-factor authentication before it's enforced.")
- unless group.has_parent?
.form-group
= f.gitlab_ui_checkbox_component :allow_mfa_for_subgroups,
- _('Allow subgroups to set up their own two-factor authentication rules'),
+ _('Subgroups can set up their own two-factor authentication rules'),
checkbox_options: { checked: group.namespace_settings&.allow_mfa_for_subgroups }
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index 86c0a8d0c52..c294df5ac62 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
- = form_errors(group)
+ = form_errors(group, pajamas_alert: true)
%fieldset
.form-group
.card.auto-devops-card
diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml
index b6f70879d17..59c67197f81 100644
--- a/app/views/groups/settings/ci_cd/_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_form.html.haml
@@ -1,7 +1,6 @@
.row.gl-mt-3
.col-lg-12
= form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f|
- = form_errors(group)
%fieldset.builds-feature
.form-group
= f.label :max_artifacts_size, _('Maximum artifacts size'), class: 'label-bold'
diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml
index c4ce76c43ec..888419e463a 100644
--- a/app/views/groups/settings/packages_and_registries/show.html.haml
+++ b/app/views/groups/settings/packages_and_registries/show.html.haml
@@ -2,6 +2,5 @@
- page_title _('Packages & Registries')
- @content_class = 'limit-container-width' unless fluid_layout
-%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s,
- group_path: @group.full_path,
+%section#js-packages-and-registries-settings{ data: { group_path: @group.full_path,
group_dependency_proxy_path: group_dependency_proxy_path(@group) } }
diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml
index 844a5f890a4..cae33820a05 100644
--- a/app/views/groups/settings/repository/_default_branch.html.haml
+++ b/app/views/groups/settings/repository/_default_branch.html.haml
@@ -8,7 +8,7 @@
= s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.')
.settings-content
= gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@group)
+ = form_errors(@group, pajamas_alert: true)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>"
%fieldset
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 3614d854036..d8da77dc5cc 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -3,8 +3,8 @@
- @skip_current_level_breadcrumb = true
- add_page_specific_style 'page_bundles/group'
-- if show_thanks_for_purchase_banner?
- = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
+- if show_thanks_for_purchase_alert?
+ = render_if_exists 'shared/thanks_for_purchase_alert', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
= render_if_exists 'shared/qrtly_reconciliation_alert', group: @group
= render_if_exists 'shared/free_user_cap_alert', source: @group
@@ -25,7 +25,7 @@
= render partial: 'flash_messages'
-= render_if_exists 'trials/banner', namespace: @group
+= render_if_exists 'trials/alert', namespace: @group
= render 'groups/home_panel'
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index e69ca4663b4..b4b73e9e790 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -6,4 +6,4 @@
= sprite_icon('bitbucket', css_class: 'gl-mr-2')
= _('Import projects from Bitbucket')
-= render 'import/githubish_status', provider: 'bitbucket'
+= render 'import/githubish_status', provider: 'bitbucket', default_namespace: @namespace
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index 0d87cf66814..292dd9d071c 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -10,7 +10,7 @@
%p
= _('Enter in your Bitbucket Server URL and personal access token below')
-= form_tag configure_import_bitbucket_server_path, method: :post do
+= form_tag configure_import_bitbucket_server_path(namespace_id: params[:namespace_id]), method: :post do
.form-group.row
= label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2'
.col-md-4
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index 05b42767668..7e0c7b3dd74 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -5,4 +5,4 @@
= sprite_icon('bitbucket', css_class: 'gl-mr-2')
= _('Import projects from Bitbucket Server')
-= render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
+= render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, default_namespace: @namespace, extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml
index 71866bab30b..1c8de23f28f 100644
--- a/app/views/import/bulk_imports/status.html.haml
+++ b/app/views/import/bulk_imports/status.html.haml
@@ -4,6 +4,7 @@
#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json),
available_namespaces_path: import_available_namespaces_path(format: :json),
+ default_target_namespace: @namespace&.id,
create_bulk_import_path: import_bulk_imports_path(format: :json),
jobs_path: realtime_changes_import_bulk_imports_path(format: :json),
source_url: @source_url,
diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml
index b74262f2567..bd0e4b51a63 100644
--- a/app/views/import/fogbugz/new.html.haml
+++ b/app/views/import/fogbugz/new.html.haml
@@ -8,7 +8,7 @@
= _('Import projects from FogBugz')
%hr
-= form_tag callback_import_fogbugz_path do
+= form_tag callback_import_fogbugz_path(namespace_id: params[:namespace_id]) do
%p
= _("To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import.")
.form-group.row
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 5caee78b9c4..28836055e0e 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -8,7 +8,7 @@
= _('Import projects from FogBugz')
%hr
-= form_tag create_user_map_import_fogbugz_path do
+= form_tag create_user_map_import_fogbugz_path(namespace_id: params[:namespace_id]) do
%p
= _("Customize how FogBugz email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import.")
%p
diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml
index 3e303d3163d..fb05e8e9724 100644
--- a/app/views/import/fogbugz/status.html.haml
+++ b/app/views/import/fogbugz/status.html.haml
@@ -8,4 +8,4 @@
- link_to_customize = link_to('customize', new_user_map_import_fogbugz_path)
= _('Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab.').html_safe % { link_to_customize: link_to_customize }
%hr
-= render 'import/githubish_status', provider: 'fogbugz', filterable: false
+= render 'import/githubish_status', provider: 'fogbugz', filterable: false, default_namespace: @namespace
diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml
index 13aaa41de9b..d2d49266350 100644
--- a/app/views/import/gitlab/status.html.haml
+++ b/app/views/import/gitlab/status.html.haml
@@ -3,4 +3,4 @@
= sprite_icon('heart', css_class: 'gl-vertical-align-middle')
= _('Import projects from GitLab.com')
-= render 'import/githubish_status', provider: 'gitlab', filterable: false
+= render 'import/githubish_status', provider: 'gitlab', filterable: false, default_namespace: @namespace
diff --git a/app/views/layouts/_bizible.html.haml b/app/views/layouts/_bizible.html.haml
index a2b28c138e5..8d173a7ab61 100644
--- a/app/views/layouts/_bizible.html.haml
+++ b/app/views/layouts/_bizible.html.haml
@@ -1,6 +1,5 @@
- 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');
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index 21cccb86398..ab4b3cf6afd 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,6 +1,7 @@
-# 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'}
+- closable = %w[alert notice success]
.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } }
- flash.each do |key, value|
- if key == 'toast' && value
@@ -13,6 +14,6 @@
%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)
+ - if closable.include?(key)
%div{ class: "close-icon-wrapper js-close-icon" }
= sprite_icon('close', css_class: 'close-icon gl-vertical-align-baseline!')
diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml
index 3c62180214b..28118cf4aaa 100644
--- a/app/views/layouts/_header_search.html.haml
+++ b/app/views/layouts/_header_search.html.haml
@@ -25,3 +25,7 @@
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test'
%noscript= button_tag 'Search'
+ %kbd.gl-absolute.gl-right-3.gl-top-0.keyboard-shortcut-helper.gl-z-index-1.has-tooltip{ data: { html: 'true',
+ placement: 'bottom' },
+ title: html_escape(s_('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search')) % { kbdOpen: '<kbd>'.html_safe, kbdClose: '</kbd>'.html_safe } }
+ = '/'
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index b7cf7b7468f..59d4c81358d 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -17,7 +17,6 @@
= dispensable_render "shared/service_ping_consent"
= dispensable_render_if_exists "layouts/header/ee_subscribable_banner"
= dispensable_render_if_exists "layouts/header/seat_count_alert"
- = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
= dispensable_render_if_exists "shared/namespace_user_cap_reached_alert"
= dispensable_render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :page_level_alert
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index cee5c1b6b69..cb1a2a8c690 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html.devise-layout-html{ class: system_message_class }
= render "layouts/head", { startup_filename: 'signin' }
- %body.ui-indigo.login-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } }
+ %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } }
= header_message
= render "layouts/init_client_detection_flags"
.page-wrap
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index b5649be8917..cadba3f91e9 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html.devise-layout-html{ lang: "en", class: system_message_class }
= render "layouts/head"
- %body.ui-indigo.login-page.application.navless{ class: "#{client_class_list}" }
+ %body.login-page.application.navless{ class: "#{user_application_theme} #{client_class_list}" }
= header_message
= render "layouts/init_client_detection_flags"
= render "layouts/header/empty"
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 940724e0e4a..1c2ab8cf008 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -3,11 +3,11 @@
- header_title group_title(@group) unless header_title
- nav "group"
- display_subscription_banner!
-- display_namespace_storage_limit_alert!
- @left_sidebar = true
- content_for :flash_message do
= render "layouts/header/storage_enforcement_banner", namespace: @group
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
- content_for :page_specific_javascripts do
- if current_user
diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml
index 580b8e67a3c..8452f0d9976 100644
--- a/app/views/layouts/mailer.html.haml
+++ b/app/views/layouts/mailer.html.haml
@@ -3,8 +3,6 @@
%td
%img.footer-logo{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png') }
%div
- - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link')
- - help_link = link_to(_("Help"), help_url, class: 'help-link')
- = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+ = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html)
= render 'layouts/mailer'
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index 1a06ea68bcd..24553734e49 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -3,7 +3,7 @@
<%= yield -%>
-- <%# signature marker %>
-<%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %>
+<%= notification_reason_text %>
<%= render_if_exists 'layouts/mailer/additional_text' %>
<%= text_footer_message %>
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index fde4e74fb7a..98d6af28cf5 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -8,7 +8,7 @@
- if defined?(@left_sidebar)
= button_tag class: 'toggle-mobile-nav', data: { qa_selector: 'toggle_mobile_nav_button' }, type: 'button' do
%span.sr-only= _("Open sidebar")
- = sprite_icon('hamburger', size: 18)
+ = sprite_icon('sidebar', size: 18)
.breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
%ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- unless hide_top_links
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index d9f16a89fbc..d05b6951fbf 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -26,16 +26,7 @@
- else
#{link_to _("View it on GitLab"), @target_url}.
%br
- -# Don't link the host in the line below, one link in the email is easier to quickly click than two.
- = notification_reason_text(@reason)
- If you'd like to receive fewer emails, you can
- - if @labels_url
- adjust your #{link_to 'label subscriptions', @labels_url}.
- - else
- - if @unsubscribe_url
- = link_to "unsubscribe", @unsubscribe_url
- from this thread or
- adjust your notification settings.
+ = notification_reason_text(reason: @reason, show_manage_notifications_link: !@labels_url, show_help_link: true, manage_label_subscriptions_url: @labels_url, unsubscribe_url: @unsubscribe_url, format: :html)
= email_action @target_url
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index 49ad0b5abc5..4eae96dc376 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -11,7 +11,7 @@
<% end -%>
<% end -%>
-<%= notification_reason_text(@reason) %>
+<%= notification_reason_text(reason: @reason) %>
<%= render_if_exists 'layouts/mailer/additional_text' %>
<%= text_footer_message -%>
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index a54e0351d2f..86b4c4eabe3 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -4,10 +4,13 @@
- nav "project"
- page_itemtype 'http://schema.org/SoftwareSourceCode'
- display_subscription_banner!
-- display_namespace_storage_limit_alert!
- @left_sidebar = true
- @content_class = [@content_class, project_classes(@project)].compact.join(" ")
+- content_for :flash_message do
+ = render "layouts/header/storage_enforcement_banner", namespace: @project.namespace
+ = dispensable_render_if_exists "shared/namespace_storage_limit_alert"
+
- content_for :project_javascripts do
- project = @target_project || @project
- if current_user
diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml
index f768fba84ca..4d0bb36d4b5 100644
--- a/app/views/layouts/signup_onboarding.html.haml
+++ b/app/views/layouts/signup_onboarding.html.haml
@@ -2,7 +2,7 @@
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/signup'
= render "layouts/head"
- %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
+ %body.signup-page{ class: "#{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"
= render "layouts/init_client_detection_flags"
.page-wrap
diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml
index afed3c95130..fc4a063f5a9 100644
--- a/app/views/notify/_failed_builds.html.haml
+++ b/app/views/notify/_failed_builds.html.haml
@@ -1,12 +1,10 @@
%tr
%td{ colspan: 2, style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 0 8px 16px; text-align: center;" }
- had
- = failed.size
- failed
- #{'job'.pluralize(failed.size)}.
+ = n_('had %{count} failed job', 'had %{count} failed jobs', failed.size).html_safe % { count: failed.size }
+
%tr.table-warning
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" }
- Failed jobs
+ = n_('Failed job', 'Failed jobs', failed.size)
%tr.section
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" }
%table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" }
diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml
index 4ab40ff2659..54e51e07c86 100644
--- a/app/views/notify/_reassigned_issuable_email.html.haml
+++ b/app/views/notify/_reassigned_issuable_email.html.haml
@@ -1,10 +1,7 @@
+- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : s_('Unassigned'))
+
%p
- Assignee changed
- if previous_assignees.any?
- from
- %strong= sanitize_name(previous_assignees.map(&:name).to_sentence)
- to
- - if issuable.assignees.any?
- %strong= sanitize_name(issuable.assignee_list)
+ = html_escape(s_('Notify|Assignee changed from %{fromNames} to %{toNames}').html_safe % { fromNames: content_tag(:strong, sanitize_name(previous_assignees.map(&:name).to_sentence)), toNames: to_names })
- else
- %strong Unassigned
+ = html_escape(s_('Notify|Assignee changed to %{toNames}').html_safe % { toNames: to_names})
diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml
index 80a0de255be..41d3a63845f 100644
--- a/app/views/notify/_relabeled_issuable_email.html.haml
+++ b/app/views/notify/_relabeled_issuable_email.html.haml
@@ -1,3 +1,2 @@
%p
- #{'Label'.pluralize(@label_names.size)} added:
- %em= @label_names.to_sentence
+ = html_escape(n_('Label added: %{labels}', 'Labels added: %{labels}', @label_names.size).html_safe % { labels: content_tag(:em, @label_names.to_sentence).html_safe })
diff --git a/app/views/notify/_removal_notification.html.haml b/app/views/notify/_removal_notification.html.haml
index 590e0d569aa..1c3c84e0f41 100644
--- a/app/views/notify/_removal_notification.html.haml
+++ b/app/views/notify/_removal_notification.html.haml
@@ -1,9 +1,5 @@
-- if @domain.remove_at
- %p
- Unless you verify your domain by
- %strong= @domain.remove_at.strftime('%F %T,')
- it will be removed from your GitLab project.
-- else
- %p
- If you no longer wish to use this domain with GitLab Pages, please remove it
- from your GitLab project and delete any related DNS records.
+%p
+ - if @domain.remove_at
+ = s_('Notify|Unless you verify your domain by %{time_start}%{time}%{time_end} it will be removed from your GitLab project.').html_safe % { time_start: '<strong>'.html_safe, time_end: '</strong>'.html_safe, time: @domain.remove_at.strftime('%F %T,') }
+ - else
+ = s_('Notify|If you no longer wish to use this domain with GitLab Pages, please remove it from your GitLab project and delete any related DNS records.')
diff --git a/app/views/notify/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml
index 231df2e9206..e77db14a9c5 100644
--- a/app/views/notify/_successful_pipeline.html.haml
+++ b/app/views/notify/_successful_pipeline.html.haml
@@ -45,12 +45,12 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" }
%img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-commit-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
- %a{ href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
- = @pipeline.short_sha
+ - commit_link = content_tag(:a, @pipeline.short_sha, href: commit_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe
- if @merge_request
- in
- %a{ href: merge_request_url(@merge_request), style: "color:#3777b0;text-decoration:none;" }
- = @merge_request.to_reference
+ - mr_link = content_tag(:a, @merge_request.to_reference, href: merge_request_url(@merge_request), style: "color:#3777b0;text-decoration:none;").html_safe
+ = s_('Notify|%{commit_link} in %{mr_link}').html_safe % { commit_link: commit_link, mr_link: mr_link }
+ - else
+ = commit_link
.commit{ style: "color:#5c5c5c;font-weight:300;" }
= @pipeline.git_commit_message.truncate(50)
- commit = @pipeline.commit
@@ -94,25 +94,22 @@
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
%tbody
%tr
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
- Pipeline
- %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" }
- = "\##{@pipeline.id}"
- triggered by
+ - common_style = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;"
+ - pipeline_link = content_tag(:a, "\##{@pipeline.id}", href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;").html_safe
+ %td{ style: "#{common_style} font-weight:500;vertical-align:baseline;" }
+ = s_('Notify|Pipeline %{pipeline_link} triggered by').html_safe % { pipeline_link: pipeline_link }
- if @pipeline.user
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
+ %td{ style: "#{common_style} font-weight:500;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" }
%img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" }
+ %td{ style: "#{common_style} vertical-align:baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" }
= @pipeline.user.name
- else
%td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" }
- API
+ = _('API')
+
%tr
%td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" }
- job_count = @pipeline.total_size
- stage_count = @pipeline.stages_count
- successfully completed
- #{job_count} #{'job'.pluralize(job_count)}
- in
- #{stage_count} #{'stage'.pluralize(stage_count)}.
+ = s_('Notify|successfully completed %{jobs} in %{stages}.').html_safe % { jobs: n_('%d job', '%d jobs', job_count) % job_count, stages: n_('%d stage', '%d stages', stage_count) % stage_count }
diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml
index c51fe02370d..28da1182d49 100644
--- a/app/views/notify/approved_merge_request_email.html.haml
+++ b/app/views/notify/approved_merge_request_email.html.haml
@@ -152,6 +152,4 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" }/
%div
- - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
- - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
- = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+ = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html)
diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml
index 72bcfbdf3af..bdf2a1136d3 100644
--- a/app/views/notify/autodevops_disabled_email.html.haml
+++ b/app/views/notify/autodevops_disabled_email.html.haml
@@ -4,46 +4,40 @@
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; vertical-align: middle; color: #ffffff; text-align: center;" }
- Auto DevOps pipeline was disabled for #{@project.name}
+ = s_('Notify|Auto DevOps pipeline was disabled for %{project}') % { project: @project.name }
%tr.pre-section
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.7; padding: 16px 8px 0;" }
- The Auto DevOps pipeline failed for pipeline
- %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration:none;" }
- = "\##{@pipeline.iid}"
- and has been disabled for
- %a{ href: project_url(@project), style: "color: #1b69b6; text-decoration: none;" }
- = @project.name + "."
- In order to use the Auto DevOps pipeline with your project, please review the
- %a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: "color:#1b69b6;text-decoration:none;" } currently supported languages,
- adjust your project accordingly, and turn on the Auto DevOps pipeline within your
- %a{ href: project_settings_ci_cd_url(@project), style: "color: #1b69b6; text-decoration: none;" }
- CI/CD project settings.
+ - link_style = "color: #1b69b6; text-decoration:none;"
+ - pipeline_link = link_to("\##{@pipeline.iid}", pipeline_url(@pipeline), style: link_style).html_safe
+ - project_link = link_to(@project.name, project_url(@project), style: link_style).html_safe
+ - supported_langs_link = link_to(s_('Notify|currently supported languages'), 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: link_style ).html_safe
+ - settings_link = link_to(s_('Notify|CI/CD project settings'), project_settings_ci_cd_url(@project), style: link_style).html_safe
+ = s_('Notify|The Auto DevOps pipeline failed for pipeline %{pipeline_link} and has been disabled for %{project_link}. In order to use the Auto DevOps pipeline with your project, please review the %{supported_langs_link}, adjust your project accordingly, and turn on the Auto DevOps pipeline within your %{settings_link}.').html_safe % { pipeline_link: pipeline_link, project_link: project_link, supported_langs_link: supported_langs_link, settings_link: settings_link }
%tr.pre-section
%td{ style: 'text-align: center;border-bottom:1px solid #ededed' }
%a{ href: 'https://docs.gitlab.com/ee/topics/autodevops/', style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%button{ type: 'button', style: 'border-color: #dfdfdf; border-style: solid; border-width: 1px; border-radius: 4px; font-size: 14px; padding: 8px 16px; background-color:#fff; margin: 8px 0; cursor: pointer;' }
- Learn more about Auto DevOps
+ = s_('Notify|Learn more about Auto DevOps')
%tr.pre-section
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; color: #333333; font-size: 14px; font-weight: 400; line-height: 1.4; padding: 16px 8px; text-align: center;" }
%table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" }
%tbody
%tr
- %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size:14px; font-weight:500;line-height: 1.4; vertical-align: baseline;" }
- Pipeline
- %a{ href: pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration: none;" }
- = "\##{@pipeline.id}"
- triggered by
+ - common_style = "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;line-height: 1.4;"
+ - pipeline_link = link_to("\##{@pipeline.id}", pipeline_url(@pipeline), style: "color: #1b69b6; text-decoration: none;").html_safe
+ %td{ style: "#{common_style} font-size:14px;font-weight:500;vertical-align:baseline;" }
+ = s_("Notify|Pipeline %{pipeline_link} triggered by").html_safe % { pipeline_link: pipeline_link }
- if @pipeline.user
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 15px; line-height: 1.4; vertical-align: middle; padding-right: 8px; padding-left:8px", width: "24" }
- %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display: block; border-radius: 12px; margin: -2px 0;", width: "24", alt: "" }/
- %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 14px; font-weight: 500; line-height: 1.4; vertical-align: baseline;" }
+ %td{ style: "#{common_style} font-size: 15px; vertical-align: middle; padding-right: 8px; padding-left:8px", width: "24" }
+ %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display: block; border-radius: 12px; margin: -2px 0;", width: "24", alt: "" }
+ %td{ style: "#{common_style} font-size: 14px; font-weight: 500; vertical-align: baseline;" }
%a.muted{ href: user_url(@pipeline.user), style: "color: #333333; text-decoration: none;" }
= @pipeline.user.name
- else
%td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" }
- API
+ = _('API')
= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed
diff --git a/app/views/notify/changed_milestone_email.html.haml b/app/views/notify/changed_milestone_email.html.haml
index 01d27cac36b..bfc9d65d1c2 100644
--- a/app/views/notify/changed_milestone_email.html.haml
+++ b/app/views/notify/changed_milestone_email.html.haml
@@ -1,5 +1,5 @@
%p
- Milestone changed to
- %strong= link_to(@milestone.name, @milestone_url)
+ - milestone_link = link_to(@milestone.name, @milestone_url)
+ = s_('Notify|Milestone changed to %{milestone}').html_safe % { milestone: content_tag(:strong, milestone_link).html_safe }
- if date_range = milestone_date_range(@milestone)
= "(#{date_range})"
diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml
index 749584a7044..bd98003a804 100644
--- a/app/views/notify/closed_merge_request_email.html.haml
+++ b/app/views/notify/closed_merge_request_email.html.haml
@@ -1,3 +1,4 @@
%p
- Merge request #{merge_request_reference_link(@merge_request)}
- was closed by #{sanitize_name(@updated_by.name)}
+ - mr_link = merge_request_reference_link(@merge_request)
+ - closed_by = sanitize_name(@updated_by.name)
+ = s_('Notify|Merge request %{mr_link} was closed by %{closed_by}').html_safe % { mr_link: mr_link, closed_by: closed_by }
diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml
index eeef66d353d..98d3daf2107 100644
--- a/app/views/notify/member_access_denied_email.html.haml
+++ b/app/views/notify/member_access_denied_email.html.haml
@@ -1,12 +1,7 @@
%tr
%td.text-content
%p
- Your request to join the
-
- - if @source_hidden
- #{content_tag :span, 'Hidden', class: :highlight}
- - else
- #{link_to member_source.human_name, member_source.web_url, class: :highlight}
-
- #{member_source.model_name.singular} has been #{content_tag :span, 'denied', class: :highlight}.
+ - target_to_join = @source_hidden ? content_tag(:span, _('Hidden'), class: :highlight) : link_to(member_source.human_name, member_source.web_url, class: :highlight)
+ - denied_tag = content_tag :span, _('denied'), class: :highlight
+ = s_('Notify|Your request to join the %{target_to_join} %{target_type} has been %{denied_tag}.').html_safe % { target_to_join: target_to_join, target_type: member_source.model_name.singular, denied_tag: denied_tag }
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
index 550d386c843..f6b517d6e34 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
@@ -148,6 +148,4 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" }
%div
- - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
- - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
- = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+ = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html)
diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml
index ae58ccd3995..0b8fbe14228 100644
--- a/app/views/notify/unapproved_merge_request_email.html.haml
+++ b/app/views/notify/unapproved_merge_request_email.html.haml
@@ -151,6 +151,4 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", src: image_url('mailers/gitlab_logo_black_text.png'), style: "display:block;margin:0 auto 1em;", width: "90" }/
%div
- - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;")
- - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;")
- = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+ = notification_reason_text(show_manage_notifications_link: true, show_help_link: true, format: :html)
diff --git a/app/views/notify/user_auto_banned_email.html.haml b/app/views/notify/user_auto_banned_email.html.haml
index d88c06526eb..8c33cd7299d 100644
--- a/app/views/notify/user_auto_banned_email.html.haml
+++ b/app/views/notify/user_auto_banned_email.html.haml
@@ -2,7 +2,7 @@
- link_end = '</a>'.html_safe
= email_default_heading(_("We've detected some unusual activity"))
%p
- = _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes }
+ = _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope }
%p
= _('If this is a mistake, you can %{link_start}unban them%{link_end}.').html_safe % { link_start: link_start % { url: admin_users_url(filter: 'banned') }, link_end: link_end }
%p
diff --git a/app/views/notify/user_auto_banned_email.text.erb b/app/views/notify/user_auto_banned_email.text.erb
index 0469ee9788c..336973c2e42 100644
--- a/app/views/notify/user_auto_banned_email.text.erb
+++ b/app/views/notify/user_auto_banned_email.text.erb
@@ -1,6 +1,6 @@
<%= _("We've detected some unusual activity") %>
-<%= _('We want to let you know %{username} has been banned from your GitLab instance due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes } %>
+<%= _('We want to let you know %{username} has been banned from %{scope} due to them downloading more than %{max_project_downloads} project repositories within %{within_minutes} minutes.') % { username: sanitize_name(@user.name), max_project_downloads: @max_project_downloads, within_minutes: @within_minutes, scope: @ban_scope } %>
<%= _('If this is a mistake, you can unban them: %{url}.') % { url: admin_users_url(filter: 'banned') } %>
diff --git a/app/views/notify/verification_instructions_email.html.haml b/app/views/notify/verification_instructions_email.html.haml
new file mode 100644
index 00000000000..63d8d1b2461
--- /dev/null
+++ b/app/views/notify/verification_instructions_email.html.haml
@@ -0,0 +1,12 @@
+%div{ style: 'text-align:center;color:#1F1F1F;line-height:1.25em;max-width:400px;margin:0 auto;' }
+ %h3
+ = s_('IdentityVerification|Help us protect your account')
+ %p{ style: 'font-size:0.9em' }
+ = s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.')
+ %div{ style: 'margin:26px 0;width:207px;height:53px;background-color:#F0F0F0;line-height:53px;font-weight:700;font-size:1.5em;color:#303030;' }
+ = @token
+ %p{ style: 'font-size:0.75em' }
+ = s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend %{password_link_start}changing your password%{link_end} and %{two_fa_link_start}setting up Two-Factor Authentication%{link_end} to keep your account safe. Your verification code expires after %{expires_in_minutes} minutes.').html_safe % { link_end: link_end,
+ password_link_start: link_start(@password_link),
+ two_fa_link_start: link_start(@two_fa_link),
+ expires_in_minutes: @expires_in_minutes }
diff --git a/app/views/notify/verification_instructions_email.text.erb b/app/views/notify/verification_instructions_email.text.erb
new file mode 100644
index 00000000000..df507b5db71
--- /dev/null
+++ b/app/views/notify/verification_instructions_email.text.erb
@@ -0,0 +1,8 @@
+<%= s_('IdentityVerification|Help us protect your account') %>
+
+<%= s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.') %>
+
+<%= @token %>
+
+<%= s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend changing your password (%{password_link}) and setting up Two-Factor Authentication (%{two_fa_link}) to keep your account safe.') % { password_link: @password_link, two_fa_link: @two_fa_link } %>
+<%= s_('IdentityVerification|Your verification code expires after %{expires_in_minutes} minutes.') % { expires_in_minutes: @expires_in_minutes } %>
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
index 457d6690a78..0ca9acba2de 100644
--- a/app/views/profiles/_email_settings.html.haml
+++ b/app/views/profiles/_email_settings.html.haml
@@ -22,12 +22,12 @@
{ include_blank: s_("Profiles|Do not show on profile") },
{ class: 'gl-form-select custom-select', disabled: email_change_disabled }
%small.form-text.text-gl-muted
- = s_("Profiles|This email will be displayed on your public profile")
+ = s_("Profiles|This email will be displayed on your public profile.")
.form-group.gl-form-group
- commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank')
- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
- - commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
+ - commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more.%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
= form.label :commit_email, s_('Profiles|Commit email')
.gl-md-form-input-lg
= form.select :commit_email,
diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml
index 5af4fe24d62..d798eab7635 100644
--- a/app/views/profiles/_name.html.haml
+++ b/app/views/profiles/_name.html.haml
@@ -2,8 +2,8 @@
- if user.read_only_attribute?(:name)
= form.text_field :name, class: 'gl-form-input form-control', required: true, readonly: true
%small.form-text.text-gl-muted
- = s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
+ = s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) }
- else
= form.text_field :name, class: 'gl-form-input form-control', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead")
%small.form-text.text-gl-muted
- = s_("Profiles|Enter your name, so people you know can recognize you")
+ = s_("Profiles|Enter your name, so people you know can recognize you.")
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 745d3c62c5d..cdd5a9ae7a1 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -17,9 +17,9 @@
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
- = s_('Profiles|Two-Factor Authentication')
+ = s_('Profiles|Two-factor authentication')
%p
- = s_("Profiles|Increase your account's security by enabling Two-Factor Authentication (2FA)")
+ = s_("Profiles|Increase your account's security by enabling two-factor authentication (2FA).")
.col-lg-8
%p
#{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')}
@@ -35,9 +35,9 @@
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
- = s_('Profiles|Social sign-in')
+ = s_('Profiles|Service sign-in')
%p
- = s_('Profiles|Activate signin with one of the following services')
+ = s_('Profiles|Connect a service for sign-in.')
.col-lg-8
= render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
.col-lg-12
@@ -68,7 +68,7 @@
= render 'users/deletion_guidance', user: current_user
-# Delete button here
- %button#delete-account-button.gl-button.btn.btn-danger.disabled{ data: { qa_selector: 'delete_account_button' } }
+ = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do
= s_('Profiles|Delete account')
#delete-account-modal{ data: { action_url: user_registration_path,
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index 9804a3b7735..b3784faed28 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -1,6 +1,6 @@
%div
= form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f|
- = form_errors(@gpg_key)
+ = form_errors(@gpg_key, pajamas_alert: true)
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 5d3e0720176..46ae602359f 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -15,7 +15,7 @@
- else
= _('Change your password or recover your current one')
= form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f|
- = form_errors(@user)
+ = form_errors(@user, pajamas_alert: true)
- unless @user.password_automatically_set?
.form-group
@@ -25,7 +25,8 @@
= _('You must provide your current password in order to change it.')
.form-group
= f.label :new_password, _('New password'), class: 'label-bold'
- = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' }
+ = render_if_exists 'shared/password_requirements_list'
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index a2180dc68a6..5bcc92dcdfd 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -9,7 +9,7 @@
%br
= _('After a successful password update you will be redirected to login screen.')
- = form_errors(@user)
+ = form_errors(@user, pajamas_alert: true)
- unless @user.password_automatically_set?
.form-group.row
@@ -21,7 +21,8 @@
.col-sm-2.col-form-label
= f.label :new_password, _('New password')
.col-sm-10
- = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' }
+ = f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation', data: { qa_selector: 'new_password_field' }
+ = render_if_exists 'shared/password_requirements_list'
.form-group.row
.col-sm-2.col-form-label
= f.label :password_confirmation, _('Password confirmation')
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index d1f1ff892d5..dda1640968e 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -5,9 +5,7 @@
- availability = availability_values
- custom_emoji = show_status_emoji?(@user.status)
-= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
- = form_errors(@user)
-
+= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.row.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
@@ -46,20 +44,18 @@
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
- - emoji_button = button_tag type: :button,
- class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn gl-button btn-default has-tooltip',
- title: s_("Profiles|Add status emoji") do
+ - emoji_button = render Pajamas::ButtonComponent.new(button_options: { title: s_("Profiles|Add status emoji"),
+ class: 'js-toggle-emoji-menu emoji-menu-toggle-button has-tooltip' } ) do
- if custom_emoji
= emoji_icon(@user.status.emoji, class: 'gl-mr-0!')
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
= sprite_icon('smiley', css_class: 'award-control-icon-positive')
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- - reset_message_button = button_tag type: :button,
- id: 'js-clear-user-status-button',
- class: 'clear-user-status btn gl-button btn-default has-tooltip',
- title: s_("Profiles|Clear status") do
- = sprite_icon("close")
+ - reset_message_button = render Pajamas::ButtonComponent.new(icon: 'close',
+ button_options: { id: 'js-clear-user-status-button',
+ class: 'has-tooltip',
+ title: s_("Profiles|Clear status") } )
= status_form.hidden_field :emoji, id: 'js-status-emoji-field'
.form-group.gl-form-group
@@ -76,7 +72,7 @@
.form-group.gl-form-group
= status_form.gitlab_ui_checkbox_component :availability,
s_("Profiles|Busy"),
- help_text: s_('Profiles|An indicator appears next to your name and avatar'),
+ help_text: s_('Profiles|An indicator appears next to your name and avatar.'),
checkbox_options: { data: { testid: "user-availability-checkbox" } },
checked_value: availability["busy"],
unchecked_value: availability["not_set"]
@@ -85,7 +81,7 @@
.row.user-time-preferences.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0= s_("Profiles|Time settings")
- %p= s_("Profiles|Set your local time zone")
+ %p= s_("Profiles|Set your local time zone.")
.col-lg-8
%h5= _("Time zone")
= dropdown_tag(_("Select a time zone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg gl-w-full!', title: _("Select a time zone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
@@ -97,7 +93,7 @@
%h4.gl-mt-0
= s_("Profiles|Main settings")
%p
- = s_("Profiles|This information will appear on your profile")
+ = s_("Profiles|This information will appear on your profile.")
- if current_user.ldap_user?
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
@@ -111,12 +107,12 @@
= f.label :pronouns, s_('Profiles|Pronouns')
= f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
%small.form-text.text-gl-muted
- = s_("Profiles|Enter your pronouns to let people know how to refer to you")
+ = s_("Profiles|Enter your pronouns to let people know how to refer to you.")
.form-group.gl-form-group
= f.label :pronunciation, s_('Profiles|Pronunciation')
= f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
%small.form-text.text-gl-muted
- = s_("Profiles|Enter how your name is pronounced to help people address you correctly")
+ = s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
= render_if_exists 'profiles/extra_settings', form: f
= render_if_exists 'profiles/email_settings', form: f
.form-group.gl-form-group
@@ -148,17 +144,17 @@
= f.label :organization, s_('Profiles|Organization')
= f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
%small.form-text.text-gl-muted
- = s_("Profiles|Who you represent or work for")
+ = s_("Profiles|Who you represent or work for.")
.form-group.gl-form-group
= f.label :bio, s_('Profiles|Bio')
= f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
%small.form-text.text-gl-muted
- = s_("Profiles|Tell us about yourself in fewer than 250 characters")
+ = s_("Profiles|Tell us about yourself in fewer than 250 characters.")
%hr
%fieldset.form-group.gl-form-group
%legend.col-form-label.col-form-label
= _('Private profile')
- - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile")
+ - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.")
- private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
= f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe }
%fieldset.form-group.gl-form-group
@@ -166,7 +162,7 @@
= s_("Profiles|Private contributions")
= f.gitlab_ui_checkbox_component :include_private_contributions,
s_('Profiles|Include private contributions on my profile'),
- help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
+ help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
%hr
= f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn'
= link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 845baae3bb2..6304d42896d 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -78,7 +78,7 @@
.col-lg-8
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?
- = form_errors(registration)
+ = form_errors(registration, pajamas_alert: true)
- if webauthn_enabled
= render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
- else
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index 05166395067..402affc7b0e 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -11,4 +11,4 @@
.content_list.project-activity{ :"data-href" => activity_project_path(@project) }
.loading
- = gl_loading_icon(size: 'md')
+ = render Pajamas::SpinnerComponent.new(size: :md)
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index bea5d548e03..319c6333e77 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -16,7 +16,7 @@
#js-code-owners
- if is_project_overview
- .project-buttons.gl-mb-3.js-show-on-project-root
+ .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
#js-tree-list{ data: vue_file_list_data(project, ref) }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index cb15858a935..42cdc1d6989 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -24,21 +24,21 @@
- if bitbucket_import_enabled?
%div
- = 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?}",
+ = link_to status_import_bitbucket_path(namespace_id: namespace_id), 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
- 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
+ = link_to status_import_bitbucket_server_path(namespace_id: namespace_id), 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
.gl-button-icon
= sprite_icon('bitbucket')
Bitbucket Server
%div
- if gitlab_import_enabled?
%div
- = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}",
+ = link_to status_import_gitlab_path(namespace_id: namespace_id), class: "gl-button btn-default btn import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}",
data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } do
.gl-button-icon
= sprite_icon('tanuki')
@@ -46,7 +46,7 @@
- if fogbugz_import_enabled?
%div
- = link_to new_import_fogbugz_path, class: 'gl-button btn-default btn import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } do
+ = link_to new_import_fogbugz_path(namespace_id: namespace_id), class: 'gl-button btn-default btn import_fogbugz js-import-project-btn', data: { platform: 'fogbugz', **tracking_attrs_data(track_label, 'click_button', 'fogbugz') } do
.gl-button-icon
= sprite_icon('bug')
FogBugz
diff --git a/app/views/projects/_merge_request_squash_options_settings.html.haml b/app/views/projects/_merge_request_squash_options_settings.html.haml
index 4b428363646..372c0723600 100644
--- a/app/views/projects/_merge_request_squash_options_settings.html.haml
+++ b/app/views/projects/_merge_request_squash_options_settings.html.haml
@@ -5,7 +5,7 @@
%b= s_('ProjectSettings|Squash commits when merging')
%p.text-secondary
= s_('ProjectSettings|Set the default behavior of this option in merge requests. Changes to this are also applied to existing merge requests.')
- = link_to "What is squashing?", help_page_path('user/project/merge_requests/squash_and_merge.md'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('ProjectSettings|What is squashing?'), help_page_path('user/project/merge_requests/squash_and_merge.md'), target: '_blank', rel: 'noopener noreferrer'
= settings.gitlab_ui_radio_component :squash_option,
:never,
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 2cbb9758703..992b46c1f7b 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -35,7 +35,7 @@
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
- - project_tip = s_('ProjectsNew|Want to house several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' }
+ - project_tip = s_('ProjectsNew|Want to organize several dependent projects under the same namespace? %{link_start}Create a group.%{link_end}') % { link_start: link_start_group_path, link_end: '</a>' }
= project_tip.html_safe
= render Pajamas::AlertComponent.new(alert_options: { class: "gl-mb-4 gl-display-none js-user-readme-repo" },
dismissible: false,
@@ -52,10 +52,11 @@
- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? || !Gitlab.com?
.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'
-= render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'}
+.form-group.gl-form-group
+ = f.label :visibility_level, class: 'label-bold' do
+ = s_('ProjectsNew|Visibility Level')
+ = link_to sprite_icon('question-o'), help_page_path('user/public_access'), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer'
+ = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false, data: { qa_selector: 'visibility_radios'}
- if !hide_init_with_readme
= f.label :project_configuration, class: 'label-bold' do
@@ -77,5 +78,7 @@
= 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' }
+-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675
+= render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label
= f.submit _('Create project'), class: "btn gl-button btn-confirm js-create-project-button", data: { qa_selector: 'project_create_button', 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/_remove.html.haml b/app/views/projects/_remove.html.haml
index d0dfbb89ca7..ed238dab4ff 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -7,7 +7,7 @@
%h4.danger-title= _('Delete project')
%p
%strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.')
- = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'remove-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(forks_count), stars_count: number_with_delimiter(project.star_count) } }
diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml
index bb51aa86170..bfc1e77118a 100644
--- a/app/views/projects/_remove_fork.html.haml
+++ b/app/views/projects/_remove_fork.html.haml
@@ -8,5 +8,5 @@
= form_for @project, url: remove_fork_project_path(@project), method: :delete, html: { id: remove_form_id } do |f|
%p
%strong= _('Once removed, the fork relationship cannot be restored. This project will no longer be able to receive or send merge requests to the source project or other forks.')
- = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'remove-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
.js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) }
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 13ff8abe499..4a21cb32c20 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -2,7 +2,7 @@
- project_buttons = local_assigns.fetch(:project_buttons, false)
- return unless anchors.any?
-%ul.nav
+%ul.nav.gl-gap-3
- anchors.each do |anchor|
%li.nav-item
= link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
index 9e6648c71fc..393b199fb05 100644
--- a/app/views/projects/_transfer.html.haml
+++ b/app/views/projects/_transfer.html.haml
@@ -7,7 +7,7 @@
%h4.danger-title= _('Transfer project')
= form_for @project, url: transfer_project_path(@project), method: :put, html: { class: 'js-project-transfer-form', id: form_id } do |f|
.form-group
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transferring-an-existing-project-into-another-namespace') }
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'transfer-a-project-to-another-namespace') }
%p= _("Transfer your project into another namespace. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
%p= _('When you transfer your project to a group, you can easily manage multiple projects, view usage quotas for storage, pipeline minutes, and users, and start a trial or upgrade to a paid tier.')
%p
diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml
index e69c4f51ec4..6a4760c3954 100644
--- a/app/views/projects/activity.html.haml
+++ b/app/views/projects/activity.html.haml
@@ -1,5 +1,4 @@
- page_title _("Activity")
-= render_if_exists 'shared/minute_limit_banner', namespace: @project
= render 'projects/last_push'
= render 'projects/activity'
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index bc1e62a8980..b44c773adff 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -20,7 +20,7 @@
%span.legend-box.legend-box-9
%span.right-label Older
- .table-responsive.file-content.blame.code{ class: user_color_scheme }
+ .table-responsive.file-content.blame.code{ class: user_color_scheme, data: { qa_selector: 'blame_file_content' } }
%table
- current_line = @blame.first_line
- @blame.groups.each do |blame_group|
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 2c3aade1068..4139be053f8 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -2,6 +2,8 @@
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
- expanded = params[:expanded].present?
+- if blob.rich_viewer
+ - add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) }
.info-well.d-none.d-sm-block
.well-segment
@@ -14,7 +16,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) && !expanded
+ - if !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/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
deleted file mode 100644
index 7511de76223..00000000000
--- a/app/views/projects/blob/_remove.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-#modal-remove-blob.modal
- .modal-dialog
- .modal-content
- .modal-header
- %h1.page-title.gl-font-size-h-display Delete #{@blob.name}
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": "true" } &times;
-
- .modal-body
- = form_tag project_blob_path(@project, @id), method: :delete, class: 'js-delete-blob-form js-quick-submit js-requires-input' do
- = render 'shared/new_commit_form', placeholder: "Delete #{@blob.name}"
-
- .form-group.row
- .offset-sm-2.col-sm-10
- = button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file'
- = link_to _('Cancel'), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index a91c0d63b00..16ecc1cc5a0 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -11,8 +11,5 @@
#tree-holder.tree-holder
= render 'blob', blob: @blob
- - if can_modify_blob?(@blob)
- = render 'projects/blob/remove'
-
= render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration?
= render 'shared/web_ide_path'
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
index 8bf0339fc3c..7206a969fb7 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -3,7 +3,7 @@
= gl_loading_icon(size: "md", css_class: "gl-my-4")
.text-center.gl-mt-3.gl-mb-3.stl-controls
.btn-group
- %button.gl-button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
- Wireframe
- %button.gl-button.btn.btn-default.btn-sm.selected.js-material-changer{ data: { type: 'default' } }
- Solid
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-material-changer', data: { material: 'wireframe' } }) do
+ = _('Wireframe')
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-material-changer selected', data: { material: 'default' } }) do
+ = _('Solid')
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index e4ec7a43d61..1477ae66d80 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,9 +1,9 @@
- merged = local_assigns.fetch(:merged, false)
- commit = @repository.commit(branch.dereferenced_target)
- merge_project = merge_request_source_project_for_project(@project)
-%li{ class: "branch-item js-branch-item js-branch-#{branch.name}", data: { name: branch.name } }
+%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name}", data: { name: branch.name } }
.branch-info
- .branch-title
+ .gl-display-flex.gl-align-items-center
= sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0')
= link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do
= branch.name
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index c33b9b538f3..bd096ed74f5 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -132,4 +132,4 @@
= sprite_icon('play', css_class: 'gl-icon')
- elsif job.retryable?
= link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'gl-button btn btn-default btn-icon' do
- = sprite_icon('repeat', css_class: 'gl-icon')
+ = sprite_icon('retry', css_class: 'gl-icon')
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index 81a77489075..e04c4ebfda2 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -1,3 +1,12 @@
+- can_push_code = can?(current_user, :push_code, @project)
+
+- if !can_push_code && selected_branch.present?
+ - branch_collaboration = @project.branch_allows_collaboration?(current_user, selected_branch)
+ - existing_branch = ERB::Util.html_escape(selected_branch)
+- else
+ - branch_collaboration = false
+ - existing_branch = ''
+
- case type.to_s
- when 'revert'
- revert_merge_request = _('Revert this merge request')
@@ -7,9 +16,9 @@
.js-revert-commit-modal{ data: { title: title,
endpoint: revert_namespace_project_commit_path(commit, namespace_id: @project.namespace.full_path, project_id: @project),
branch: @project.default_branch,
- push_code: can?(current_user, :push_code, @project).to_s,
- branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s,
- existing_branch: ERB::Util.html_escape(selected_branch),
+ push_code: can_push_code.to_s,
+ branch_collaboration: branch_collaboration.to_s,
+ existing_branch: existing_branch,
branches_endpoint: project_branches_path(@project) } }
- when 'cherry-pick'
@@ -20,8 +29,8 @@
branch: @project.default_branch,
target_project_id: @project.id,
target_project_name: @project.full_path,
- push_code: can?(current_user, :push_code, @project).to_s,
- branch_collaboration: @project.branch_allows_collaboration?(current_user, selected_branch).to_s,
- existing_branch: ERB::Util.html_escape(selected_branch),
+ push_code: can_push_code.to_s,
+ branch_collaboration: branch_collaboration.to_s,
+ existing_branch: existing_branch,
branches_endpoint: refs_project_path(@project),
projects: cherry_pick_projects_data(@project).to_json } }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 4442f62b221..71485e203db 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -24,7 +24,7 @@
.avatar-cell.d-none.d-sm-block
= author_avatar(commit, size: 40, has_tooltip: false)
- .commit-detail.flex-list
+ .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-flex-start.gl-flex-grow-1.gl-min-w-0
.commit-content{ data: { qa_selector: 'commit_content' } }
- if view_details && merge_request
= link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)]
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 23f9afe8352..780bb3404cc 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -3,9 +3,9 @@
.diff-content
- if diff_file.has_renderable?
- %div{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toHide' } }
+ .hidden{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'rawViewer' } }
= render 'projects/diffs/viewer', viewer: diff_file.viewer
- %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'toShow' } }
+ %div{ id: "#rendered-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'renderedViewer' } }
= render 'projects/diffs/viewer', viewer: diff_file.rendered.viewer
- else
= render 'projects/diffs/viewer', viewer: diff_file.viewer
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 41d6b7086c1..a7dd69a9607 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -5,8 +5,6 @@
- expanded = expanded_by_default?
- reduce_visibility_form_id = 'reduce-visibility-form'
-= render_if_exists 'shared/minute_limit_banner', namespace: @project
-
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
@@ -90,7 +88,7 @@
= render 'projects/errors'
= form_for @project do |f|
.form-group
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'renaming-a-repository') }
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') }
%p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
%ul
%li= _("Be careful. Renaming a project's repository can have unintended side effects.")
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index ce6d021ce2f..6f2e135f9d3 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -21,10 +21,10 @@
= _('You can get started by cloning the repository or start adding files to it with one of the following options.')
.project-buttons.qa-quick-actions
- .project-clone-holder.d-block.d-md-none.mt-2.mr-2
+ .project-clone-holder.d-block.d-md-none.gl-mt-3.gl-mr-3
= render "shared/mobile_clone_panel"
- .project-clone-holder.d-none.d-md-inline-block.mb-2.mr-2.float-left
+ .project-clone-holder.d-none.d-md-inline-block.gl-mb-3.gl-mr-3.float-left
= render "projects/buttons/clone"
= render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index af5ad06d30e..2e024b8ffc4 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -1,6 +1,6 @@
- page_title _("Find File"), @ref
-.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @id || @commit.id)) }
+.file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) }
.nav-block
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'find_file', path: @path
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 7243852e1f5..36347776ec9 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -4,7 +4,7 @@
endpoint: new_project_fork_path(@project, format: :json),
new_group_path: new_group_path,
project_full_path: project_path(@project),
- visibility_help_path: help_page_path("public_access/public_access"),
+ visibility_help_path: help_page_path("user/public_access"),
project_id: @project.id,
project_name: @project.name,
project_path: @project.path,
diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml
new file mode 100644
index 00000000000..ec977898f47
--- /dev/null
+++ b/app/views/projects/google_cloud/configuration/index.html.haml
@@ -0,0 +1,7 @@
+- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- breadcrumb_title s_('CloudSeed|Configuration')
+- page_title s_('CloudSeed|Configuration')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud-configuration{ data: @js_data }
diff --git a/app/views/projects/google_cloud/databases/index.html.haml b/app/views/projects/google_cloud/databases/index.html.haml
new file mode 100644
index 00000000000..ad732317d8d
--- /dev/null
+++ b/app/views/projects/google_cloud/databases/index.html.haml
@@ -0,0 +1,7 @@
+- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- breadcrumb_title s_('CloudSeed|Databases')
+- page_title s_('CloudSeed|Databases')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud-databases{ data: @js_data }
diff --git a/app/views/projects/google_cloud/deployments/index.html.haml b/app/views/projects/google_cloud/deployments/index.html.haml
new file mode 100644
index 00000000000..b140159a7f5
--- /dev/null
+++ b/app/views/projects/google_cloud/deployments/index.html.haml
@@ -0,0 +1,7 @@
+- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
+- breadcrumb_title s_('CloudSeed|Deployments')
+- page_title s_('CloudSeed|Deployments')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-google-cloud-deployments{ data: @js_data }
diff --git a/app/views/projects/google_cloud/errors/gcp_error.html.haml b/app/views/projects/google_cloud/errors/gcp_error.html.haml
deleted file mode 100644
index 69e481501d5..00000000000
--- a/app/views/projects/google_cloud/errors/gcp_error.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- breadcrumb_title _('Google Cloud')
-- page_title _('Google Cloud')
-
-- @content_class = "limit-container-width" unless fluid_layout
-
-#js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml
deleted file mode 100644
index 69e481501d5..00000000000
--- a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- breadcrumb_title _('Google Cloud')
-- page_title _('Google Cloud')
-
-- @content_class = "limit-container-width" unless fluid_layout
-
-#js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml
index 3a6f8ca059d..d7cabaa029b 100644
--- a/app/views/projects/google_cloud/gcp_regions/index.html.haml
+++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml
@@ -1,8 +1,8 @@
- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
-- breadcrumb_title _('Regions')
-- page_title _('Regions')
+- breadcrumb_title _('CloudSeed|Regions')
+- page_title s_('CloudSeed|Regions')
- @content_class = "limit-container-width" unless fluid_layout
= form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do
- #js-google-cloud{ data: @js_data }
+ #js-google-cloud-gcp-regions{ data: @js_data }
diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml
deleted file mode 100644
index 69e481501d5..00000000000
--- a/app/views/projects/google_cloud/index.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- breadcrumb_title _('Google Cloud')
-- page_title _('Google Cloud')
-
-- @content_class = "limit-container-width" unless fluid_layout
-
-#js-google-cloud{ data: @js_data }
diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml
index 9b82bc0acb5..6191de577fe 100644
--- a/app/views/projects/google_cloud/service_accounts/index.html.haml
+++ b/app/views/projects/google_cloud/service_accounts/index.html.haml
@@ -1,8 +1,8 @@
- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
-- breadcrumb_title _('Service Account')
-- page_title _('Service Account')
+- breadcrumb_title s_('CloudSeed|Service Account')
+- page_title s_('CloudSeed|Service Account')
- @content_class = "limit-container-width" unless fluid_layout
= form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do
- #js-google-cloud{ data: @js_data }
+ #js-google-cloud-service-accounts{ data: @js_data }
diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml
index 270cbf3facd..0fce3b7f8aa 100644
--- a/app/views/projects/harbor/repositories/index.html.haml
+++ b/app/views/projects/harbor/repositories/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Harbor Registry")
- @content_class = "limit-container-width" unless fluid_layout
-#js-harbor-registry-list-project{ data: { endpoint: project_harbor_registry_index_path(@project),
+#js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index 8096bc6cead..9fe541c5912 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -3,9 +3,10 @@
.save-project-loader
.center
- %h2
+ %h2.gl--flex-center.gl-flex-direction-column.gl-sm-flex-direction-row
= gl_loading_icon(inline: true)
- = import_in_progress_title
+ %span.gl-ml-3
+ = import_in_progress_title
- if !has_ci_cd_only_params? && @project.external_import?
%p.monospace git clone --bare #{@project.safe_import_url}
%p
diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml
index 4d4607e8e36..5043f94bd5c 100644
--- a/app/views/projects/incidents/show.html.haml
+++ b/app/views/projects/incidents/show.html.haml
@@ -2,5 +2,6 @@
- add_to_breadcrumbs _("Incidents"), project_incidents_path(@project)
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents")
+- add_page_specific_style 'page_bundles/issues_show'
= render 'projects/issuable/show', issuable: @issue
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index a904b53515c..16b795ee3c9 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,4 +1,4 @@
-- add_page_startup_api_call Feature.enabled?(:paginated_issue_discussions, @project) ? discussions_path(@issue, per_page: 20) : discussions_path(@issue)
+- add_page_startup_api_call discussions_path(@issue, per_page: 20)
- @gfm_form = true
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 4c96875ce42..4d4645c7087 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -65,6 +65,9 @@
= render 'shared/issuable_meta_data', issuable: issue
- .float-right.issuable-updated-at.d-none.d-sm-inline-block
+ .float-right.issuable-timestamp.d-none.d-sm-inline-block
%span
- = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') }
+ - if issue.closed? && issue.closed_at
+ = _('closed %{timeago}').html_safe % { timeago: time_ago_with_tooltip(issue.closed_at, placement: 'bottom') }
+ - else
+ = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom') }
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index 55a8eb720b6..5d478784350 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,2 +1,2 @@
- if Feature.enabled?(:work_items_hierarchy, @project)
- .js-work-item-links-root{ data: { issuable_id: @issue.id } }
+ .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 3572d1d6556..06c422fc4d6 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -3,6 +3,7 @@
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
- add_page_specific_style 'page_bundles/issues_show'
+- add_page_specific_style 'page_bundles/work_items'
= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index dfea4db4d07..d39d292fb53 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -2,12 +2,4 @@
- add_page_specific_style 'page_bundles/ci_status'
- admin = local_assigns.fetch(:admin, false)
-- if Feature.enabled?(:jobs_table_vue, @project)
- #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
-- else
- .top-area
- - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
- = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
-
- .content-list.builds-content-list
- = render "table", builds: @builds, project: @project
+#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index fedc1291a92..5f249f693ff 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -7,7 +7,4 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
-- if @build.is_a? ::Ci::Build
- #js-job-page{ data: jobs_data }
-- else
- #js-bridge-page{ data: bridge_data(@build, @project) }
+#js-job-page{ data: jobs_data }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index dd63e854a36..647464b31f8 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -4,7 +4,6 @@
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present?
-= render_if_exists 'shared/minute_limit_banner', namespace: @project
- if labels_or_filters
#js-promote-label-modal
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
diff --git a/app/views/projects/logs/empty_logs.html.haml b/app/views/projects/logs/empty_logs.html.haml
deleted file mode 100644
index 48403f5e55e..00000000000
--- a/app/views/projects/logs/empty_logs.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- page_title _('Logs')
-
-.row.empty-state
- .col-sm-12
- .svg-content
- = image_tag 'illustrations/operations_log_pods_empty.svg'
- .col-12
- .text-content
- %h4.text-center
- = s_('Environments|No deployed environments')
- %p.state-description.text-center
- = s_('Logs|To see the logs, deploy your code to an environment.')
- .text-center
- = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/logs/index.html.haml b/app/views/projects/logs/index.html.haml
deleted file mode 100644
index 1f74eb52fd9..00000000000
--- a/app/views/projects/logs/index.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-#environment-logs{ data: environment_logs_data(@project, @environment) }
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index a0810cfe37d..6b367c735c3 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -26,7 +26,7 @@
.gl-new-dropdown-item-text-wrapper
= _('Close')
= display_issuable_type
- - elsif !@merge_request.source_project_missing?
+ - elsif !@merge_request.source_project_missing? && @merge_request.closed?
%li.gl-new-dropdown-item
= link_to reopen_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do
.gl-new-dropdown-item-text-wrapper
@@ -34,7 +34,7 @@
= display_issuable_type
- unless current_controller?('conflicts')
- - if current_user && moved_mr_sidebar_enabled?
+ - if current_user && moved_mr_sidebar_enabled? && !@merge_request.merged?
%li.gl-new-dropdown-divider
%hr.dropdown-divider
%li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point
diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml
index 14ddaf8d2b7..7cadc37b0fd 100644
--- a/app/views/projects/merge_requests/_commits.html.haml
+++ b/app/views/projects/merge_requests/_commits.html.haml
@@ -8,7 +8,7 @@
- if can_update_merge_request
%p
= _('Push commits to the source branch or add previously merged commits to review them.')
- %button.btn.gl-button.btn-confirm.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } }
+ = render Pajamas::ButtonComponent.new(variant: 'confirm', button_options: { class: 'add-review-item-modal-trigger', data: { commits_empty: 'true', context_commits_empty: 'true' } }) do
= _('Add previously merged commits')
- else
%ol#commits-list.list-unstyled
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 4f4acb6103f..893f03157db 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -3,7 +3,7 @@
- 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)
- hide_gutter_toggle = local_assigns.fetch(:hide_gutter_toggle, false)
-- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle]
+- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle, fluid_layout]
= cache(cache_key, expires_in: 1.day) do
- if @merge_request.closed_or_merged_without_fork?
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index 8cd0d2f9e32..cee8d2e92aa 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -67,5 +67,5 @@
%ul.list-unstyled.mr_target_commit
- if @merge_request.errors.any?
- = form_errors(@merge_request)
+ = form_errors(@merge_request, pajamas_alert: true)
= f.submit 'Compare branches and continue', class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 99b84339058..4ef557fbd8f 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -99,6 +99,9 @@
#js-review-bar
+- if Feature.enabled?(:mr_experience_survey, @project)
+ #js-mr-experience-survey
+
- if current_user&.mr_attention_requests_enabled?
#js-need-attention-sidebar-onboarding
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 5f2057df4aa..9b0508d8cb5 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,6 +1,6 @@
= form_for [@project, @milestone],
html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
- = form_errors(@milestone)
+ = form_errors(@milestone, pajamas_alert: true)
.form-group.row
.col-form-label.col-sm-2
= f.label :title, _('Title')
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 339042eb703..a90d5224d04 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -17,7 +17,7 @@
= gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
.panel.panel-default
.panel-body
- %div= form_errors(@project)
+ %div= form_errors(@project, pajamas_alert: true)
.form-group.has-feedback
= label_tag :url, _('Git repository URL'), class: 'label-light'
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index e3fe098c807..d367f383e5a 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -11,7 +11,7 @@
= _('Fingerprints')
.fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } }
- mirror.ssh_known_hosts_fingerprints.each do |fp|
- %code= fp.fingerprint
+ %code= fp.fingerprint_sha256 || fp.fingerprint
- if verified_at
.form-text.text-muted.js-fingerprint-verification
= sprite_icon('check', css_class: 'gl-text-green-500')
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 511adf37b39..07c38d9845c 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -12,7 +12,7 @@
.row{ 'v-cloak': true }
#blank-project-pane.tab-pane.active
- = form_for @project, html: { class: 'new_project gl-mt-3' } do |f|
+ = gitlab_ui_form_for @project, html: { class: 'new_project gl-mt-3' } do |f|
= render 'new_project_fields', f: f, project_name_id: "blank-project-name"
#create-from-template-pane.tab-pane
@@ -22,7 +22,7 @@
- contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing'
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url }
= _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- = form_for @project, html: { class: 'new_project' } do |f|
+ = gitlab_ui_form_for @project, html: { class: 'new_project' } do |f|
.project-template
.form-group
%div
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 54435f675a7..07e299d71ea 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -6,9 +6,9 @@
= preserve(markdown(commit.description, pipeline: :single_line))
.info-well
- .well-segment.pipeline-info
- .icon-container.gl-vertical-align-text-bottom
- = sprite_icon('clock')
+ .well-segment.pipeline-info{ class: "gl-align-items-baseline!" }
+ .icon-container
+ = sprite_icon('clock', css_class: 'gl-top-0!')
= pluralize @pipeline.total_size, "job"
= @pipeline.ref_text
- if @pipeline.duration
@@ -20,7 +20,7 @@
- if has_pipeline_badges?(@pipeline)
.well-segment.qa-pipeline-badges
.icon-container
- = sprite_icon('flag')
+ = sprite_icon('flag', css_class: 'gl-top-0!')
- if @pipeline.child?
- text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe
= gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") }
@@ -44,13 +44,13 @@
.well-segment.branch-info
.icon-container.commit-icon
- = custom_icon("icon_commit")
+ = sprite_icon('commit', css_class: 'gl-top-0!')
= link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
= clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
.well-segment.related-merge-request-info
.icon-container
- = sprite_icon("git-merge")
+ = sprite_icon("git-merge", css_class: 'gl-top-0!')
%span.related-merge-requests
%span.js-truncated-mr-list
= @pipeline.all_related_merge_request_text(limit: 1)
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 10ff9c31c3e..c9eb2e92193 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -27,7 +27,7 @@
= 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 }
- if Feature.enabled?(:pipeline_tabs_vue, @project)
- #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline) }
+ #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) }
- else
= 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 8c616b89658..37fe80d2aaf 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -2,47 +2,45 @@
- page_title _("Members")
= render_if_exists 'projects/free_user_cap_alert', project: @project
-= render_if_exists 'shared/minute_limit_banner', namespace: @project
.row.gl-mt-3
.col-lg-12
- - if can_invite_members_for_project?(@project)
- .row
- .col-md-12.col-lg-6.gl-display-flex
- .gl-flex-direction-column.gl-flex-wrap.align-items-baseline
- %h4
- = _("Project members")
- .gl-justify-content-bottom.gl-display-flex.align-items-center
- %p
- = project_member_header_subtext(@project)
- .col-md-12.col-lg-6
- .gl-display-flex.gl-flex-wrap.gl-justify-content-end
- - if can_admin_project_member?(@project)
- .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: 'confirm',
- classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
- trigger_source: 'project-members-page',
- display_text: _('Invite members') } }
- = render 'projects/invite_members_modal', project: @project
-
- - else
- - if project_can_be_shared?
+ .gl-display-flex.gl-flex-wrap
+ - if can_invite_members_for_project?(@project)
%h4
= _("Project members")
- - if can?(current_user, :admin_project_member, @project)
- %p
- = project_member_header_subtext(@project)
- - else
- %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 }
+ %p.gl-w-full.order-md-1
+ = project_member_header_subtext(@project)
+ .gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3
+ - invite_group_top_margin = ''
+ - if can_admin_project_member?(@project)
+ .js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } }
+ .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name } }
+ - invite_group_top_margin = 'gl-md-mt-0 gl-mt-3'
+ - if @project.allowed_to_share_with_group?
+ .js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } }
+ = render 'projects/invite_groups_modal', project: @project
+ - if can_admin_project_member?(@project)
+ .js-invite-members-trigger{ data: { variant: 'confirm',
+ classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3',
+ trigger_source: 'project-members-page',
+ display_text: _('Invite members') } }
+ = render 'projects/invite_members_modal', project: @project
+ - else
+ - if project_can_be_shared?
+ %h4
+ = _("Project members")
+ - if can?(current_user, :admin_project_member, @project)
+ %p.gl-w-full
+ = project_member_header_subtext(@project)
+ - else
+ %p.gl-w-full
+ = 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 }
.js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project,
members: @project_members,
- group_links: @group_links,
invited: @invited_members,
- access_requests: @requesters) } }
+ access_requests: @requesters,
+ include_relations: @include_relations,
+ search: params[:search_groups]) } }
= gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index e5810930be2..3b8294a1dec 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -1,29 +1,29 @@
= form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
.card
- .card-header
+ .card-header.gl-font-weight-bold
= s_("ProtectedBranch|Protect a branch")
.card-body
- = form_errors(@protected_branch)
+ = form_errors(@protected_branch, pajamas_alert: true)
.form-group.row
- = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-md-2 text-left text-md-right'
- .col-md-10
- = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
+ = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
+ .col-sm-12
+ = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
.form-text.text-muted
- wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard')
- wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
= (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe
.form-group.row
- = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-md-2 text-left text-md-right'
- .col-md-10
+ = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12'
+ .col-sm-12
= yield :merge_access_levels
.form-group.row
- = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-md-2 text-left text-md-right'
- .col-md-10
+ = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-sm-12'
+ .col-sm-12
= yield :push_access_levels
.form-group.row
- = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right'
- .col-md-10
+ = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-sm-12'
+ .col-sm-12
= render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
label: s_("ProtectedBranch|Allowed to force push"),
label_position: :hidden) do
diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/projects/protected_branches/shared/_dropdown.html.haml
index 67a6e8efae8..4b09d36e7c3 100644
--- a/app/views/projects/protected_branches/shared/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/shared/_dropdown.html.haml
@@ -1,8 +1,12 @@
+- toggle_classes = local_assigns.fetch(:toggle_classes, '')
+
= f.hidden_field(:name)
= dropdown_tag('Select branch or create wildcard',
- options: { toggle_class: 'js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select',
- filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown", placeholder: "Search protected branches",
+ options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace qa-protected-branch-select #{toggle_classes}",
+ filter: true,
+ dropdown_class: "dropdown-menu-selectable git-revision-dropdown qa-protected-branch-dropdown",
+ placeholder: "Search protected branches",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_branch_name],
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index ba0935fff7d..e257117a32e 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -4,7 +4,7 @@
.card-header
= _('Protect a tag')
.card-body
- = form_errors(@protected_tag)
+ = form_errors(@protected_tag, pajamas_alert: true)
.form-group.row
= f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right'
.col-md-10.protected-tags-dropdown
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 8a080241513..2f97a068b49 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -7,14 +7,14 @@
- else
= _('Archive project')
- if @project.archived?
- - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchiving-a-project') }
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') }
%p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays 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') }
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') }
%p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display 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),
aria: { label: _('Archive project') },
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 96564e44cf2..64f45ec89d1 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -16,7 +16,7 @@
.row
.col-lg-12
= gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f|
- = form_errors(@project)
+ = form_errors(@project, pajamas_alert: true)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 9419dacc16f..50e96528c0d 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -6,7 +6,7 @@
.row.gl-mt-3
.col-lg-12
= gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f|
- = form_errors(@project)
+ = form_errors(@project, pajamas_alert: true)
%fieldset.builds-feature
.form-group
= f.gitlab_ui_checkbox_component :public_builds,
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 5da3d2b891c..09f9ca60b3e 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -109,13 +109,15 @@
= render 'ci/token_access/index'
- if show_secure_files_setting(@project, current_user)
- %section.settings
+ %section.settings.no-animate#js-secure-files{ class: ('expanded' if expanded) }
.settings-header
- %h4.settings-title
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Secure Files")
- = button_to project_ci_secure_files_path(@project), method: :get, class: 'btn gl-button btn-default' do
- = _('Manage')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
%p
= _("Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.")
= link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ #js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } }
diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml
deleted file mode 100644
index 3c8ebe3fb20..00000000000
--- a/app/views/projects/settings/operations/_tracing.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- setting = tracing_setting
-
-%section.settings.border-0.no-animate
- .settings-header{ :class => 'border-top' }
- %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _('Tracing')
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
- = _('Expand')
- %p
- = _('Embed an image of your existing Jaeger server in GitLab.')
- = link_to _('Learn more.'), help_page_path('operations/tracing'), target: '_blank', rel: 'noopener noreferrer'
- .settings-content
- = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
- = form_errors(@project)
- .form-group
- = f.fields_for :tracing_setting_attributes, setting do |form|
- = form.label :external_url, _('Jaeger URL'), class: 'label-bold'
- = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'https://jaeger.example.com'
- %p.form-text.text-muted
- - jaeger_help_url = 'https://www.jaegertracing.io/docs/getting-started/'
- - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- - link_end_tag = "#{sprite_icon('external-link', css_class: 'gl-ml-2 gl-vertical-align-middle')}</a>".html_safe
- = _('Learn more about %{link_start_tag}Jaeger configuration%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
- = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 80c22604e49..50bfd3c6976 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -16,11 +16,10 @@
.gl-alert-body
%p
= html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.'))
- = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } if Feature.enabled?(:monitor_tracing, @project)
+ = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end }
= html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end }
= render 'projects/settings/operations/metrics_dashboard'
-= render 'projects/settings/operations/tracing' if Feature.enabled?(:monitor_tracing, @project)
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml
index 378bb0f9306..1a7821d3268 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -8,6 +8,8 @@
keep_n_options: keep_n_options.to_json,
older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s,
+ show_container_registry_settings: show_container_registry_settings(@project).to_s,
+ show_package_registry_settings: show_package_registry_settings(@project).to_s,
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s,
help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 290ef79f261..1f529849b28 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -7,7 +7,6 @@
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= render_if_exists 'projects/free_user_cap_alert', project: @project
-= render_if_exists 'shared/minute_limit_banner', namespace: @project
= render partial: 'flash_messages', locals: { project: @project }
= render 'clusters_deprecation_alert'
diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml
new file mode 100644
index 00000000000..5bdf1c7896c
--- /dev/null
+++ b/app/views/projects/tags/_edit_release_button.html.haml
@@ -0,0 +1,11 @@
+- if Feature.enabled?(:edit_tag_release_notes_via_release_page, project)
+ - release_btn_text = s_('TagsPage|Create release')
+ - release_btn_path = new_project_release_path(project, tag_name: tag.name)
+ - if release
+ - release_btn_text = s_('TagsPage|Edit release')
+ - release_btn_path = edit_project_release_path(project, release)
+ = link_to release_btn_path, class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: release_btn_text, data: { container: "body" } do
+ = sprite_icon('pencil', css_class: 'gl-icon')
+- else
+ = link_to edit_project_tag_release_path(project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
+ = sprite_icon('pencil', css_class: 'gl-icon')
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 7654150509e..258f662420b 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -40,6 +40,5 @@
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :admin_tag, @project)
- = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
- = sprite_icon('pencil', css_class: 'gl-icon')
+ = render 'edit_release_button', tag: tag, project: @project, release: release
= render 'projects/buttons/remove_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 2a68ad37c1e..24da8e2db87 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -41,8 +41,7 @@
- if @tag.has_signature?
= render partial: 'projects/commit/signature', object: @tag.signature
- if can?(current_user, :admin_tag, @project)
- = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-icon btn-edit gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
- = sprite_icon("pencil", css_class: 'gl-icon')
+ = render 'edit_release_button', tag: @tag, project: @project, release: @release
= link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse files') do
= sprite_icon('folder-open', css_class: 'gl-icon')
= link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button btn-default controls-item has-tooltip', title: s_('TagsPage|Browse commits') do
@@ -58,7 +57,7 @@
= strip_signature(@tag.message)
.gl-mb-3.gl-mt-3
- - if @release.description.present?
+ - if @release&.description.present?
.description.md{ data: { qa_selector: 'tag_release_notes_content' } }
= markdown_field(@release, :description)
- else
diff --git a/app/views/projects/tracings/_tracing_button.html.haml b/app/views/projects/tracings/_tracing_button.html.haml
deleted file mode 100644
index fe3af1c6a1a..00000000000
--- a/app/views/projects/tracings/_tracing_button.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'gl-button btn btn-confirm' do
- = _('Add Jaeger URL')
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
deleted file mode 100644
index 61f2cd8ac7f..00000000000
--- a/app/views/projects/tracings/show.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-- @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 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?
- %h1.page-title.gl-font-size-h-display= _('Tracing')
- .gl-alert.gl-alert-info.gl-mb-5
- .gl-alert-container
- = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.")
- - jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer")
- %p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link }
-
-
- .card
- - iframe_permissions = "allow-forms allow-scripts allow-same-origin allow-popups"
- %iframe.border-0{ src: sanitize(@project.tracing_external_url, scrubber: Rails::Html::TextOnlyScrubber.new), width: '100%', height: 970, sandbox: iframe_permissions }
-- else
- .row.empty-state
- .col-12
- .svg-content
- = image_tag 'illustrations/monitoring/tracing.svg'
-
- .col-12
- .text-content
- %h4.text-left= _('Troubleshoot and monitor your application with tracing')
- %p
- - jaeger_help_url = "https://www.jaegertracing.io/docs/getting-started/"
- - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
- = _('Add a Jaeger URL to replace this page with a link to your Jaeger server. You first need to %{link_start_tag}install Jaeger%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
-
- .text-center
- = render 'tracing_button'
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 9043b8e60fc..d24cfd61052 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -1,5 +1,5 @@
= form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
- = form_errors(@trigger)
+ = form_errors(@trigger, pajamas_alert: true)
- if @trigger.token
.form-group
diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml
index 1f36afc48aa..8575fd10ad3 100644
--- a/app/views/projects/work_items/index.html.haml
+++ b/app/views/projects/work_items/index.html.haml
@@ -1,3 +1,5 @@
- page_title s_('WorkItem|Work Items')
+- add_page_specific_style 'page_bundles/work_items'
#js-work-items{ data: work_items_index_data(@project) }
+= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/pwa/offline.html.haml b/app/views/pwa/offline.html.haml
index 5eae546bea9..cd1fca5d2b5 100644
--- a/app/views/pwa/offline.html.haml
+++ b/app/views/pwa/offline.html.haml
@@ -1,5 +1,5 @@
= link_to root_path do
- = render 'shared/logo.svg'
+ = render partial: 'shared/logo', formats: :svg
%h1= _('Offline')
.container
%h3= _('You are currently offline, or the GitLab instance is not reachable.')
diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml
index 608a0ca37d9..ab5f2fb1772 100644
--- a/app/views/shared/_allow_request_access.html.haml
+++ b/app/views/shared/_allow_request_access.html.haml
@@ -1,3 +1,3 @@
= form.gitlab_ui_checkbox_component :request_access_enabled,
- _('Allow users to request access (if visibility is public or internal)'),
+ _('Users can request access (if visibility is public or internal)'),
checkbox_options: { data: { qa_selector: 'request_access_checkbox' } }
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 5ae99474c70..db5e055a1c4 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -3,53 +3,4 @@
- group_path << parent.full_path + '/' if parent
-- if Feature::enabled?(:group_name_path_vue, current_user)
- = render 'shared/groups/group_name_and_path_fields', f: f
-- else
- .row
- .form-group.group-name-holder.col-sm-12
- = f.label :name, class: 'label-bold' do
- = 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: 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
- = 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
- %span>= root_url
- - if parent
- %strong= parent.full_path + '/'
- = f.hidden_field :parent_id
- = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path', data: { qa_selector: 'group_path_field' },
- autofocus: local_assigns[:autofocus] || false, required: true,
- pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
- title: group_url_error_message,
- 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
- = 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
- = s_('Groups|Changing group URL can have unintended side effects.')
- = succeed '.' do
- = 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
- = s_('Groups|Group ID')
- = f.text_field :id, class: 'form-control', readonly: true
+= render 'shared/groups/group_name_and_path_fields', f: f
diff --git a/app/views/shared/_integration_settings.html.haml b/app/views/shared/_integration_settings.html.haml
index 84710b2ecc7..d58be0f0f4a 100644
--- a/app/views/shared/_integration_settings.html.haml
+++ b/app/views/shared/_integration_settings.html.haml
@@ -1,4 +1,4 @@
-= form_errors(integration)
+= form_errors(integration, pajamas_alert: true)
%div{ data: { testid: "integration-settings-form" } }
- if @default_integration
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index af5657e0e14..c0bc50fef5b 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -31,22 +31,19 @@
%ul
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
- %button.js-promote-project-label-button.gl-button.btn.btn-default-tertiary{ disabled: true, type: 'button',
- data: { url: promote_project_label_path(label.project, label),
- label_title: label.title,
- label_color: label.color,
- label_text_color: label.text_color,
- group_name: label.project.group.name } }
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } } ) do
= _('Promote to group label')
%li
%span
- %button.text-danger.js-delete-label-modal-button{ type: 'button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } } ) do
= _('Delete')
- if current_user
%li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3
- if label.can_subscribe_to_label_in_different_levels?
- %button.js-unsubscribe-button.gl-button.btn.btn-default.gl-w-full{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
- %span.gl-button-text= _('Unsubscribe')
+ = render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{('hidden' if status.unsubscribed?)}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do
+ = _('Unsubscribe')
.dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
= render Pajamas::ButtonComponent.new(button_options: { class: 'gl-w-full', data: { toggle: 'dropdown' } }) do
= _('Subscribe')
@@ -54,11 +51,11 @@
.dropdown-menu.dropdown-open-left
%ul
%li
- %button.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_project_label_path(@project, label) } }
- %span.gl-button-text= _('Subscribe at project level')
+ = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button #{('hidden' unless status.unsubscribed?)}", data: { status: status, url: toggle_subscription_project_label_path(@project, label) } } ) do
+ = _('Subscribe at project level')
%li
- %button.js-subscribe-button.js-group-level{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }
- %span.gl-button-text= _('Subscribe at group level')
+ = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{('hidden' unless status.unsubscribed?)}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } ) do
+ = _('Subscribe at group level')
- else
- %button.gl-button.js-subscribe-button.btn.btn-default.gl-w-full{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
- %span.gl-button-text= label_subscription_toggle_button_text(label, @project)
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do
+ = label_subscription_toggle_button_text(label, @project)
diff --git a/app/views/shared/_old_visibility_level.html.haml b/app/views/shared/_old_visibility_level.html.haml
index 104c722ee65..6bcac2b0e6b 100644
--- a/app/views/shared/_old_visibility_level.html.haml
+++ b/app/views/shared/_old_visibility_level.html.haml
@@ -1,6 +1,5 @@
-.form-group.row
- .col-sm-2.col-form-label
+%fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
= _('Visibility level')
- = link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), target: '_blank', rel: 'noopener noreferrer'
- .col-sm-10
- = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label
+ = link_to sprite_icon('question-o'), help_page_path('user/public_access'), target: '_blank', rel: 'noopener noreferrer'
+ = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label
diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml
index 3e30dcaf35a..763ae5a498b 100644
--- a/app/views/shared/_visibility_level.html.haml
+++ b/app/views/shared/_visibility_level.html.haml
@@ -1,11 +1,11 @@
- with_label = local_assigns.fetch(:with_label, true)
-.form-group.visibility-level-setting
+.visibility-level-setting
- if with_label
= f.label :visibility_level, _('Visibility level'), class: 'label-bold gl-mb-0'
%p
= _('Who can see this group?')
- - visibility_docs_path = help_page_path('public_access/public_access')
+ - visibility_docs_path = help_page_path('user/public_access')
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: visibility_docs_path }
= _('%{docs_link_start}Learn about visibility levels.%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
- if can_change_visibility_level
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 760fe18ddec..1bac75e0ff5 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -2,15 +2,13 @@
- selected_level = snippets_selected_visibility_level(available_visibility_levels, selected_level)
- available_visibility_levels.each do |level|
- .form-check
- = form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" }
- = form.label "#{model_method}_#{level}", class: 'form-check-label' do
- = visibility_level_icon(level)
- .option-title
- = visibility_level_label(level)
- .option-description
- = visibility_level_description(level, form_model)
- .option-disabled-reason
+
+ = form.gitlab_ui_radio_component model_method, level,
+ "#{visibility_level_icon(level)} #{visibility_level_label(level)}".html_safe,
+ help_text: '<span class="option-description">%{visibility_level_description}</span><span class="option-disabled-reason"></span>'.html_safe % { visibility_level_description: visibility_level_description(level, form_model)},
+ radio_options: { checked: (selected_level == level), data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" } },
+ label_options: { class: 'js-visibility-level-radio' }
+
.text-muted
- if all_visibility_levels_restricted?
diff --git a/app/views/shared/admin/_admin_note_form.html.haml b/app/views/shared/admin/_admin_note_form.html.haml
index 0bc26f9120f..09b059774bc 100644
--- a/app/views/shared/admin/_admin_note_form.html.haml
+++ b/app/views/shared/admin/_admin_note_form.html.haml
@@ -1,6 +1,4 @@
-.form-group.row
+.form-group.gl-form-group{ role: 'group' }
= f.fields_for :admin_note do |an|
- .col-sm-2.col-form-label.gl-text-right
- = an.label :note, s_('Admin|Admin notes')
- .col-sm-10
- = an.text_area :note, class: 'form-control'
+ = an.label :note, s_('Admin|Admin notes'), class: 'gl-display-block col-form-label'
+ = an.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 4ab93030638..38985319ca5 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -13,14 +13,19 @@
= form.label :key, class: 'col-form-label col-sm-2'
.col-sm-10
%p.light
- - link_start = "<a href='#{help_page_path('ssh/index')}' target='_blank' rel='noreferrer noopener'>".html_safe
+ - link_start = "<a href='#{help_page_path('user/ssh')}' target='_blank' rel='noreferrer noopener'>".html_safe
- link_end = '</a>'
= _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
= form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' }
- else
- = form.label :fingerprint, class: 'col-form-label col-sm-2'
- .col-sm-10
- = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
+ - if deploy_key.fingerprint_sha256.present?
+ = form.label :fingerprint, _('Fingerprint (SHA256)'), class: 'col-form-label col-sm-2'
+ .col-sm-10
+ = form.text_field :fingerprint_sha256, class: 'form-control gl-form-input', readonly: 'readonly'
+ - if deploy_key.fingerprint.present?
+ = form.label :fingerprint, _('Fingerprint (MD5)'), class: 'col-form-label col-sm-2'
+ .col-sm-10
+ = form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
- if deploy_keys_project.present?
= form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index c9edf09b350..4bedce71c0f 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f|
- = form_errors(@deploy_keys.new_key)
+ = form_errors(@deploy_keys.new_key, pajamas_alert: true)
.form-group.row
= f.label :title, class: "label-bold"
= f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' }
@@ -9,7 +9,7 @@
.form-group.row
%p.light.gl-mb-0
= _('Paste a public key here.')
- = link_to _('How do I generate it?'), help_page_path("ssh/index")
+ = link_to _('How do I generate it?'), help_page_path("user/ssh")
= f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
.form-group.row
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index b40e2630011..9810754f52b 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
- = form_errors(@application)
+ = form_errors(@application, pajamas_alert: true)
.form-group
= f.label :name, class: 'label-bold'
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
index 0359c28794c..b14ff9b2508 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -55,7 +55,7 @@
.oauth-authorized-applications.prepend-top-20.gl-mb-3
- if oauth_applications_enabled
%h5
- = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
+ = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
- if @authorized_tokens.any?
.table-responsive
@@ -67,22 +67,22 @@
%th= _('Scope')
%th
%tbody
- - @authorized_apps.each do |app|
- - token = app.authorized_tokens.order('created_at desc').first # rubocop: disable CodeReuse/ActiveRecord
- %tr{ id: "application_#{app.id}" }
- %td= app.name
- %td= token.created_at
- %td= token.scopes
- %td= render 'doorkeeper/authorized_applications/delete_form', application: app
- - @authorized_anonymous_tokens.each do |token|
- %tr
+ - @authorized_tokens.each do |token|
+ %tr{ id: ("application_#{token.application.id}" if token.application) }
%td
- = _('Anonymous')
- .form-text.text-muted
- %em= _("Authorization was granted by entering your username and password in the application.")
+ - if token.application
+ = token.application.name
+ - else
+ = _('Anonymous')
+ .form-text.text-muted
+ %em= _("Authorization was granted by entering your username and password in the application.")
%td= token.created_at
%td= token.scopes
- %td= render 'doorkeeper/authorized_applications/delete_form', token: token
+ %td
+ - if token.application
+ = render 'doorkeeper/authorized_applications/delete_form', application: token.application
+ - else
+ = render 'doorkeeper/authorized_applications/delete_form', token: token
- else
.settings-message.text-center
= _("You don't have any authorized applications")
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index 20ca7954479..a006a3bc0a4 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -4,15 +4,15 @@
.col-12
.svg-content
= image_tag 'illustrations/snippets_empty.svg', data: { qa_selector: 'svg_content' }
- .text-content.text-center.pt-0
+ .text-content.gl-text-center.gl-pt-0
- if current_user
%h4
= s_('SnippetsEmptyState|Code snippets')
- %p.mb-0
+ %p.gl-mb-0
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
- .mt-2<
+ .gl-mt-3<
- if button_path
= link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn gl-button btn-confirm', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn gl-button btn-default', title: s_('SnippetsEmptyState|Documentation')
- else
- %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
+ %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.')
diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml
index ca1d3d53f16..131c450ddd7 100644
--- a/app/views/shared/form_elements/_apply_template_warning.html.haml
+++ b/app/views/shared/form_elements/_apply_template_warning.html.haml
@@ -7,7 +7,7 @@
%p
= _("Applying a template will replace the existing issue description. Any changes you have made will be lost.")
- %button.js-override-template.btn.gl-button.btn-confirm.mr-2{ type: 'button' }
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3 js-override-template' }) do
= _("Apply template")
- %button.js-close-btn.js-cancel-btn.btn.gl-button.btn-default{ type: 'button' }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-btn js-cancel-btn' }) do
= _("Cancel")
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index a87aa8de679..2c46b2191c6 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -21,7 +21,7 @@
classes: 'note-textarea rspec-issuable-form-description',
placeholder: placeholder,
supports_quick_actions: supports_quick_actions,
- qa_selector: 'issuable_form_description'
+ qa_selector: 'issuable_form_description_field'
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
.clearfix
.error-alert
diff --git a/app/views/shared/groups/_group_name_and_path_fields.html.haml b/app/views/shared/groups/_group_name_and_path_fields.html.haml
index 709130a47d3..634b8448535 100644
--- a/app/views/shared/groups/_group_name_and_path_fields.html.haml
+++ b/app/views/shared/groups/_group_name_and_path_fields.html.haml
@@ -1,5 +1,5 @@
.js-group-name-and-path{ data: group_name_and_path_app_data(@group) }
= f.hidden_field :name, data: { js_name: 'name' }
= f.hidden_field :path, maxlength: ::Namespace::URL_MAX_LENGTH, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, data: { js_name: 'path' }
- = f.hidden_field :parent_id, data: { js_name: 'parentId' }
+ = f.hidden_field :parent_id, value: @group.parent&.id, data: { js_name: 'parentId' }
= f.hidden_field :id, data: { js_name: 'groupId' }
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index 8409f224158..0bec94f70ea 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -26,16 +26,16 @@
= render_if_exists 'shared/issuable/epic_dropdown', parent: @project.group
.block
.title
+ = _('Labels')
+ .filter-item.labels-filter
+ = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true
+ .block
+ .title
= _('Milestone')
.filter-item
= dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, use_id: true, default_label: _("Milestone") } })
- if is_issue
= render_if_exists 'shared/issuable/iterations_dropdown', parent: @project.group
- .block
- .title
- = _('Labels')
- .filter-item.labels-filter
- = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true
- if is_issue
= render_if_exists 'shared/issuable/health_status_dropdown', parent: @project
.block
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index da49a301087..e90ea35f28e 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -67,11 +67,11 @@
= form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_experiment: 'promote_mr_approvals_in_free', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
- if issuable.new_record?
- = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default'
+ = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave'
- else
- = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default'
+ = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default js-reset-autosave'
- 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'
+ = 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 js-reset-autosave'
- if issuable.respond_to?(:issue_type)
= form.hidden_field :issue_type
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
index c9dda22de46..f9c70236c8f 100644
--- a/app/views/shared/issuable/form/_contribution.html.haml
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -9,11 +9,10 @@
%hr
-.form-group.row
- %label.col-form-label.col-sm-2.pt-sm-0
+.form-group
+ %label
= _('Contribution')
- .col-sm-10
- = form.gitlab_ui_checkbox_component :allow_collaboration,
- _('Allow commits from members who can merge to the target branch. %{link_start}About this feature.%{link_end}').html_safe % { link_start: contribution_help_link_start, link_end: '</a>'.html_safe },
- checkbox_options: { disabled: !issuable.can_allow_collaboration?(current_user) },
- help_text: allow_collaboration_unavailable_reason(issuable)
+ = form.gitlab_ui_checkbox_component :allow_collaboration,
+ _('Allow commits from members who can merge to the target branch. %{link_start}About this feature.%{link_end}').html_safe % { link_start: contribution_help_link_start, link_end: '</a>'.html_safe },
+ checkbox_options: { disabled: !issuable.can_allow_collaboration?(current_user) },
+ help_text: allow_collaboration_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index d5c696b1698..a94ef70b2d5 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -1,35 +1,35 @@
- return unless issuable.supports_issue_type? && can?(current_user, :create_issue, @project)
.form-group
- = form.label :type, _('Type')
- .gl-display-flex.gl-align-items-center
- .issuable-form-select-holder.selectbox.form-group.gl-mb-0
- .dropdown.js-issuable-type-filter-dropdown-wrap
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.dropdown-toggle-text.is-default
- = issuable.issue_type.capitalize || _("Select type")
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-menu-selectable.dropdown-select
- .dropdown-title.gl-display-flex
- %span.gl-ml-auto
- = _("Select type")
- %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
- = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
- .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } }
- %ul
- - if create_issue_type_allowed?(@project, :issue)
- %li.js-filter-issuable-type
- = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
- #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')}
- - if create_issue_type_allowed?(@project, :incident)
- %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
- = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
- #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')}
-
+ = form.label :type do
+ = _('Type')
#js-type-popover
- - if issuable.incident?
- %p.form-text.text-muted
- - incident_docs_url = help_page_path('operations/incident_management/incidents.md')
- - incident_docs_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: incident_docs_url)
- = format(_('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.'), incident_docs_start: incident_docs_start, incident_docs_end: '</a>').html_safe
+ .issuable-form-select-holder.selectbox.form-group.gl-mb-0.gl-display-block
+ .dropdown.js-issuable-type-filter-dropdown-wrap
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.dropdown-toggle-text.is-default
+ = issuable.issue_type.capitalize || _("Select type")
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
+ .dropdown-menu.dropdown-menu-selectable.dropdown-select
+ .dropdown-title.gl-display-flex
+ %span.gl-ml-auto
+ = _("Select type")
+ %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
+ = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
+ .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } }
+ %ul
+ - if create_issue_type_allowed?(@project, :issue)
+ %li.js-filter-issuable-type
+ = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
+ #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')}
+ - if create_issue_type_allowed?(@project, :incident)
+ %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
+ = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
+ #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')}
+
+ - if issuable.incident?
+ %p.form-text.text-muted
+ - incident_docs_url = help_page_path('operations/incident_management/incidents.md')
+ - incident_docs_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: incident_docs_url)
+ = format(_('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.'), incident_docs_start: incident_docs_start, incident_docs_end: '</a>').html_safe
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 7c5b3fd4b3c..39e7d196965 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -18,6 +18,7 @@
= render 'projects/issues/design_management'
= render_if_exists 'projects/issues/work_item_links'
+ = render_if_exists 'projects/issues/linked_resources'
= render_if_exists 'projects/issues/related_issues'
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index ba0e5e492f4..23f78f4be45 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -45,7 +45,8 @@
&middot;
%span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_s(:medium) if member.expires?) }
- if member.expires?
- = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) }
+ - preposition = current_user.time_display_relative ? '' : 'on'
+ = _("Expires %{preposition} %{expires_at}").html_safe % { expires_at: time_ago_with_tooltip(member.expires_at), preposition: preposition }
- else
= image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 541d7a52385..18db556e024 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -11,23 +11,21 @@
.milestone-buttons
- if can?(current_user, :admin_milestone, @group || @project)
- = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn gl-button btn-grouped'
+ = render Pajamas::ButtonComponent.new(href: edit_milestone_path(milestone), button_options: { class: 'btn-grouped' }) do
+ = _('Edit')
- if milestone.project_milestone? && milestone.project.group
- %button.js-promote-project-milestone-button.btn.gl-button.btn-grouped{ data: { milestone_title: milestone.title,
- group_name: milestone.project.group.name,
- url: promote_project_milestone_path(milestone.project, milestone)},
- disabled: true,
- type: 'button' }
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-promote-project-milestone-button btn-grouped', data: { milestone_title: milestone.title, group_name: milestone.project.group.name, url: promote_project_milestone_path(milestone.project, milestone) }, disabled: true }) do
= _('Promote')
#promote-milestone-modal
- if milestone.active?
- = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn gl-button btn-grouped btn-close'
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), button_options: { class: 'btn-grouped btn-close', data: { method: 'put' }, rel: 'nofollow' }) do
+ = _('Close milestone')
- else
- = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped'
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), button_options: { class: 'btn-grouped', data: { method: 'put' }, rel: 'nofollow' }) do
+ = _('Reopen milestone')
= render 'shared/milestones/delete_button'
- %button.btn.gl-button.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' }
- = sprite_icon('chevron-double-lg-left')
+ = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', button_options: { 'aria-label' => _('Toggle sidebar'), class: 'btn-grouped gl-float-right! gl-sm-display-none js-sidebar-toggle' })
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index 024b06fe97a..e0079a95cec 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -1,5 +1,5 @@
= gitlab_ui_form_for runner, url: runner_form_url do |f|
- = form_errors(runner)
+ = form_errors(runner, pajamas_alert: true)
.form-group.row
= label :active, _("Active"), class: 'col-form-label col-sm-2'
.col-sm-10
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 5f511b35b61..33b0e1f693e 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,2 +1,2 @@
- available_visibility_levels = available_visibility_levels(@snippet)
-#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } }
+#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("user/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } }
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index afe72767b9a..fe68244f1da 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -1,4 +1,4 @@
-= form_errors(hook)
+= form_errors(hook, pajamas_alert: true)
.form-group
= form.label :url, s_('Webhooks|URL'), class: 'label-bold'
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 6bbce6b80d8..fc56a191cad 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -1,5 +1,7 @@
- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New')
- add_page_specific_style 'page_bundles/wiki'
+- @gfm_form = true
+- @noteable_type = 'Wiki'
- if @error
#js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } }
diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index 3b0186e84e1..b62440fcbde 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -3,4 +3,7 @@
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
= render 'middle_dot_divider' do
+ = s_('UserProfile|User ID: %{id}') % { id: @user.id }
+ = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
+ = render 'middle_dot_divider' do
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index ab75abff9ba..966a1202db2 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -219,6 +219,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:ci_runners_reconcile_existing_runner_versions_cron
+ :worker_name: Ci::Runners::ReconcileExistingRunnerVersionsCronWorker
+ :feature_category: :runner_fleet
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:ci_schedule_delete_objects_cron
:worker_name: Ci::ScheduleDeleteObjectsCronWorker
:feature_category: :continuous_integration
@@ -948,15 +957,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: gcp_cluster:clusters_applications_activate_service
- :worker_name: Clusters::Applications::ActivateServiceWorker
- :feature_category: :kubernetes_management
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: gcp_cluster:clusters_applications_deactivate_integration
:worker_name: Clusters::Applications::DeactivateIntegrationWorker
:feature_category: :kubernetes_management
@@ -966,15 +966,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: gcp_cluster:clusters_applications_deactivate_service
- :worker_name: Clusters::Applications::DeactivateServiceWorker
- :feature_category: :kubernetes_management
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: gcp_cluster:clusters_applications_uninstall
:worker_name: Clusters::Applications::UninstallWorker
:feature_category: :kubernetes_management
@@ -1038,6 +1029,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_import_issue_event
+ :worker_name: Gitlab::GithubImport::ImportIssueEventWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_import_lfs_object
:worker_name: Gitlab::GithubImport::ImportLfsObjectWorker
:feature_category: :importers
@@ -1110,6 +1110,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_stage_import_issue_events
+ :worker_name: Gitlab::GithubImport::Stage::ImportIssueEventsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_stage_import_issues_and_diff_notes
:worker_name: Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker
:feature_category: :importers
@@ -1452,6 +1461,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: package_cleanup:packages_cleanup_execute_policy
+ :worker_name: Packages::Cleanup::ExecutePolicyWorker
+ :feature_category: :package_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_cleanup:packages_cleanup_package_file
:worker_name: Packages::CleanupPackageFileWorker
:feature_category: :package_registry
@@ -2344,6 +2362,15 @@
:weight: 2
:idempotent: false
:tags: []
+- :name: google_cloud_create_cloudsql_instance
+ :worker_name: GoogleCloud::CreateCloudsqlInstanceWorker
+ :feature_category: :not_owned
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: group_destroy
:worker_name: GroupDestroyWorker
:feature_category: :subgroups
@@ -2389,6 +2416,15 @@
:weight: 2
:idempotent: true
:tags: []
+- :name: incident_management_close_incident
+ :worker_name: IncidentManagement::CloseIncidentWorker
+ :feature_category: :incident_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: integrations_create_external_cross_reference
:worker_name: Integrations::CreateExternalCrossReferenceWorker
:feature_category: :integrations
@@ -2425,15 +2461,6 @@
:weight: 2
:idempotent: false
:tags: []
-- :name: irker
- :worker_name: IrkerWorker
- :feature_category: :integrations
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: issuable_export_csv
:worker_name: IssuableExportCsvWorker
:feature_category: :team_planning
@@ -2704,14 +2731,14 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: pages_transfer
- :worker_name: PagesTransferWorker
+- :name: pages_invalidate_domain_cache
+ :worker_name: Pages::InvalidateDomainCacheWorker
:feature_category: :pages
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: phabricator_import_import_tasks
:worker_name: Gitlab::PhabricatorImport::ImportTasksWorker
@@ -2767,15 +2794,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: project_service
- :worker_name: ProjectServiceWorker
- :feature_category: :integrations
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: projects_after_import
:worker_name: Projects::AfterImportWorker
:feature_category: :importers
@@ -3092,15 +3110,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: web_hooks_destroy
- :worker_name: WebHooks::DestroyWorker
- :feature_category: :integrations
- :has_external_dependencies: false
- :urgency: :high
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: true
- :tags: []
- :name: web_hooks_log_destroy
:worker_name: WebHooks::LogDestroyWorker
:feature_category: :integrations
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index afe14369d43..4312ba41367 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -14,17 +14,6 @@ class AuthorizedProjectsWorker
idempotent!
loggable_arguments 1 # For the job waiter key
- # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
- # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
- # for more details.
- if Rails.env.test?
- def self.bulk_perform_and_wait(args_list, timeout: 10)
- end
-
- def self.bulk_perform_inline(args_list)
- end
- end
-
def perform(user_id)
user = User.find_by_id(user_id)
diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb
index 78244e0941e..5c08344bfe3 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -18,4 +18,16 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker
.try(:execute_hooks)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def self.perform_async(build)
+ Gitlab::AppLogger.info(
+ message: "Enqueuing hooks for Build #{build.id}: #{build.status}",
+ class: self.name,
+ build_id: build.id,
+ pipeline_id: build.pipeline_id,
+ project_id: build.project_id,
+ build_status: build.status)
+
+ super(build.id)
+ end
end
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 9c95e25e2e8..e171ec1e194 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -53,12 +53,8 @@ module BulkImports
pipeline_tracker.update!(status_event: 'start', jid: jid)
pipeline_tracker.pipeline_class.new(context).run
pipeline_tracker.finish!
- rescue BulkImports::NetworkError => e
- if e.retriable?(pipeline_tracker)
- retry_tracker(e)
- else
- fail_tracker(e)
- end
+ rescue BulkImports::RetryPipelineError => e
+ retry_tracker(e)
rescue StandardError => e
fail_tracker(e)
end
diff --git a/app/workers/ci/archive_trace_worker.rb b/app/workers/ci/archive_trace_worker.rb
index 47d77c15b4a..edbaf0536a2 100644
--- a/app/workers/ci/archive_trace_worker.rb
+++ b/app/workers/ci/archive_trace_worker.rb
@@ -4,17 +4,13 @@ module Ci
class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :sticky, feature_flag: :sticky_ci_archive_trace_worker
+ data_consistency :sticky
sidekiq_options retry: 3
include PipelineBackgroundQueue
def perform(job_id)
- archivable_jobs = Ci::Build.without_archived_trace
-
- if Feature.enabled?(:sticky_ci_archive_trace_worker)
- archivable_jobs = archivable_jobs.eager_load_for_archiving_trace
- end
+ archivable_jobs = Ci::Build.without_archived_trace.eager_load_for_archiving_trace
archivable_jobs.find_by_id(job_id).try do |job|
Ci::ArchiveTraceService.new.execute(job, worker_name: self.class.name)
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
index 2d7f3a67004..25c7637a79f 100644
--- a/app/workers/ci/build_finished_worker.rb
+++ b/app/workers/ci/build_finished_worker.rb
@@ -37,9 +37,10 @@ module Ci
Ci::BuildReportResultService.new.execute(build)
# We execute these async as these are independent operations.
- BuildHooksWorker.perform_async(build.id)
+ BuildHooksWorker.perform_async(build)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
build.track_deployment_usage
+ build.track_verify_usage
if build.failed? && !build.auto_retry_expected?
::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
@@ -57,15 +58,7 @@ module Ci
# See https://gitlab.com/gitlab-org/gitlab/-/issues/267112 for more
# details.
#
- archive_trace_worker_class(build).perform_in(ARCHIVE_TRACES_IN, build.id)
- end
-
- def archive_trace_worker_class(build)
- if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project)
- Ci::ArchiveTraceWorker
- else
- ::ArchiveTraceWorker
- end
+ Ci::ArchiveTraceWorker.perform_in(ARCHIVE_TRACES_IN, build.id)
end
end
end
diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
index 8ee518e3ae6..127eb3b6f44 100644
--- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
@@ -15,8 +15,14 @@ module Ci
idempotent!
def perform(pipeline_id)
- Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
- Ci::PipelineArtifacts::CoverageReportService.new(pipeline).execute
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
+
+ return unless pipeline
+
+ pipeline.root_ancestor.try do |root_ancestor_pipeline|
+ next unless root_ancestor_pipeline.self_and_descendants_complete?
+
+ Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute
end
end
end
diff --git a/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb
new file mode 100644
index 00000000000..035b2563e56
--- /dev/null
+++ b/app/workers/ci/runners/reconcile_existing_runner_versions_cron_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ReconcileExistingRunnerVersionsCronWorker
+ include ApplicationWorker
+
+ # This worker does not schedule other workers that require context.
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :sticky
+ feature_category :runner_fleet
+ urgency :low
+
+ idempotent!
+
+ def perform
+ result = ::Ci::Runners::ReconcileExistingRunnerVersionsService.new.execute
+ result.each { |key, value| log_extra_metadata_on_done(key, value) }
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb
deleted file mode 100644
index abc84bcd093..00000000000
--- a/app/workers/clusters/applications/activate_service_worker.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-# This worker was renamed in 15.1, we can delete it in 15.2.
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112
-#
-# rubocop:disable Scalability/IdempotentWorker
-module Clusters
- module Applications
- class ActivateServiceWorker < ActivateIntegrationWorker
- end
- end
-end
diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb
deleted file mode 100644
index 88219b8b17e..00000000000
--- a/app/workers/clusters/applications/deactivate_service_worker.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-# This worker was renamed in 15.1, we can delete it in 15.2.
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112
-#
-# rubocop:disable Scalability/IdempotentWorker
-module Clusters
- module Applications
- class DeactivateServiceWorker < DeactivateIntegrationWorker
- end
- end
-end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index e1f404b250d..c2cd50d8c21 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -23,6 +23,12 @@ module Gitlab
# client - An instance of `Gitlab::GithubImport::Client`
# hash - A Hash containing the details of the object to import.
def import(project, client, hash)
+ if project.import_state&.canceled?
+ info(project.id, message: 'project import canceled')
+
+ return
+ end
+
object = representation_class.from_json_hash(hash)
# To better express in the logs what object is being imported.
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
index 225716f6bf3..b12c2311ea8 100644
--- a/app/workers/concerns/gitlab/github_import/stage_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -9,6 +9,12 @@ module Gitlab
return unless (project = find_project(project_id))
+ if project.import_state&.canceled?
+ info(project_id, message: 'project import canceled')
+
+ return
+ end
+
client = GithubImport.new_client_for(project)
try_import(client, project)
diff --git a/app/workers/concerns/packages/cleanup_artifact_worker.rb b/app/workers/concerns/packages/cleanup_artifact_worker.rb
index a01d7e8abba..7e647ddd229 100644
--- a/app/workers/concerns/packages/cleanup_artifact_worker.rb
+++ b/app/workers/concerns/packages/cleanup_artifact_worker.rb
@@ -9,14 +9,21 @@ module Packages
def perform_work
return unless artifact
- artifact.transaction do
- log_metadata(artifact)
+ begin
+ artifact.transaction do
+ log_metadata(artifact)
- artifact.destroy!
- rescue StandardError
+ artifact.destroy!
+ end
+ rescue StandardError => exception
unless artifact&.destroyed?
artifact&.update_column(:status, :error)
end
+
+ Gitlab::ErrorTracking.log_exception(
+ exception,
+ class: self.class.name
+ )
end
after_destroy
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
index f8b945b8892..336d60d46ac 100644
--- a/app/workers/concerns/waitable_worker.rb
+++ b/app/workers/concerns/waitable_worker.rb
@@ -5,25 +5,13 @@ module WaitableWorker
class_methods do
# Schedules multiple jobs and waits for them to be completed.
- def bulk_perform_and_wait(args_list, timeout: 10)
+ def bulk_perform_and_wait(args_list)
# Short-circuit: it's more efficient to do small numbers of jobs inline
- return bulk_perform_inline(args_list) if args_list.size <= 3
-
- # Don't wait if there's too many jobs to be waited for. Not including the
- # waiter allows them to be deduplicated and it skips waiting for jobs that
- # are not likely to finish within the timeout. This assumes we can process
- # 10 jobs per second:
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/205
- return bulk_perform_async(args_list) if args_list.length >= 10 * timeout
-
- waiter = Gitlab::JobWaiter.new(args_list.size, worker_label: self.to_s)
-
- # Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
- # into [[1, "key"], [2, "key"], [3, "key"]]
- waiting_args_list = args_list.map { |args| [*args, waiter.key] }
- bulk_perform_async(waiting_args_list)
+ if args_list.size == 1
+ return bulk_perform_inline(args_list)
+ end
- waiter.wait(timeout)
+ bulk_perform_async(args_list)
end
# Performs multiple jobs directly. Failed jobs will be put into sidekiq so
diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb
index f3c8dfa63ad..1dd29eff86e 100644
--- a/app/workers/container_registry/migration/enqueuer_worker.rb
+++ b/app/workers/container_registry/migration/enqueuer_worker.rb
@@ -125,17 +125,18 @@ module ContainerRegistry
def next_repository
strong_memoize(:next_repository) do
- # Using .limit(2)[0] instead of take here. Using a LIMIT 1 caused the query planner to
- # use an inefficient sequential scan instead of picking an index. LIMIT 2 works around
+ # Using .limit(25)[0] instead of take here. Using a LIMIT 1 and 2 caused the query planner to
+ # use an inefficient sequential scan instead of picking an index. LIMIT 25 works around
# this issue.
- # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87733 for details.
- ContainerRepository.ready_for_import.limit(2)[0] # rubocop:disable CodeReuse/ActiveRecord
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87733 and
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90735 for details.
+ ContainerRepository.ready_for_import.limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord
end
end
def next_aborted_repository
strong_memoize(:next_aborted_repository) do
- ContainerRepository.with_migration_state('import_aborted').limit(2)[0] # rubocop:disable CodeReuse/ActiveRecord
+ ContainerRepository.with_migration_state('import_aborted').limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord
end
end
diff --git a/app/workers/deployments/hooks_worker.rb b/app/workers/deployments/hooks_worker.rb
index 608601b4eb9..62e75638c7d 100644
--- a/app/workers/deployments/hooks_worker.rb
+++ b/app/workers/deployments/hooks_worker.rb
@@ -16,7 +16,7 @@ module Deployments
log_extra_metadata_on_done(:deployment_project_id, deploy.project.id)
log_extra_metadata_on_done(:deployment_id, params[:deployment_id])
- deploy.execute_hooks(params[:status_changed_at].to_time)
+ deploy.execute_hooks(params[:status], params[:status_changed_at].to_time)
end
end
end
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 06f0ef623c2..70d18d8004c 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -23,6 +23,7 @@ module Gitlab
pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker,
pull_request_reviews: Stage::ImportPullRequestsReviewsWorker,
issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
+ issue_events: Stage::ImportIssueEventsWorker,
notes: Stage::ImportNotesWorker,
lfs_objects: Stage::ImportLfsObjectsWorker,
finish: Stage::FinishImportWorker
diff --git a/app/workers/gitlab/github_import/import_issue_event_worker.rb b/app/workers/gitlab/github_import/import_issue_event_worker.rb
new file mode 100644
index 00000000000..d7071d3ee09
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_issue_event_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportIssueEventWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::IssueEvent
+ end
+
+ def importer_class
+ Importer::IssueEventImporter
+ end
+
+ def object_type
+ :issue_event
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb
new file mode 100644
index 00000000000..8155b910677
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportIssueEventsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ importer = ::Gitlab::GithubImport::Importer::SingleEndpointIssueEventsImporter
+ return skip_to_next_stage(project, importer) if feature_disabled?(project)
+
+ start_importer(project, importer, client)
+ end
+
+ private
+
+ def start_importer(project, importer, client)
+ info(project.id, message: "starting importer", importer: importer.name)
+ waiter = importer.new(project, client).execute
+ move_to_next_stage(project, waiter.key => waiter.jobs_remaining)
+ end
+
+ def skip_to_next_stage(project, importer)
+ info(project.id, message: "skipping importer", importer: importer.name)
+ move_to_next_stage(project)
+ end
+
+ def move_to_next_stage(project, waiters = {})
+ AdvanceStageWorker.perform_async(project.id, waiters, :notes)
+ end
+
+ def feature_disabled?(project)
+ Feature.disabled?(:github_importer_issue_events_import, project.group, type: :ops)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
index 34996b710d4..7922c1113c4 100644
--- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
@@ -21,7 +21,7 @@ module Gitlab
hash[waiter.key] = waiter.jobs_remaining
end
- AdvanceStageWorker.perform_async(project.id, waiters, :notes)
+ AdvanceStageWorker.perform_async(project.id, waiters, :issue_events)
end
# The importers to run in this stage. Issues can't be imported earlier
diff --git a/app/workers/gitlab_service_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb
index 0f7b3ba56a5..a974667e5e0 100644
--- a/app/workers/gitlab_service_ping_worker.rb
+++ b/app/workers/gitlab_service_ping_worker.rb
@@ -30,8 +30,6 @@ class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker
end
def usage_data
- return unless Feature.enabled?(:prerecord_service_ping_data)
-
ServicePing::BuildPayload.new.execute.tap do |payload|
record = {
recorded_at: payload[:recorded_at],
diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
new file mode 100644
index 00000000000..3c15c59b8d9
--- /dev/null
+++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class CreateCloudsqlInstanceWorker
+ include ApplicationWorker
+
+ data_consistency :always
+ feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
+ idempotent!
+
+ def perform(user_id, project_id, options = {})
+ user = User.find(user_id)
+ project = Project.find(project_id)
+
+ google_oauth2_token = options[:google_oauth2_token]
+ gcp_project_id = options[:gcp_project_id]
+ instance_name = options[:instance_name]
+ database_version = options[:database_version]
+ environment_name = options[:environment_name]
+ is_protected = options[:is_protected]
+
+ params = {
+ google_oauth2_token: google_oauth2_token,
+ gcp_project_id: gcp_project_id,
+ instance_name: instance_name,
+ database_version: database_version,
+ environment_name: environment_name,
+ is_protected: is_protected
+ }
+
+ response = GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
+
+ if response[:status] == :error
+ raise response[:message]
+ end
+ end
+ end
+end
diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb
new file mode 100644
index 00000000000..7d45a6785ea
--- /dev/null
+++ b/app/workers/incident_management/close_incident_worker.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class CloseIncidentWorker
+ include ApplicationWorker
+
+ idempotent!
+ deduplicate :until_executed
+ data_consistency :always
+ feature_category :incident_management
+ urgency :low
+
+ # Issues:CloseService execute webhooks which are treated as external dependencies
+ worker_has_external_dependencies!
+
+ def perform(issue_id)
+ incident = Issue.incident.opened.find_by_id(issue_id)
+
+ return unless incident
+
+ close_incident(incident)
+ add_system_note(incident)
+ end
+
+ private
+
+ def user
+ @user ||= User.alert_bot
+ end
+
+ def close_incident(incident)
+ ::Issues::CloseService
+ .new(project: incident.project, current_user: user)
+ .execute(incident, system_note: false)
+ end
+
+ def add_system_note(incident)
+ return unless incident.reset.closed?
+
+ SystemNoteService.auto_resolve_prometheus_alert(incident, incident.project, user)
+ end
+ end
+end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
deleted file mode 100644
index a054021e418..00000000000
--- a/app/workers/irker_worker.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-# This worker was renamed in 15.1, we can delete it in 15.2.
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112
-#
-# rubocop: disable Gitlab/NamespacedClass
-# rubocop:disable Scalability/IdempotentWorker
-class IrkerWorker < Integrations::IrkerWorker
-end
diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb
index 0d04c503fbf..0a3a834578a 100644
--- a/app/workers/loose_foreign_keys/cleanup_worker.rb
+++ b/app/workers/loose_foreign_keys/cleanup_worker.rb
@@ -34,7 +34,7 @@ module LooseForeignKeys
# If two DBs are configured (Main, CI): minute 1 -> Main, minute 2 -> CI
def current_connection_name_and_base_model
minutes_since_epoch = Time.current.to_i / 60
- connections_with_name = Gitlab::Database.database_base_models.to_a # this will never be empty
+ connections_with_name = Gitlab::Database.database_base_models_with_gitlab_shared.to_a # this will never be empty
connections_with_name[minutes_since_epoch % connections_with_name.count]
end
end
diff --git a/app/workers/packages/cleanup/execute_policy_worker.rb b/app/workers/packages/cleanup/execute_policy_worker.rb
new file mode 100644
index 00000000000..59f0f0250c8
--- /dev/null
+++ b/app/workers/packages/cleanup/execute_policy_worker.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Packages
+ module Cleanup
+ class ExecutePolicyWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+
+ data_consistency :always
+ queue_namespace :package_cleanup
+ feature_category :package_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ COUNTS_KEYS = %i[
+ marked_package_files_total_count
+ unique_package_id_and_file_name_total_count
+ ].freeze
+
+ def perform_work
+ return unless next_policy
+
+ log_extra_metadata_on_done(:project_id, next_policy.project_id)
+ result = ::Packages::Cleanup::ExecutePolicyService.new(next_policy).execute
+
+ if result.success?
+ timeout = !!result.payload[:timeout]
+ counts = result.payload[:counts]
+ log_extra_metadata_on_done(:execution_timeout, timeout)
+ COUNTS_KEYS.each do |count_key|
+ log_extra_metadata_on_done(count_key, counts[count_key])
+ end
+ end
+ end
+
+ def remaining_work_count
+ ::Packages::Cleanup::Policy.runnable
+ .limit(max_running_jobs + 1)
+ .count
+ end
+
+ def max_running_jobs
+ ::Gitlab::CurrentSettings.package_registry_cleanup_policies_worker_capacity
+ end
+
+ private
+
+ def next_policy
+ strong_memoize(:next_policy) do
+ ::Packages::Cleanup::Policy.transaction do
+ # the #lock call is specific to this worker
+ # rubocop: disable CodeReuse/ActiveRecord
+ policy = ::Packages::Cleanup::Policy.runnable
+ .limit(1)
+ .lock('FOR UPDATE SKIP LOCKED')
+ .first
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ next nil unless policy
+
+ policy.set_next_run_at
+ policy.save!
+
+ policy
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/packages/cleanup_package_registry_worker.rb b/app/workers/packages/cleanup_package_registry_worker.rb
index a849e055b64..5f14102b5a1 100644
--- a/app/workers/packages/cleanup_package_registry_worker.rb
+++ b/app/workers/packages/cleanup_package_registry_worker.rb
@@ -12,6 +12,7 @@ module Packages
def perform
enqueue_package_file_cleanup_job if Packages::PackageFile.pending_destruction.exists?
+ enqueue_cleanup_policy_jobs if Packages::Cleanup::Policy.runnable.exists?
log_counts
end
@@ -22,6 +23,10 @@ module Packages
Packages::CleanupPackageFileWorker.perform_with_capacity
end
+ def enqueue_cleanup_policy_jobs
+ Packages::Cleanup::ExecutePolicyWorker.perform_with_capacity
+ end
+
def log_counts
use_replica_if_available do
pending_destruction_package_files_count = Packages::PackageFile.pending_destruction.count
@@ -31,6 +36,9 @@ module Packages
log_extra_metadata_on_done(:pending_destruction_package_files_count, pending_destruction_package_files_count)
log_extra_metadata_on_done(:processing_package_files_count, processing_package_files_count)
log_extra_metadata_on_done(:error_package_files_count, error_package_files_count)
+
+ pending_cleanup_policies_count = Packages::Cleanup::Policy.runnable.count
+ log_extra_metadata_on_done(:pending_cleanup_policies_count, pending_cleanup_policies_count)
end
end
diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb
index 1eff3ea02dd..822fe5a1517 100644
--- a/app/workers/packages/debian/generate_distribution_worker.rb
+++ b/app/workers/packages/debian/generate_distribution_worker.rb
@@ -4,6 +4,7 @@ module Packages
module Debian
class GenerateDistributionWorker
include ApplicationWorker
+ include ::Packages::FIPS
data_consistency :always
include Gitlab::Utils::StrongMemoize
@@ -20,6 +21,8 @@ module Packages
loggable_arguments 0
def perform(container_type, distribution_id)
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
+
@container_type = container_type
@distribution_id = distribution_id
diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb
index 0a716c61203..d477a6f2e1f 100644
--- a/app/workers/packages/debian/process_changes_worker.rb
+++ b/app/workers/packages/debian/process_changes_worker.rb
@@ -4,6 +4,7 @@ module Packages
module Debian
class ProcessChangesWorker
include ApplicationWorker
+ include ::Packages::FIPS
data_consistency :always
include Gitlab::Utils::StrongMemoize
@@ -15,6 +16,8 @@ module Packages
feature_category :package_registry
def perform(package_file_id, user_id)
+ raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled?
+
@package_file_id = package_file_id
@user_id = user_id
@@ -22,6 +25,8 @@ module Packages
::Packages::Debian::ProcessChangesService.new(package_file, user).execute
rescue StandardError => e
+ raise if e.instance_of?(DisabledError)
+
Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id)
package_file.destroy!
end
diff --git a/app/workers/pages/invalidate_domain_cache_worker.rb b/app/workers/pages/invalidate_domain_cache_worker.rb
new file mode 100644
index 00000000000..63b6f5c05b5
--- /dev/null
+++ b/app/workers/pages/invalidate_domain_cache_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Pages
+ class InvalidateDomainCacheWorker
+ include Gitlab::EventStore::Subscriber
+
+ idempotent!
+
+ feature_category :pages
+
+ def handle_event(event)
+ if event.data[:project_id]
+ ::Gitlab::Pages::CacheControl
+ .for_project(event.data[:project_id])
+ .clear_cache
+ end
+
+ if event.data[:root_namespace_id]
+ ::Gitlab::Pages::CacheControl
+ .for_namespace(event.data[:root_namespace_id])
+ .clear_cache
+ end
+ end
+ end
+end
diff --git a/app/workers/pages_transfer_worker.rb b/app/workers/pages_transfer_worker.rb
deleted file mode 100644
index 6d3918e7ab6..00000000000
--- a/app/workers/pages_transfer_worker.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-class PagesTransferWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- TransferFailedError = Class.new(StandardError)
-
- feature_category :pages
- loggable_arguments 0, 1
-
- def perform(method, args)
- # noop
- # This worker is not necessary anymore and will be removed
- # https://gitlab.com/gitlab-org/gitlab/-/issues/340616
- end
-end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 45af15216fc..68a0934e2b7 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -131,11 +131,24 @@ class PostReceive
repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs)
SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks)
Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes)
+ emit_snowplow_event(project, user)
end
def log(message)
Gitlab::GitLogger.error("POST-RECEIVE: #{message}")
end
+
+ def emit_snowplow_event(project, user)
+ return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace)
+
+ Gitlab::Tracking.event(
+ 'PostReceive',
+ 'source_code_pushes',
+ project: project,
+ namespace: project.namespace,
+ user: user
+ )
+ end
end
PostReceive.prepend_mod_with('PostReceive')
diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb
deleted file mode 100644
index 56ac4bc046a..00000000000
--- a/app/workers/project_service_worker.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-# This worker was renamed in 15.1, we can delete it in 15.2.
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/364112
-#
-# rubocop: disable Gitlab/NamespacedClass
-# rubocop: disable Scalability/IdempotentWorker
-class ProjectServiceWorker < Integrations::ExecuteWorker
- data_consistency :always
- sidekiq_options retry: 3
- sidekiq_options dead: false
- feature_category :integrations
- urgency :low
-
- worker_has_external_dependencies!
-end
diff --git a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
index a91af72cc2c..705bf0534f7 100644
--- a/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
+++ b/app/workers/projects/refresh_build_artifacts_size_statistics_worker.rb
@@ -5,10 +5,6 @@ module Projects
include ApplicationWorker
include LimitedCapacity::Worker
- MAX_RUNNING_LOW = 2
- MAX_RUNNING_MEDIUM = 20
- MAX_RUNNING_HIGH = 50
-
data_consistency :always
feature_category :build_artifacts
@@ -37,12 +33,8 @@ module Projects
end
def max_running_jobs
- if ::Feature.enabled?(:projects_build_artifacts_size_refresh_high)
- MAX_RUNNING_HIGH
- elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_medium)
- MAX_RUNNING_MEDIUM
- elsif ::Feature.enabled?(:projects_build_artifacts_size_refresh_low)
- MAX_RUNNING_LOW
+ if ::Feature.enabled?(:projects_build_artifacts_size_refresh, type: :ops)
+ 10
else
0
end
diff --git a/app/workers/web_hooks/destroy_worker.rb b/app/workers/web_hooks/destroy_worker.rb
deleted file mode 100644
index 8f9b194f88a..00000000000
--- a/app/workers/web_hooks/destroy_worker.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module WebHooks
- class DestroyWorker
- include ApplicationWorker
-
- DestroyError = Class.new(StandardError)
-
- data_consistency :always
- sidekiq_options retry: 3
- feature_category :integrations
- urgency :high
-
- idempotent!
-
- def perform(user_id, web_hook_id)
- user = User.find_by_id(user_id)
- hook = WebHook.find_by_id(web_hook_id)
-
- return unless user && hook
-
- result = ::WebHooks::DestroyService.new(user).sync_destroy(hook)
-
- result.track_and_raise_exception(as: DestroyError, web_hook_id: hook.id)
- end
- end
-end