summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-04-20 11:43:17 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-04-20 11:43:17 +0000
commitdfc94207fec2d84314b1a5410cface22e8b369bd (patch)
treec54022f61ced104305889a64de080998a0dc773b /app
parentb874efeff674f6bf0355d5d242ecf81c6f7155df (diff)
downloadgitlab-ce-dfc94207fec2d84314b1a5410cface22e8b369bd.tar.gz
Add latest changes from gitlab-org/gitlab@15-11-stable-eev15.11.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_canceled.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_created.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_failed.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_manual.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_not_found.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_pending.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_preparing.icobin34494 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_running.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_scheduled.icobin5430 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_skipped.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_success.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_warning.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/mr_favicons/favicon_status_merged.pngbin0 -> 326 bytes
-rw-r--r--app/assets/javascripts/access_level/constants.js20
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue132
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/token.vue28
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/actions.js28
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/index.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/state.js1
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue148
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue66
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue54
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue4
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js42
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js5
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue38
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue34
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue16
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue24
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue77
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue3
-rw-r--r--app/assets/javascripts/alert_management/constants.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js3
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/bundle.js1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue10
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/utils.js28
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue1
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js22
-rw-r--r--app/assets/javascripts/api/projects_api.js9
-rw-r--r--app/assets/javascripts/api/user_api.js22
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue182
-rw-r--r--app/assets/javascripts/authentication/password/components/password_input.vue82
-rw-r--r--app/assets/javascripts/authentication/password/constants.js8
-rw-r--r--app/assets/javascripts/authentication/password/index.js32
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue8
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js2
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue23
-rw-r--r--app/assets/javascripts/batch_comments/index.js4
-rw-r--r--app/assets/javascripts/behaviors/date_picker.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js23
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js6
-rw-r--r--app/assets/javascripts/blob/sketch_viewer.js3
-rw-r--r--app/assets/javascripts/blob/template_selector.js3
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js5
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue39
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue141
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue1
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue38
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql7
-rw-r--r--app/assets/javascripts/build_artifacts.js3
-rw-r--r--app/assets/javascripts/ci/artifacts/components/app.vue (renamed from app/assets/javascripts/artifacts/components/app.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue (renamed from app/assets/javascripts/artifacts/components/artifact_delete_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_row.vue (renamed from app/assets/javascripts/artifacts/components/artifact_row.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue65
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue (renamed from app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue73
-rw-r--r--app/assets/javascripts/ci/artifacts/components/feedback_banner.vue (renamed from app/assets/javascripts/artifacts/components/feedback_banner.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue (renamed from app/assets/javascripts/artifacts/components/job_artifacts_table.vue)106
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_checkbox.vue (renamed from app/assets/javascripts/artifacts/components/job_checkbox.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/constants.js (renamed from app/assets/javascripts/artifacts/constants.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/cache_update.js (renamed from app/assets/javascripts/artifacts/graphql/cache_update.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql (renamed from app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql (renamed from app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/index.js (renamed from app/assets/javascripts/artifacts/index.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/utils.js (renamed from app/assets/javascripts/artifacts/utils.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue97
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue17
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue6
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue42
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue14
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue104
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue105
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js70
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue110
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js31
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js17
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue21
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/index.js12
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutations.js10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/state.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue7
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue14
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue44
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue31
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/utils.js38
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_create_form.vue35
-rw-r--r--app/assets/javascripts/ci/runner/constants.js2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql5
-rw-r--r--app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue98
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/index.js33
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue19
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js2
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue42
-rw-r--r--app/assets/javascripts/comment_templates/components/app.vue (renamed from app/assets/javascripts/saved_replies/components/app.vue)4
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue (renamed from app/assets/javascripts/saved_replies/components/form.vue)40
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue (renamed from app/assets/javascripts/saved_replies/components/list.vue)6
-rw-r--r--app/assets/javascripts/comment_templates/components/list_item.vue116
-rw-r--r--app/assets/javascripts/comment_templates/index.js (renamed from app/assets/javascripts/saved_replies/index.js)4
-rw-r--r--app/assets/javascripts/comment_templates/pages/edit.vue (renamed from app/assets/javascripts/saved_replies/pages/edit.vue)4
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue (renamed from app/assets/javascripts/saved_replies/pages/index.vue)2
-rw-r--r--app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql (renamed from app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql (renamed from app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/routes.js (renamed from app/assets/javascripts/saved_replies/routes.js)0
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/constants.js3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue140
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue15
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue168
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue135
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue61
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue109
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue129
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue45
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference_label.vue (renamed from app/assets/javascripts/content_editor/components/wrappers/label.vue)0
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js30
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js50
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js3
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js16
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue22
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue6
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js8
-rw-r--r--app/assets/javascripts/deprecated_notes.js24
-rw-r--r--app/assets/javascripts/design_management/index.js4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue9
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js9
-rw-r--r--app/assets/javascripts/diffs/components/app.vue136
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue32
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality_item.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue40
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue12
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue22
-rw-r--r--app/assets/javascripts/diffs/components/shared/findings_drawer.vue110
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue12
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/i18n.js6
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js110
-rw-r--r--app/assets/javascripts/diffs/store/utils.js6
-rw-r--r--app/assets/javascripts/diffs/utils/merge_request.js13
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue2
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js40
-rw-r--r--app/assets/javascripts/editor/schema/ci.json4
-rw-r--r--app/assets/javascripts/emoji/index.js4
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js3
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue49
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue39
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_pods.vue111
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue16
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue93
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js6
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue6
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue8
-rw-r--r--app/assets/javascripts/environments/graphql/client.js9
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql6
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql27
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js14
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql14
-rw-r--r--app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js56
-rw-r--r--app/assets/javascripts/environments/index.js3
-rw-r--r--app/assets/javascripts/environments/mount_show.js4
-rw-r--r--app/assets/javascripts/error_tracking/utils.js12
-rw-r--r--app/assets/javascripts/featurable/constants.js6
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue4
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js16
-rw-r--r--app/assets/javascripts/filtered_search/droplab/utils.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js12
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js4
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js1
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js43
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json5
-rw-r--r--app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql)4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue1
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue8
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue16
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/groups/index.js2
-rw-r--r--app/assets/javascripts/groups/init_group_readme.js26
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/settings/components/group_settings_readme.vue147
-rw-r--r--app/assets/javascripts/groups/settings/constants.js4
-rw-r--r--app/assets/javascripts/groups/settings/init_group_settings_readme.js24
-rw-r--r--app/assets/javascripts/header.js6
-rw-r--r--app/assets/javascripts/header_search/components/app.vue47
-rw-r--r--app/assets/javascripts/header_search/constants.js2
-rw-r--r--app/assets/javascripts/header_search/init.js36
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue2
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js1
-rw-r--r--app/assets/javascripts/ide/lib/languages/codeowners.js39
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js5
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/import/constants.js28
-rw-r--r--app/assets/javascripts/import/details/components/import_details_app.vue25
-rw-r--r--app/assets/javascripts/import/details/components/import_details_table.vue106
-rw-r--r--app/assets/javascripts/import/details/index.js18
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue66
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue26
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue88
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js4
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue8
-rw-r--r--app/assets/javascripts/incidents/constants.js9
-rw-r--r--app/assets/javascripts/init_diff_stats_dropdown.js10
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_notification.vue20
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue8
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue100
-rw-r--r--app/assets/javascripts/invite_members/constants.js21
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js10
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue7
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue9
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js35
-rw-r--r--app/assets/javascripts/issuable/mixins/related_issuable_mixin.js7
-rw-r--r--app/assets/javascripts/issues/constants.js3
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/issues/index.js7
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue2
-rw-r--r--app/assets/javascripts/issues/list/constants.js4
-rw-r--r--app/assets/javascripts/issues/list/graphql.js14
-rw-r--r--app/assets/javascripts/issues/list/index.js8
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions_item.vue3
-rw-r--r--app/assets/javascripts/issues/new/components/type_select.vue113
-rw-r--r--app/assets/javascripts/issues/new/index.js27
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue25
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue36
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue78
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue17
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue11
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js3
-rw-r--r--app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js97
-rw-r--r--app/assets/javascripts/lib/apollo/local_db.js14
-rw-r--r--app/assets/javascripts/lib/graphql.js64
-rw-r--r--app/assets/javascripts/lib/mermaid.js5
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js38
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js1
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js33
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js45
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js78
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js105
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vuex.js38
-rw-r--r--app/assets/javascripts/lib/utils/web_ide_navigator.js24
-rw-r--r--app/assets/javascripts/locale/ensure_single_line.cjs2
-rw-r--r--app/assets/javascripts/main.js6
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue13
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue23
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestones/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue98
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue27
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js5
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue47
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue16
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js5
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/monitoring/utils.js9
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/actions.js5
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/getters.js1
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/index.js13
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js3
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutations.js7
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js2
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue4
-rw-r--r--app/assets/javascripts/new_branch_form.js5
-rw-r--r--app/assets/javascripts/new_commit_form.js3
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe.vue46
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe_util.js44
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue14
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue119
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue12
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue13
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue2
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js30
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js10
-rw-r--r--app/assets/javascripts/notes/utils.js5
-rw-r--r--app/assets/javascripts/oauth_application/components/oauth_secret.vue106
-rw-r--r--app/assets/javascripts/oauth_application/constants.js20
-rw-r--r--app/assets/javascripts/oauth_application/index.js21
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue15
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue86
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue71
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue31
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue37
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue92
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue96
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue80
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql33
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql38
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue41
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js9
-rw-r--r--app/assets/javascripts/pages/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/applications/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue)0
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue)0
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/constants.js (renamed from app/assets/javascripts/pages/admin/jobs/index/components/constants.js)9
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue118
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js62
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql81
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue19
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js17
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue5
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js6
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-rw-r--r--app/assets/javascripts/pages/import/github/details/index.js3
-rw-r--r--app/assets/javascripts/pages/oauth/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/comment_templates/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/saved_replies/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js57
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js6
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project.js139
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js12
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue5
-rw-r--r--app/assets/javascripts/pages/time_tracking/timelogs/index.js3
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue105
-rw-r--r--app/assets/javascripts/performance_bar/index.js16
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js9
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js35
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js6
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue152
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue80
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql24
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js5
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js15
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue30
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue27
-rw-r--r--app/assets/javascripts/profile/gl_crop.js17
-rw-r--r--app/assets/javascripts/profile/index.js4
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/commits/index.js16
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue18
-rw-r--r--app/assets/javascripts/projects/new/index.js2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue8
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue11
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue8
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue4
-rw-r--r--app/assets/javascripts/protected_branches/constants.js6
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js63
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue8
-rw-r--r--app/assets/javascripts/ref/stores/actions.js4
-rw-r--r--app/assets/javascripts/ref/stores/index.js8
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js3
-rw-r--r--app/assets/javascripts/ref/stores/state.js1
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue6
-rw-r--r--app/assets/javascripts/releases/components/tag_create.vue91
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue276
-rw-r--r--app/assets/javascripts/releases/components/tag_search.vue121
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/constants.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js20
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js14
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js4
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue15
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue96
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue28
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue11
-rw-r--r--app/assets/javascripts/repository/constants.js2
-rw-r--r--app/assets/javascripts/repository/event_hub.js3
-rw-r--r--app/assets/javascripts/repository/index.js24
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue101
-rw-r--r--app/assets/javascripts/search/index.js1
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue21
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue28
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/data.js (renamed from app/assets/javascripts/search/sidebar/constants/language_filter_data.js)0
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/index.vue (renamed from app/assets/javascripts/search/sidebar/components/language_filter.vue)40
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/tracking.js39
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue4
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue14
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue40
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js4
-rw-r--r--app/assets/javascripts/search/sidebar/utils.js2
-rw-r--r--app/assets/javascripts/search/store/actions.js5
-rw-r--r--app/assets/javascripts/search/store/constants.js14
-rw-r--r--app/assets/javascripts/search/store/getters.js15
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/search/store/utils.js6
-rw-r--r--app/assets/javascripts/search_autocomplete.js520
-rw-r--r--app/assets/javascripts/search_autocomplete_utils.js19
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue9
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js45
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue58
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue27
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue40
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue69
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue13
-rw-r--r--app/assets/javascripts/sidebar/constants.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js16
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/sidebar/utils.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue59
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue29
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue379
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue147
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue42
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue79
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js15
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js82
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js6
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/utils.js81
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue98
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue7
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue97
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue104
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue74
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue135
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue91
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue80
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue120
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue66
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js34
-rw-r--r--app/assets/javascripts/super_sidebar/mock_data.js54
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js44
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js21
-rw-r--r--app/assets/javascripts/syntax_highlight.js3
-rw-r--r--app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql69
-rw-r--r--app/assets/javascripts/time_tracking/components/timelog_source_cell.vue50
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_app.vue229
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_table.vue105
-rw-r--r--app/assets/javascripts/time_tracking/index.js32
-rw-r--r--app/assets/javascripts/tracking/constants.js3
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js9
-rw-r--r--app/assets/javascripts/tracking/index.js12
-rw-r--r--app/assets/javascripts/tracking/tracker.js10
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue12
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue1
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue39
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js37
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql1
-rw-r--r--app/assets/javascripts/users_select/index.js11
-rw-r--r--app/assets/javascripts/visibility_level/constants.js32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue97
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js104
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue78
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js13
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue4
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue (renamed from app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue)71
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js99
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue152
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/history_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue4
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue18
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue2
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue4
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue12
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue27
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js2
-rw-r--r--app/assets/javascripts/webhooks/components/test_dropdown.vue45
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue6
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue125
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue138
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue49
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue4
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue172
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue65
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue18
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue113
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue36
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue214
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue71
-rw-r--r--app/assets/javascripts/work_items/constants.js6
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql4
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/zen_mode.js3
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss3
-rw-r--r--app/assets/stylesheets/components/detail_page.scss (renamed from app/assets/stylesheets/pages/detail_page.scss)3
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss8
-rw-r--r--app/assets/stylesheets/components/whats_new.scss14
-rw-r--r--app/assets/stylesheets/framework/blocks.scss8
-rw-r--r--app/assets/stylesheets/framework/calendar.scss53
-rw-r--r--app/assets/stylesheets/framework/common.scss35
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss8
-rw-r--r--app/assets/stylesheets/framework/diffs.scss57
-rw-r--r--app/assets/stylesheets/framework/files.scss26
-rw-r--r--app/assets/stylesheets/framework/filters.scss21
-rw-r--r--app/assets/stylesheets/framework/flash.scss16
-rw-r--r--app/assets/stylesheets/framework/header.scss72
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/layout.scss10
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss3
-rw-r--r--app/assets/stylesheets/framework/mixins.scss20
-rw-r--r--app/assets/stylesheets/framework/page_title.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss65
-rw-r--r--app/assets/stylesheets/framework/sortable.scss9
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss23
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss113
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss55
-rw-r--r--app/assets/stylesheets/framework/tables.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss29
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/issuable_list.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss (renamed from app/assets/stylesheets/pages/login.scss)3
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss109
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss36
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss36
-rw-r--r--app/assets/stylesheets/page_bundles/projects_usage_quotas.scss (renamed from app/assets/stylesheets/pages/storage_quota.scss)4
-rw-r--r--app/assets/stylesheets/page_bundles/releases.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/search.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss51
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss24
-rw-r--r--app/assets/stylesheets/pages/commits.scss15
-rw-r--r--app/assets/stylesheets/pages/issues.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss22
-rw-r--r--app/assets/stylesheets/pages/notes.scss115
-rw-r--r--app/assets/stylesheets/pages/projects.scss2
-rw-r--r--app/assets/stylesheets/pages/registry.scss2
-rw-r--r--app/assets/stylesheets/pages/settings.scss37
-rw-r--r--app/assets/stylesheets/performance_bar.scss3
-rw-r--r--app/assets/stylesheets/print.scss6
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss115
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss93
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss238
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss16
-rw-r--r--app/assets/stylesheets/utilities.scss196
-rw-r--r--app/channels/awareness_channel.rb85
-rw-r--r--app/components/diffs/overflow_warning_component.html.haml2
-rw-r--r--app/components/diffs/overflow_warning_component.rb4
-rw-r--r--app/controllers/abuse_reports_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb4
-rw-r--r--app/controllers/admin/applications_controller.rb5
-rw-r--r--app/controllers/admin/background_migrations_controller.rb1
-rw-r--r--app/controllers/admin/ci/variables_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb2
-rw-r--r--app/controllers/admin/projects_controller.rb7
-rw-r--r--app/controllers/admin/users_controller.rb14
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/clusters/base_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb5
-rw-r--r--app/controllers/concerns/access_tokens_actions.rb2
-rw-r--r--app/controllers/concerns/integrations/params.rb3
-rw-r--r--app/controllers/concerns/issuable_actions.rb4
-rw-r--r--app/controllers/concerns/kas_cookie.rb12
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb40
-rw-r--r--app/controllers/concerns/renders_commits.rb2
-rw-r--r--app/controllers/concerns/renders_member_access.rb3
-rw-r--r--app/controllers/concerns/renders_projects_list.rb3
-rw-r--r--app/controllers/concerns/uploads_actions.rb2
-rw-r--r--app/controllers/dashboard/application_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb4
-rw-r--r--app/controllers/explore/projects_controller.rb4
-rw-r--r--app/controllers/google_api/authorizations_controller.rb2
-rw-r--r--app/controllers/groups/group_members_controller.rb4
-rw-r--r--app/controllers/groups/runners_controller.rb28
-rw-r--r--app/controllers/groups/settings/applications_controller.rb5
-rw-r--r--app/controllers/groups/usage_quotas_controller.rb2
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb12
-rw-r--r--app/controllers/import/github_controller.rb8
-rw-r--r--app/controllers/invites_controller.rb6
-rw-r--r--app/controllers/oauth/applications_controller.rb5
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb4
-rw-r--r--app/controllers/profiles/chat_names_controller.rb7
-rw-r--r--app/controllers/profiles/comment_templates_controller.rb (renamed from app/controllers/profiles/saved_replies_controller.rb)2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb18
-rw-r--r--app/controllers/profiles_controller.rb4
-rw-r--r--app/controllers/projects/blame_controller.rb57
-rw-r--r--app/controllers/projects/blob_controller.rb3
-rw-r--r--app/controllers/projects/cluster_agents_controller.rb12
-rw-r--r--app/controllers/projects/commit_controller.rb6
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb16
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/incidents_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests_controller.rb11
-rw-r--r--app/controllers/projects/ml/candidates_controller.rb19
-rw-r--r--app/controllers/projects/ml/experiments_controller.rb40
-rw-r--r--app/controllers/projects/pages_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb42
-rw-r--r--app/controllers/projects/project_members_controller.rb10
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb18
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/work_items_controller.rb56
-rw-r--r--app/controllers/projects_controller.rb5
-rw-r--r--app/controllers/registrations_controller.rb14
-rw-r--r--app/controllers/time_tracking/timelogs_controller.rb12
-rw-r--r--app/controllers/uploads_controller.rb1
-rw-r--r--app/controllers/users/pins_controller.rb24
-rw-r--r--app/events/packages/package_created_event.rb23
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb22
-rw-r--r--app/experiments/security_actions_continuous_onboarding_experiment.rb9
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb6
-rw-r--r--app/finders/abuse_reports_finder.rb7
-rw-r--r--app/finders/access_requests_finder.rb6
-rw-r--r--app/finders/achievements/achievements_finder.rb29
-rw-r--r--app/finders/autocomplete/users_finder.rb4
-rw-r--r--app/finders/ci/runners_finder.rb2
-rw-r--r--app/finders/clusters/agent_authorizations_finder.rb69
-rw-r--r--app/finders/clusters/agent_tokens_finder.rb6
-rw-r--r--app/finders/clusters/agents/authorizations/ci_access/finder.rb75
-rw-r--r--app/finders/clusters/agents_finder.rb2
-rw-r--r--app/finders/concerns/finder_with_group_hierarchy.rb13
-rw-r--r--app/finders/concerns/updated_at_filter.rb8
-rw-r--r--app/finders/context_commits_finder.rb8
-rw-r--r--app/finders/data_transfer/group_data_transfer_finder.rb34
-rw-r--r--app/finders/data_transfer/mocked_transfer_finder.rb27
-rw-r--r--app/finders/data_transfer/project_data_transfer_finder.rb25
-rw-r--r--app/finders/deployments_finder.rb6
-rw-r--r--app/finders/fork_targets_finder.rb2
-rw-r--r--app/finders/group_descendants_finder.rb21
-rw-r--r--app/finders/group_members_finder.rb54
-rw-r--r--app/finders/groups/accepting_project_creations_finder.rb105
-rw-r--r--app/finders/groups/accepting_project_shares_finder.rb22
-rw-r--r--app/finders/groups/user_groups_finder.rb2
-rw-r--r--app/finders/labels_finder.rb9
-rw-r--r--app/finders/members_finder.rb6
-rw-r--r--app/finders/notes_finder.rb8
-rw-r--r--app/finders/packages/npm/package_finder.rb6
-rw-r--r--app/finders/projects_finder.rb2
-rw-r--r--app/finders/security/security_jobs_finder.rb2
-rw-r--r--app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb22
-rw-r--r--app/graphql/gitlab_schema.rb2
-rw-r--r--app/graphql/graphql_triggers.rb28
-rw-r--r--app/graphql/mutations/achievements/delete.rb33
-rw-r--r--app/graphql/mutations/achievements/update.rb46
-rw-r--r--app/graphql/mutations/award_emojis/base.rb2
-rw-r--r--app/graphql/mutations/boards/update.rb6
-rw-r--r--app/graphql/mutations/ci/runner/common_mutation_arguments.rb5
-rw-r--r--app/graphql/mutations/ci/runner/create.rb79
-rw-r--r--app/graphql/mutations/ci/runner/delete.rb6
-rw-r--r--app/graphql/mutations/ci/runner/update.rb11
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/create.rb6
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/revoke.rb6
-rw-r--r--app/graphql/mutations/clusters/agents/delete.rb6
-rw-r--r--app/graphql/mutations/concerns/mutations/finds_by_gid.rb9
-rw-r--r--app/graphql/mutations/concerns/mutations/finds_namespace.rb11
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb8
-rw-r--r--app/graphql/mutations/container_repositories/destroy_base.rb6
-rw-r--r--app/graphql/mutations/design_management/update.rb6
-rw-r--r--app/graphql/mutations/discussions/toggle_resolve.rb4
-rw-r--r--app/graphql/mutations/environments/canary_ingress/update.rb4
-rw-r--r--app/graphql/mutations/members/projects/bulk_update.rb3
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb4
-rw-r--r--app/graphql/mutations/notes/base.rb6
-rw-r--r--app/graphql/mutations/notes/create/base.rb4
-rw-r--r--app/graphql/mutations/packages/destroy.rb6
-rw-r--r--app/graphql/mutations/packages/destroy_file.rb6
-rw-r--r--app/graphql/mutations/projects/sync_fork.rb17
-rw-r--r--app/graphql/mutations/release_asset_links/delete.rb6
-rw-r--r--app/graphql/mutations/release_asset_links/update.rb6
-rw-r--r--app/graphql/mutations/terraform/state/base.rb6
-rw-r--r--app/graphql/mutations/todos/base.rb13
-rw-r--r--app/graphql/mutations/todos/create.rb10
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb2
-rw-r--r--app/graphql/mutations/todos/mark_done.rb2
-rw-r--r--app/graphql/mutations/todos/restore.rb2
-rw-r--r--app/graphql/mutations/todos/restore_many.rb2
-rw-r--r--app/graphql/mutations/work_items/convert.rb72
-rw-r--r--app/graphql/mutations/work_items/create.rb30
-rw-r--r--app/graphql/mutations/work_items/create_from_task.rb6
-rw-r--r--app/graphql/mutations/work_items/delete.rb6
-rw-r--r--app/graphql/mutations/work_items/delete_task.rb5
-rw-r--r--app/graphql/mutations/work_items/update.rb4
-rw-r--r--app/graphql/resolvers/achievements/achievements_resolver.rb12
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb38
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb10
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb14
-rw-r--r--app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb21
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/all_jobs_resolver.rb25
-rw-r--r--app/graphql/resolvers/ci/runner_jobs_resolver.rb6
-rw-r--r--app/graphql/resolvers/ci/runner_projects_resolver.rb15
-rw-r--r--app/graphql/resolvers/ci/runner_status_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb1
-rw-r--r--app/graphql/resolvers/data_transfer/data_transfer_arguments.rb19
-rw-r--r--app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb34
-rw-r--r--app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb34
-rw-r--r--app/graphql/resolvers/data_transfer_resolver.rb57
-rw-r--r--app/graphql/resolvers/design_management/version_resolver.rb4
-rw-r--r--app/graphql/resolvers/down_votes_count_resolver.rb7
-rw-r--r--app/graphql/resolvers/group_labels_resolver.rb6
-rw-r--r--app/graphql/resolvers/kas/agent_configurations_resolver.rb2
-rw-r--r--app/graphql/resolvers/labels_resolver.rb13
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb4
-rw-r--r--app/graphql/resolvers/notes/synthetic_note_resolver.rb4
-rw-r--r--app/graphql/resolvers/paginated_tree_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb20
-rw-r--r--app/graphql/resolvers/projects/commit_parent_names_resolver.rb33
-rw-r--r--app/graphql/resolvers/projects/fork_details_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb25
-rw-r--r--app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb20
-rw-r--r--app/graphql/resolvers/timelog_resolver.rb2
-rw-r--r--app/graphql/resolvers/up_votes_count_resolver.rb7
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb6
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb5
-rw-r--r--app/graphql/subscriptions/base_subscription.rb12
-rw-r--r--app/graphql/subscriptions/issuable_updated.rb4
-rw-r--r--app/graphql/subscriptions/notes/base.rb4
-rw-r--r--app/graphql/types/achievements/user_achievement_type.rb2
-rw-r--r--app/graphql/types/branch_protections/base_access_level_type.rb2
-rw-r--r--app/graphql/types/ci/catalog/resource_type.rb27
-rw-r--r--app/graphql/types/ci/config/include_type_enum.rb1
-rw-r--r--app/graphql/types/ci/job_trace_type.rb19
-rw-r--r--app/graphql/types/ci/job_type.rb27
-rw-r--r--app/graphql/types/ci/runner_manager_type.rb (renamed from app/graphql/types/ci/runner_machine_type.rb)32
-rw-r--r--app/graphql/types/ci/runner_type.rb37
-rw-r--r--app/graphql/types/clusters/agent_activity_event_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_token_type.rb2
-rw-r--r--app/graphql/types/clusters/agent_type.rb2
-rw-r--r--app/graphql/types/data_transfer/base_type.rb2
-rw-r--r--app/graphql/types/data_transfer/egress_node_type.rb6
-rw-r--r--app/graphql/types/data_transfer/project_data_transfer_type.rb10
-rw-r--r--app/graphql/types/group_type.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb11
-rw-r--r--app/graphql/types/mutation_type.rb3
-rw-r--r--app/graphql/types/permission_types/work_item.rb3
-rw-r--r--app/graphql/types/project_type.rb39
-rw-r--r--app/graphql/types/projects/commit_parent_names_type.rb14
-rw-r--r--app/graphql/types/relative_position_type_enum.rb11
-rw-r--r--app/graphql/types/repository_type.rb2
-rw-r--r--app/graphql/types/timelog_type.rb4
-rw-r--r--app/graphql/types/work_item_type.rb5
-rw-r--r--app/graphql/types/work_items/available_export_fields_enum.rb1
-rw-r--r--app/graphql/types/work_items/award_emoji_update_action_enum.rb13
-rw-r--r--app/graphql/types/work_items/todo_update_action_enum.rb13
-rw-r--r--app/graphql/types/work_items/widget_interface.rb8
-rw-r--r--app/graphql/types/work_items/widgets/award_emoji_type.rb41
-rw-r--r--app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb20
-rw-r--r--app/graphql/types/work_items/widgets/current_user_todos_input_type.rb21
-rw-r--r--app/graphql/types/work_items/widgets/current_user_todos_type.rb26
-rw-r--r--app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb15
-rw-r--r--app/helpers/abuse_reports_helper.rb9
-rw-r--r--app/helpers/accounts_helper.rb2
-rw-r--r--app/helpers/admin/application_settings/settings_helper.rb4
-rw-r--r--app/helpers/admin/background_migrations_helper.rb1
-rw-r--r--app/helpers/application_helper.rb7
-rw-r--r--app/helpers/application_settings_helper.rb1
-rw-r--r--app/helpers/auth_helper.rb6
-rw-r--r--app/helpers/blame_helper.rb10
-rw-r--r--app/helpers/blob_helper.rb13
-rw-r--r--app/helpers/breadcrumbs_helper.rb2
-rw-r--r--app/helpers/ci/catalog/resources_helper.rb6
-rw-r--r--app/helpers/ci/pipelines_helper.rb6
-rw-r--r--app/helpers/clusters_helper.rb1
-rw-r--r--app/helpers/dashboard_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb31
-rw-r--r--app/helpers/ide_helper.rb3
-rw-r--r--app/helpers/issuables_helper.rb17
-rw-r--r--app/helpers/issues_helper.rb8
-rw-r--r--app/helpers/labels_helper.rb21
-rw-r--r--app/helpers/merge_requests_helper.rb28
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/packages_helper.rb5
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/projects/ml/experiments_helper.rb10
-rw-r--r--app/helpers/projects/pipeline_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb20
-rw-r--r--app/helpers/protected_branches_helper.rb2
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb5
-rw-r--r--app/helpers/search_helper.rb2
-rw-r--r--app/helpers/sidebars_helper.rb135
-rw-r--r--app/helpers/system_note_helper.rb12
-rw-r--r--app/helpers/users_helper.rb20
-rw-r--r--app/helpers/visibility_level_helper.rb33
-rw-r--r--app/helpers/work_items_helper.rb2
-rw-r--r--app/mailers/emails/profile.rb13
-rw-r--r--app/mailers/emails/service_desk.rb62
-rw-r--r--app/mailers/notify.rb8
-rw-r--r--app/mailers/previews/notify_preview.rb93
-rw-r--r--app/models/abuse/trust_score.rb37
-rw-r--r--app/models/abuse_report.rb41
-rw-r--r--app/models/achievements/achievement.rb3
-rw-r--r--app/models/active_session.rb20
-rw-r--r--app/models/appearance.rb2
-rw-r--r--app/models/application_setting.rb21
-rw-r--r--app/models/application_setting_implementation.rb2
-rw-r--r--app/models/award_emoji.rb3
-rw-r--r--app/models/awareness_session.rb245
-rw-r--r--app/models/blob_viewer/composer_json.rb2
-rw-r--r--app/models/blob_viewer/dependency_manager.rb6
-rw-r--r--app/models/blob_viewer/package_json.rb8
-rw-r--r--app/models/blob_viewer/podspec_json.rb2
-rw-r--r--app/models/broadcast_message.rb5
-rw-r--r--app/models/bulk_imports/entity.rb11
-rw-r--r--app/models/ci/bridge.rb2
-rw-r--r--app/models/ci/build.rb14
-rw-r--r--app/models/ci/build_metadata.rb2
-rw-r--r--app/models/ci/build_trace.rb6
-rw-r--r--app/models/ci/build_trace_metadata.rb4
-rw-r--r--app/models/ci/catalog/listing.rb2
-rw-r--r--app/models/ci/catalog/resource.rb12
-rw-r--r--app/models/ci/job_artifact.rb3
-rw-r--r--app/models/ci/namespace_mirror.rb3
-rw-r--r--app/models/ci/pipeline.rb51
-rw-r--r--app/models/ci/pipeline_schedule.rb2
-rw-r--r--app/models/ci/processable.rb2
-rw-r--r--app/models/ci/project_mirror.rb3
-rw-r--r--app/models/ci/ref.rb3
-rw-r--r--app/models/ci/resource_group.rb4
-rw-r--r--app/models/ci/runner.rb28
-rw-r--r--app/models/ci/runner_machine_build.rb26
-rw-r--r--app/models/ci/runner_manager.rb (renamed from app/models/ci/runner_machine.rb)15
-rw-r--r--app/models/ci/runner_manager_build.rb29
-rw-r--r--app/models/ci/runner_version.rb2
-rw-r--r--app/models/ci/running_build.rb10
-rw-r--r--app/models/ci/stage.rb3
-rw-r--r--app/models/ci/trigger.rb3
-rw-r--r--app/models/clusters/agent.rb14
-rw-r--r--app/models/clusters/agents/authorizations/ci_access/group_authorization.rb24
-rw-r--r--app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb27
-rw-r--r--app/models/clusters/agents/authorizations/ci_access/project_authorization.rb24
-rw-r--r--app/models/clusters/agents/authorizations/user_access/group_authorization.rb32
-rw-r--r--app/models/clusters/agents/authorizations/user_access/project_authorization.rb32
-rw-r--r--app/models/clusters/agents/group_authorization.rb20
-rw-r--r--app/models/clusters/agents/implicit_authorization.rb23
-rw-r--r--app/models/clusters/agents/project_authorization.rb20
-rw-r--r--app/models/clusters/applications/helm.rb83
-rw-r--r--app/models/clusters/applications/ingress.rb91
-rw-r--r--app/models/clusters/applications/jupyter.rb128
-rw-r--r--app/models/clusters/applications/knative.rb150
-rw-r--r--app/models/clusters/applications/runner.rb69
-rw-r--r--app/models/clusters/cluster.rb48
-rw-r--r--app/models/commit_status.rb2
-rw-r--r--app/models/compare.rb2
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage_event_model.rb2
-rw-r--r--app/models/concerns/awareness.rb41
-rw-r--r--app/models/concerns/bulk_member_access_load.rb14
-rw-r--r--app/models/concerns/ci/metadatable.rb9
-rw-r--r--app/models/concerns/ci/partitionable.rb4
-rw-r--r--app/models/concerns/clusters/agents/authorization_config_scopes.rb25
-rw-r--r--app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb29
-rw-r--r--app/models/concerns/database_event_tracking.rb2
-rw-r--r--app/models/concerns/discussion_on_diff.rb28
-rw-r--r--app/models/concerns/enums/abuse/source.rb18
-rw-r--r--app/models/concerns/enums/internal_id.rb3
-rw-r--r--app/models/concerns/enums/package_metadata.rb10
-rw-r--r--app/models/concerns/enums/sbom.rb6
-rw-r--r--app/models/concerns/expirable.rb2
-rw-r--r--app/models/concerns/group_descendant.rb5
-rw-r--r--app/models/concerns/has_user_type.rb19
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb38
-rw-r--r--app/models/concerns/issuable.rb19
-rw-r--r--app/models/concerns/issue_available_features.rb9
-rw-r--r--app/models/concerns/limitable.rb7
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb6
-rw-r--r--app/models/concerns/protected_ref_access.rb6
-rw-r--r--app/models/concerns/resolvable_discussion.rb16
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb5
-rw-r--r--app/models/concerns/web_hooks/auto_disabling.rb15
-rw-r--r--app/models/concerns/with_uploads.rb7
-rw-r--r--app/models/container_repository.rb12
-rw-r--r--app/models/design_management/git_repository.rb66
-rw-r--r--app/models/design_management/repository.rb55
-rw-r--r--app/models/event.rb3
-rw-r--r--app/models/group.rb23
-rw-r--r--app/models/group_group_link.rb8
-rw-r--r--app/models/group_label.rb4
-rw-r--r--app/models/hooks/web_hook.rb2
-rw-r--r--app/models/integrations/apple_app_store.rb10
-rw-r--r--app/models/integrations/bamboo.rb3
-rw-r--r--app/models/integrations/base_issue_tracker.rb6
-rw-r--r--app/models/integrations/ewm.rb2
-rw-r--r--app/models/integrations/field.rb6
-rw-r--r--app/models/integrations/google_play.rb31
-rw-r--r--app/models/integrations/harbor.rb5
-rw-r--r--app/models/integrations/jira.rb32
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb4
-rw-r--r--app/models/integrations/youtrack.rb11
-rw-r--r--app/models/issue.rb69
-rw-r--r--app/models/iteration.rb18
-rw-r--r--app/models/member.rb7
-rw-r--r--app/models/members/group_member.rb11
-rw-r--r--app/models/members/project_member.rb41
-rw-r--r--app/models/members_preloader.rb5
-rw-r--r--app/models/merge_request.rb4
-rw-r--r--app/models/milestone_note.rb3
-rw-r--r--app/models/ml/candidate.rb51
-rw-r--r--app/models/ml/experiment.rb20
-rw-r--r--app/models/namespace_setting.rb1
-rw-r--r--app/models/namespaces/project_namespace.rb2
-rw-r--r--app/models/namespaces/traversal/linear.rb10
-rw-r--r--app/models/note.rb8
-rw-r--r--app/models/onboarding/completion.rb23
-rw-r--r--app/models/packages/debian.rb2
-rw-r--r--app/models/packages/debian/file_metadatum.rb94
-rw-r--r--app/models/packages/event.rb113
-rw-r--r--app/models/packages/npm/metadata_cache.rb14
-rw-r--r--app/models/packages/npm/metadatum.rb2
-rw-r--r--app/models/packages/package.rb38
-rw-r--r--app/models/packages/package_file.rb7
-rw-r--r--app/models/pages/lookup_path.rb13
-rw-r--r--app/models/pages_deployment.rb15
-rw-r--r--app/models/personal_access_token.rb4
-rw-r--r--app/models/preloaders/labels_preloader.rb20
-rw-r--r--app/models/preloaders/runner_machine_policy_preloader.rb23
-rw-r--r--app/models/preloaders/runner_manager_policy_preloader.rb23
-rw-r--r--app/models/preloaders/users_max_access_level_by_project_preloader.rb51
-rw-r--r--app/models/preloaders/users_max_access_level_in_projects_preloader.rb54
-rw-r--r--app/models/project.rb52
-rw-r--r--app/models/project_label.rb4
-rw-r--r--app/models/project_setting.rb4
-rw-r--r--app/models/project_team.rb2
-rw-r--r--app/models/project_wiki.rb17
-rw-r--r--app/models/projects/data_transfer.rb8
-rw-r--r--app/models/protected_branch.rb8
-rw-r--r--app/models/repository.rb2
-rw-r--r--app/models/resource_events/issue_assignment_event.rb14
-rw-r--r--app/models/resource_events/merge_request_assignment_event.rb14
-rw-r--r--app/models/resource_milestone_event.rb3
-rw-r--r--app/models/resource_state_event.rb4
-rw-r--r--app/models/sent_notification.rb2
-rw-r--r--app/models/service_desk/custom_email_credential.rb66
-rw-r--r--app/models/service_desk_setting.rb69
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/terraform/state_version.rb2
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/u2f_registration.rb24
-rw-r--r--app/models/user.rb29
-rw-r--r--app/models/user_preference.rb4
-rw-r--r--app/models/users/project_callout.rb3
-rw-r--r--app/models/vulnerability.rb2
-rw-r--r--app/models/work_item.rb79
-rw-r--r--app/models/work_items/parent_link.rb4
-rw-r--r--app/models/work_items/resource_link_event.rb14
-rw-r--r--app/models/work_items/widget_definition.rb4
-rw-r--r--app/models/work_items/widgets/award_emoji.rb9
-rw-r--r--app/models/work_items/widgets/base.rb6
-rw-r--r--app/models/work_items/widgets/current_user_todos.rb8
-rw-r--r--app/policies/achievements/user_achievement_policy.rb5
-rw-r--r--app/policies/base_policy.rb6
-rw-r--r--app/policies/ci/build_policy.rb4
-rw-r--r--app/policies/ci/runner_manager_policy.rb (renamed from app/policies/ci/runner_machine_policy.rb)4
-rw-r--r--app/policies/global_policy.rb4
-rw-r--r--app/policies/group_label_policy.rb2
-rw-r--r--app/policies/group_policy.rb20
-rw-r--r--app/policies/issuable_policy.rb8
-rw-r--r--app/policies/issue_policy.rb5
-rw-r--r--app/policies/namespaces/group_project_namespace_shared_policy.rb11
-rw-r--r--app/policies/project_label_policy.rb2
-rw-r--r--app/policies/project_policy.rb34
-rw-r--r--app/policies/project_snippet_policy.rb10
-rw-r--r--app/presenters/ml/candidates_csv_presenter.rb49
-rw-r--r--app/presenters/packages/npm/package_presenter.rb85
-rw-r--r--app/presenters/project_presenter.rb2
-rw-r--r--app/presenters/search_service_presenter.rb3
-rw-r--r--app/serializers/admin/abuse_report_entity.rb34
-rw-r--r--app/serializers/build_details_entity.rb3
-rw-r--r--app/serializers/deploy_keys/basic_deploy_key_entity.rb1
-rw-r--r--app/serializers/detailed_status_entity.rb2
-rw-r--r--app/serializers/diff_file_entity.rb27
-rw-r--r--app/serializers/diff_viewer_entity.rb3
-rw-r--r--app/serializers/environment_serializer.rb6
-rw-r--r--app/serializers/error_tracking/detailed_error_entity.rb48
-rw-r--r--app/serializers/fork_namespace_entity.rb2
-rw-r--r--app/serializers/group_child_entity.rb8
-rw-r--r--app/serializers/group_deploy_key_entity.rb1
-rw-r--r--app/serializers/issue_board_entity.rb6
-rw-r--r--app/serializers/issue_entity.rb6
-rw-r--r--app/serializers/linked_issue_entity.rb6
-rw-r--r--app/serializers/merge_request_metrics_helper.rb10
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb13
-rw-r--r--app/serializers/rollout_status_entity.rb2
-rw-r--r--app/serializers/stage_entity.rb8
-rw-r--r--app/serializers/test_case_entity.rb2
-rw-r--r--app/services/achievements/award_service.rb1
-rw-r--r--app/services/achievements/destroy_service.rb33
-rw-r--r--app/services/achievements/update_service.rb41
-rw-r--r--app/services/branches/validate_new_service.rb2
-rw-r--r--app/services/bulk_imports/create_service.rb89
-rw-r--r--app/services/ci/archive_trace_service.rb17
-rw-r--r--app/services/ci/catalog/add_resource_service.rb41
-rw-r--r--app/services/ci/generate_kubeconfig_service.rb2
-rw-r--r--app/services/ci/job_artifacts/create_service.rb85
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb49
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb116
-rw-r--r--app/services/ci/register_job_service.rb8
-rw-r--r--app/services/ci/runners/create_runner_service.rb25
-rw-r--r--app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb38
-rw-r--r--app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb15
-rw-r--r--app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb38
-rw-r--r--app/services/ci/runners/stale_managers_cleanup_service.rb (renamed from app/services/ci/runners/stale_machines_cleanup_service.rb)8
-rw-r--r--app/services/ci/track_failed_build_service.rb2
-rw-r--r--app/services/clusters/agents/authorizations/ci_access/filter_service.rb54
-rw-r--r--app/services/clusters/agents/authorizations/ci_access/refresh_service.rb106
-rw-r--r--app/services/clusters/agents/authorizations/user_access/refresh_service.rb108
-rw-r--r--app/services/clusters/agents/authorize_proxy_user_service.rb4
-rw-r--r--app/services/clusters/agents/filter_authorizations_service.rb50
-rw-r--r--app/services/clusters/agents/refresh_authorization_service.rb102
-rw-r--r--app/services/clusters/applications/base_helm_service.rb69
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb6
-rw-r--r--app/services/concerns/work_items/widgetable_service.rb30
-rw-r--r--app/services/git/branch_hooks_service.rb2
-rw-r--r--app/services/import_csv/base_service.rb1
-rw-r--r--app/services/issuable/callbacks/base.rb31
-rw-r--r--app/services/issuable/callbacks/milestone.rb81
-rw-r--r--app/services/issuable_base_service.rb69
-rw-r--r--app/services/issues/after_create_service.rb1
-rw-r--r--app/services/issues/base_service.rb27
-rw-r--r--app/services/issues/build_service.rb37
-rw-r--r--app/services/issues/close_service.rb2
-rw-r--r--app/services/issues/create_service.rb44
-rw-r--r--app/services/issues/reopen_service.rb2
-rw-r--r--app/services/issues/update_service.rb41
-rw-r--r--app/services/members/creator_service.rb96
-rw-r--r--app/services/merge_requests/after_create_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb6
-rw-r--r--app/services/merge_requests/build_service.rb2
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb1
-rw-r--r--app/services/merge_requests/rebase_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb20
-rw-r--r--app/services/metrics/global_metrics_update_service.rb24
-rw-r--r--app/services/ml/experiment_tracking/candidate_repository.rb5
-rw-r--r--app/services/notes/create_service.rb24
-rw-r--r--app/services/notification_service.rb40
-rw-r--r--app/services/packages/create_event_service.rb9
-rw-r--r--app/services/packages/debian/find_or_create_incoming_service.rb2
-rw-r--r--app/services/packages/debian/find_or_create_package_service.rb31
-rw-r--r--app/services/packages/debian/generate_distribution_service.rb2
-rw-r--r--app/services/packages/debian/process_package_file_service.rb25
-rw-r--r--app/services/packages/npm/create_package_service.rb26
-rw-r--r--app/services/packages/npm/deprecate_package_service.rb78
-rw-r--r--app/services/packages/npm/generate_metadata_service.rb111
-rw-r--r--app/services/projects/blame_service.rb101
-rw-r--r--app/services/projects/create_service.rb6
-rw-r--r--app/services/projects/fork_service.rb6
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb2
-rw-r--r--app/services/projects/import_service.rb7
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb14
-rw-r--r--app/services/projects/overwrite_project_service.rb20
-rw-r--r--app/services/projects/update_pages_service.rb3
-rw-r--r--app/services/projects/update_remote_mirror_service.rb14
-rw-r--r--app/services/projects/update_service.rb4
-rw-r--r--app/services/protected_branches/cache_service.rb8
-rw-r--r--app/services/releases/create_service.rb6
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb3
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/system_notes/issuables_service.rb6
-rw-r--r--app/services/tasks_to_be_done/base_service.rb6
-rw-r--r--app/services/terraform/remote_state_handler.rb8
-rw-r--r--app/services/users/approve_service.rb5
-rw-r--r--app/services/users/ban_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb2
-rw-r--r--app/services/users/unban_service.rb2
-rw-r--r--app/services/users/unblock_service.rb2
-rw-r--r--app/services/work_items/create_service.rb10
-rw-r--r--app/services/work_items/export_csv_service.rb16
-rw-r--r--app/services/work_items/parent_links/base_service.rb53
-rw-r--r--app/services/work_items/parent_links/create_service.rb51
-rw-r--r--app/services/work_items/parent_links/destroy_service.rb10
-rw-r--r--app/services/work_items/parent_links/reorder_service.rb39
-rw-r--r--app/services/work_items/prepare_import_csv_service.rb19
-rw-r--r--app/services/work_items/update_service.rb10
-rw-r--r--app/services/work_items/widgets/assignees_service/update_service.rb2
-rw-r--r--app/services/work_items/widgets/award_emoji_service/update_service.rb33
-rw-r--r--app/services/work_items/widgets/base_service.rb12
-rw-r--r--app/services/work_items/widgets/current_user_todos_service/update_service.rb37
-rw-r--r--app/services/work_items/widgets/description_service/update_service.rb2
-rw-r--r--app/services/work_items/widgets/hierarchy_service/base_service.rb4
-rw-r--r--app/services/work_items/widgets/hierarchy_service/update_service.rb60
-rw-r--r--app/services/work_items/widgets/labels_service/update_service.rb5
-rw-r--r--app/services/work_items/widgets/milestone_service/base_service.rb11
-rw-r--r--app/services/work_items/widgets/milestone_service/create_service.rb13
-rw-r--r--app/services/work_items/widgets/milestone_service/update_service.rb13
-rw-r--r--app/services/work_items/widgets/start_and_due_date_service/update_service.rb2
-rw-r--r--app/uploaders/object_storage/cdn/google_cdn.rb2
-rw-r--r--app/uploaders/records_uploads.rb10
-rw-r--r--app/validators/json_schemas/application_setting_database_apdex_settings.json34
-rw-r--r--app/validators/json_schemas/build_report_result_data.json5
-rw-r--r--app/validators/json_schemas/build_report_result_data_tests.json12
-rw-r--r--app/validators/json_schemas/clusters_agents_authorizations_ci_access_config.json (renamed from app/validators/json_schemas/cluster_agent_authorization_configuration.json)0
-rw-r--r--app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json6
-rw-r--r--app/validators/json_schemas/import_failure_external_identifiers.json2
-rw-r--r--app/validators/json_schemas/pinned_nav_items.json22
-rw-r--r--app/views/abuse_reports/new.html.haml11
-rw-r--r--app/views/admin/application_settings/_registry.html.haml8
-rw-r--r--app/views/admin/applications/_form.html.haml25
-rw-r--r--app/views/admin/applications/index.html.haml6
-rw-r--r--app/views/admin/background_migrations/index.html.haml3
-rw-r--r--app/views/admin/dashboard/index.html.haml4
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml11
-rw-r--r--app/views/admin/labels/index.html.haml4
-rw-r--r--app/views/admin/labels/new.html.haml1
-rw-r--r--app/views/admin/projects/_form.html.haml15
-rw-r--r--app/views/admin/sessions/new.html.haml1
-rw-r--r--app/views/admin/sessions/two_factor.html.haml1
-rw-r--r--app/views/admin/system_info/show.html.haml81
-rw-r--r--app/views/admin/users/_profile.html.haml59
-rw-r--r--app/views/authentication/_authenticate.html.haml9
-rw-r--r--app/views/authentication/_register.html.haml2
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml1
-rw-r--r--app/views/clusters/clusters/connect.html.haml1
-rw-r--r--app/views/clusters/clusters/new_cluster_docs.html.haml1
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/_projects_nav.html.haml4
-rw-r--r--app/views/dashboard/activity.html.haml5
-rw-r--r--app/views/dashboard/groups/index.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml1
-rw-r--r--app/views/dashboard/merge_requests.html.haml1
-rw-r--r--app/views/dashboard/milestones/index.html.haml2
-rw-r--r--app/views/dashboard/projects/_starred_empty_state.html.haml4
-rw-r--r--app/views/dashboard/projects/index.html.haml5
-rw-r--r--app/views/dashboard/projects/shared/_common.html.haml4
-rw-r--r--app/views/dashboard/snippets/index.html.haml4
-rw-r--r--app/views/dashboard/todos/index.html.haml3
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml4
-rw-r--r--app/views/devise/shared/_signup_box.html.haml17
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml4
-rw-r--r--app/views/doorkeeper/applications/edit.html.haml1
-rw-r--r--app/views/doorkeeper/applications/show.html.haml1
-rw-r--r--app/views/events/_events.html.haml2
-rw-r--r--app/views/explore/_head.html.haml6
-rw-r--r--app/views/explore/projects/_head.html.haml11
-rw-r--r--app/views/explore/projects/index.html.haml12
-rw-r--r--app/views/explore/projects/page_out_of_bounds.html.haml12
-rw-r--r--app/views/explore/projects/starred.html.haml13
-rw-r--r--app/views/explore/projects/trending.html.haml14
-rw-r--r--app/views/groups/_flash_messages.html.haml2
-rw-r--r--app/views/groups/_group_readme.html.haml3
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml1
-rw-r--r--app/views/groups/edit.html.haml3
-rw-r--r--app/views/groups/group_members/index.html.haml3
-rw-r--r--app/views/groups/harbor/repositories/index.html.haml1
-rw-r--r--app/views/groups/imports/show.html.haml1
-rw-r--r--app/views/groups/new.html.haml7
-rw-r--r--app/views/groups/packages/index.html.haml2
-rw-r--r--app/views/groups/projects.html.haml1
-rw-r--r--app/views/groups/registry/repositories/index.html.haml1
-rw-r--r--app/views/groups/runners/index.html.haml2
-rw-r--r--app/views/groups/runners/new.html.haml5
-rw-r--r--app/views/groups/runners/register.html.haml7
-rw-r--r--app/views/groups/settings/_general.html.haml5
-rw-r--r--app/views/groups/settings/_permissions.html.haml4
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml1
-rw-r--r--app/views/groups/settings/applications/edit.html.haml1
-rw-r--r--app/views/groups/settings/applications/show.html.haml1
-rw-r--r--app/views/groups/settings/integrations/index.html.haml1
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml1
-rw-r--r--app/views/groups/settings/repository/show.html.haml1
-rw-r--r--app/views/groups/show.html.haml5
-rw-r--r--app/views/import/_githubish_status.html.haml2
-rw-r--r--app/views/import/github/details.html.haml4
-rw-r--r--app/views/import/github/status.html.haml1
-rw-r--r--app/views/jira_connect/branches/new.html.haml1
-rw-r--r--app/views/layouts/_head.html.haml36
-rw-r--r--app/views/layouts/_page.html.haml11
-rw-r--r--app/views/layouts/_search.html.haml42
-rw-r--r--app/views/layouts/dashboard.html.haml3
-rw-r--r--app/views/layouts/devise.html.haml1
-rw-r--r--app/views/layouts/devise_empty.html.haml1
-rw-r--r--app/views/layouts/group.html.haml1
-rw-r--r--app/views/layouts/header/_current_user_dropdown_item.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml9
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml1
-rw-r--r--app/views/layouts/minimal.html.haml4
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml28
-rw-r--r--app/views/layouts/nav/_top_bar.html.haml14
-rw-r--r--app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml20
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml299
-rw-r--r--app/views/layouts/nav/sidebar/_search.html.haml1
-rw-r--r--app/views/layouts/project.html.haml2
-rw-r--r--app/views/layouts/signup_onboarding.html.haml1
-rw-r--r--app/views/layouts/simple_registration.html.haml1
-rw-r--r--app/views/layouts/snippets.html.haml4
-rw-r--r--app/views/layouts/terms.html.haml2
-rw-r--r--app/views/notify/access_token_created_email.html.haml2
-rw-r--r--app/views/notify/new_achievement_email.html.haml7
-rw-r--r--app/views/notify/new_achievement_email.text.erb4
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb2
-rw-r--r--app/views/notify/service_desk_custom_email_verification_email.text.erb4
-rw-r--r--app/views/notify/service_desk_verification_result_email.html.haml58
-rw-r--r--app/views/notify/service_desk_verification_result_email.text.erb38
-rw-r--r--app/views/notify/service_desk_verification_triggered_email.html.haml18
-rw-r--r--app/views/notify/service_desk_verification_triggered_email.text.erb10
-rw-r--r--app/views/peek/_bar.html.haml3
-rw-r--r--app/views/profiles/accounts/show.html.haml1
-rw-r--r--app/views/profiles/active_sessions/index.html.haml1
-rw-r--r--app/views/profiles/audit_log.html.haml1
-rw-r--r--app/views/profiles/chat_names/index.html.haml1
-rw-r--r--app/views/profiles/chat_names/new.html.haml4
-rw-r--r--app/views/profiles/comment_templates/index.html.haml10
-rw-r--r--app/views/profiles/emails/index.html.haml3
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml1
-rw-r--r--app/views/profiles/keys/index.html.haml1
-rw-r--r--app/views/profiles/keys/show.html.haml1
-rw-r--r--app/views/profiles/notifications/show.html.haml1
-rw-r--r--app/views/profiles/passwords/edit.html.haml1
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml1
-rw-r--r--app/views/profiles/preferences/show.html.haml7
-rw-r--r--app/views/profiles/saved_replies/index.html.haml10
-rw-r--r--app/views/profiles/show.html.haml8
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml6
-rw-r--r--app/views/projects/_files.html.haml12
-rw-r--r--app/views/projects/_flash_messages.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml46
-rw-r--r--app/views/projects/_remove.html.haml1
-rw-r--r--app/views/projects/_remove_fork.html.haml3
-rw-r--r--app/views/projects/_service_desk_settings.html.haml2
-rw-r--r--app/views/projects/_terraform_banner.html.haml2
-rw-r--r--app/views/projects/blame/show.html.haml21
-rw-r--r--app/views/projects/blob/_blob.html.haml12
-rw-r--r--app/views/projects/blob/edit.html.haml16
-rw-r--r--app/views/projects/branch_defaults/_branch_names_fields.html.haml2
-rw-r--r--app/views/projects/branch_rules/_show.html.haml2
-rw-r--r--app/views/projects/branches/new.html.haml28
-rw-r--r--app/views/projects/commit/show.html.haml4
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml4
-rw-r--r--app/views/projects/edit.html.haml1
-rw-r--r--app/views/projects/empty.html.haml1
-rw-r--r--app/views/projects/environments/index.html.haml3
-rw-r--r--app/views/projects/feature_flags/edit.html.haml4
-rw-r--r--app/views/projects/feature_flags/index.html.haml2
-rw-r--r--app/views/projects/feature_flags/new.html.haml4
-rw-r--r--app/views/projects/feature_flags_user_lists/edit.html.haml2
-rw-r--r--app/views/projects/feature_flags_user_lists/index.html.haml4
-rw-r--r--app/views/projects/feature_flags_user_lists/new.html.haml2
-rw-r--r--app/views/projects/feature_flags_user_lists/show.html.haml4
-rw-r--r--app/views/projects/google_cloud/configuration/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/databases/cloudsql_form.html.haml2
-rw-r--r--app/views/projects/google_cloud/databases/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/deployments/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/gcp_regions/index.html.haml2
-rw-r--r--app/views/projects/google_cloud/service_accounts/index.html.haml2
-rw-r--r--app/views/projects/harbor/repositories/index.html.haml1
-rw-r--r--app/views/projects/hook_logs/show.html.haml1
-rw-r--r--app/views/projects/hooks/edit.html.haml1
-rw-r--r--app/views/projects/hooks/index.html.haml1
-rw-r--r--app/views/projects/imports/show.html.haml1
-rw-r--r--app/views/projects/issues/_design_management.html.haml2
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/_new_branch.html.haml14
-rw-r--r--app/views/projects/issues/_related_branches.html.haml34
-rw-r--r--app/views/projects/issues/new.html.haml2
-rw-r--r--app/views/projects/mattermosts/new.html.haml1
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml2
-rw-r--r--app/views/projects/merge_requests/_code_dropdown.html.haml2
-rw-r--r--app/views/projects/merge_requests/_description.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml17
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml3
-rw-r--r--app/views/projects/merge_requests/_page.html.haml10
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml22
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml2
-rw-r--r--app/views/projects/ml/experiments/show.html.haml5
-rw-r--r--app/views/projects/new.html.haml3
-rw-r--r--app/views/projects/packages/infrastructure_registry/index.html.haml1
-rw-r--r--app/views/projects/packages/infrastructure_registry/show.html.haml5
-rw-r--r--app/views/projects/packages/packages/index.html.haml2
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml5
-rw-r--r--app/views/projects/pipelines/new.html.haml1
-rw-r--r--app/views/projects/project_members/index.html.haml4
-rw-r--r--app/views/projects/registry/repositories/index.html.haml1
-rw-r--r--app/views/projects/releases/new.html.haml1
-rw-r--r--app/views/projects/security/configuration/show.html.haml1
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml1
-rw-r--r--app/views/projects/settings/branch_rules/index.html.haml5
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml3
-rw-r--r--app/views/projects/settings/integrations/edit.html.haml1
-rw-r--r--app/views/projects/settings/integrations/index.html.haml1
-rw-r--r--app/views/projects/settings/members/show.html.haml2
-rw-r--r--app/views/projects/settings/merge_requests/show.html.haml1
-rw-r--r--app/views/projects/settings/operations/show.html.haml1
-rw-r--r--app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml5
-rw-r--r--app/views/projects/settings/packages_and_registries/show.html.haml1
-rw-r--r--app/views/projects/settings/repository/show.html.haml1
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/snippets/edit.html.haml1
-rw-r--r--app/views/projects/snippets/new.html.haml1
-rw-r--r--app/views/projects/tags/show.html.haml8
-rw-r--r--app/views/projects/tree/show.html.haml1
-rw-r--r--app/views/projects/usage_quotas/index.html.haml5
-rw-r--r--app/views/protected_branches/shared/_branches_list.html.haml2
-rw-r--r--app/views/protected_branches/shared/_create_protected_branch.html.haml2
-rw-r--r--app/views/registrations/welcome/show.html.haml1
-rw-r--r--app/views/search/_results.html.haml10
-rw-r--r--app/views/search/_results_list.html.haml4
-rw-r--r--app/views/search/_results_status.html.haml4
-rw-r--r--app/views/search/show.html.haml8
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml2
-rw-r--r--app/views/shared/_file_highlight.html.haml10
-rw-r--r--app/views/shared/_file_picker_button.html.haml5
-rw-r--r--app/views/shared/_label.html.haml4
-rw-r--r--app/views/shared/_ref_switcher.html.haml22
-rw-r--r--app/views/shared/boards/_show.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml5
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml4
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml16
-rw-r--r--app/views/shared/doorkeeper/applications/_update_form.html.haml3
-rw-r--r--app/views/shared/empty_states/_labels.html.haml4
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml4
-rw-r--r--app/views/shared/empty_states/_topics.html.haml4
-rw-r--r--app/views/shared/form_elements/_description.html.haml24
-rw-r--r--app/views/shared/hook_logs/_index.html.haml2
-rw-r--r--app/views/shared/integrations/edit.html.haml1
-rw-r--r--app/views/shared/integrations/overrides.html.haml1
-rw-r--r--app/views/shared/issuable/_form.html.haml4
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/issuable/form/_default_templates.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml29
-rw-r--r--app/views/shared/issuable/form/_title.html.haml4
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml24
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml8
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml4
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml8
-rw-r--r--app/views/shared/milestones/_description.html.haml2
-rw-r--r--app/views/shared/milestones/_header.html.haml73
-rw-r--r--app/views/shared/nav/_admin_scope_header.html.haml6
-rw-r--r--app/views/shared/notes/_edit_form.html.haml3
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/projects/_list.html.haml7
-rw-r--r--app/views/shared/projects/_project.html.haml21
-rw-r--r--app/views/shared/projects/_search_form.html.haml2
-rw-r--r--app/views/shared/projects/_topics.html.haml52
-rw-r--r--app/views/shared/users/index.html.haml4
-rw-r--r--app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar_wiki_page.html.haml8
-rw-r--r--app/views/shared/wikis/_wiki_directory.html.haml7
-rw-r--r--app/views/snippets/_snippets.html.haml2
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--app/views/time_tracking/timelogs/index.html.haml7
-rw-r--r--app/views/users/_overview.html.haml7
-rw-r--r--app/views/users/show.html.haml302
-rw-r--r--app/workers/all_queues.yml99
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb1
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb1
-rw-r--r--app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb2
-rw-r--r--app/workers/concerns/cluster_agent_queue.rb2
-rw-r--r--app/workers/concerns/cluster_cleanup_methods.rb7
-rw-r--r--app/workers/concerns/cluster_queue.rb2
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb2
-rw-r--r--app/workers/database/batched_background_migration/execution_worker.rb3
-rw-r--r--app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb2
-rw-r--r--app/workers/database/ci_project_mirrors_consistency_check_worker.rb2
-rw-r--r--app/workers/email_receiver_worker.rb2
-rw-r--r--app/workers/gitlab/github_gists_import/import_gist_worker.rb23
-rw-r--r--app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb4
-rw-r--r--app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb8
-rw-r--r--app/workers/issuable_export_csv_worker.rb2
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb7
-rw-r--r--app/workers/jira_connect/sync_project_worker.rb16
-rw-r--r--app/workers/loose_foreign_keys/cleanup_worker.rb2
-rw-r--r--app/workers/metrics/global_metrics_update_worker.rb27
-rw-r--r--app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb26
-rw-r--r--app/workers/namespaces/process_sync_events_worker.rb2
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb16
-rw-r--r--app/workers/namespaces/schedule_aggregation_worker.rb16
-rw-r--r--app/workers/packages/debian/cleanup_dangling_package_files_worker.rb32
-rw-r--r--app/workers/packages/debian/process_package_file_worker.rb3
-rw-r--r--app/workers/packages/npm/deprecate_package_worker.rb22
-rw-r--r--app/workers/projects/process_sync_events_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb1
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb10
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb16
-rw-r--r--app/workers/service_desk_email_receiver_worker.rb2
-rw-r--r--app/workers/stuck_export_jobs_worker.rb3
-rw-r--r--app/workers/update_highest_role_worker.rb2
-rw-r--r--app/workers/users/deactivate_dormant_users_worker.rb2
-rw-r--r--app/workers/work_items/import_work_items_csv_worker.rb29
-rw-r--r--app/workers/x509_issuer_crl_check_worker.rb38
1598 files changed, 20479 insertions, 11690 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
deleted file mode 100644
index 48b1095370d..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico
deleted file mode 100644
index 623c728faf6..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_created.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
deleted file mode 100644
index 3073fe5a761..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
deleted file mode 100644
index 6c713d7b675..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
deleted file mode 100644
index dbf855fdafd..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
deleted file mode 100644
index ccd00606aeb..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
deleted file mode 100644
index 6cdf3ae2e36..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico
deleted file mode 100644
index 968e7c4c2d4..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_running.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico b/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico
deleted file mode 100644
index 5444b8e41dc..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
deleted file mode 100644
index 7e3be35cc3a..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico
deleted file mode 100644
index a1fb6e91d65..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_success.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
deleted file mode 100644
index 5d931619fb2..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mr_favicons/favicon_status_merged.png b/app/assets/images/mr_favicons/favicon_status_merged.png
new file mode 100644
index 00000000000..0acb2e463a9
--- /dev/null
+++ b/app/assets/images/mr_favicons/favicon_status_merged.png
Binary files differ
diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js
new file mode 100644
index 00000000000..02a4a3c2f15
--- /dev/null
+++ b/app/assets/javascripts/access_level/constants.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+
+// Matches `lib/gitlab/access.rb`
+export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0;
+export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5;
+export const ACCESS_LEVEL_GUEST_INTEGER = 10;
+export const ACCESS_LEVEL_REPORTER_INTEGER = 20;
+export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30;
+export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40;
+export const ACCESS_LEVEL_OWNER_INTEGER = 50;
+
+export const ACCESS_LEVEL_LABELS = {
+ [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'),
+ [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'),
+ [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'),
+ [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'),
+ [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'),
+ [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'),
+ [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'),
+};
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 c66b595ffdc..a5f8f369604 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -1,10 +1,15 @@
<script>
-import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
import { createAlert } from '~/alert';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
+import {
+ OPERATORS_IS,
+ TOKEN_TYPE_AUTHOR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
import eventHub from '../event_hub';
import {
findCommitIndex,
@@ -12,6 +17,7 @@ import {
removeIfReadyToBeRemoved,
removeIfPresent,
} from '../utils';
+import Token from './token.vue';
export default {
components: {
@@ -19,9 +25,9 @@ export default {
GlTabs,
GlTab,
ReviewTabContainer,
- GlSearchBoxByType,
GlSprintf,
GlBadge,
+ GlFilteredSearch,
},
props: {
contextCommitsPath: {
@@ -41,6 +47,49 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ availableTokens: [
+ {
+ icon: 'pencil',
+ title: __('Author'),
+ type: TOKEN_TYPE_AUTHOR,
+ operators: OPERATORS_IS,
+ token: UserToken,
+ defaultAuthors: [],
+ unique: true,
+ fetchAuthors: this.fetchAuthors,
+ initialAuthors: [],
+ },
+ {
+ formattedKey: __('Committed-before'),
+ key: 'committed-before',
+ type: 'committed-before-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_before',
+ title: __('Committed-before'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ },
+ {
+ formattedKey: __('Committed-after'),
+ key: 'committed-after',
+ type: 'committed-after-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_after',
+ title: __('Committed-after'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ },
+ ],
+ };
+ },
computed: {
...mapState([
'tabIndex',
@@ -98,8 +147,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('openModal', this.openModal);
- clearTimeout(this.timeout);
- this.timeout = null;
},
methods: {
...mapActions([
@@ -114,10 +161,8 @@ export default {
'setSearchText',
'setToRemoveCommits',
'resetModalState',
+ 'fetchAuthors',
]),
- focusSearch() {
- this.$refs.searchInput.focusInput();
- },
openModal() {
this.searchCommits();
this.fetchContextCommits();
@@ -125,7 +170,6 @@ export default {
},
handleTabChange(tabIndex) {
if (tabIndex === 0) {
- this.focusSearch();
if (this.shouldPurge) {
this.setSelectedCommits(
[...this.commits, ...this.selectedCommits].filter((commit) => commit.isSelected),
@@ -133,17 +177,36 @@ export default {
}
}
},
- handleSearchCommits(value) {
- // We only call the service, if we have 3 characters or we don't have any characters
- if (value.length >= 3) {
- clearTimeout(this.timeout);
- this.timeout = setTimeout(() => {
- this.searchCommits(value);
- }, 500);
- } else if (value.length === 0) {
- this.searchCommits();
+ blurSearchInput() {
+ const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
+ '.gl-filtered-search-token-segment-input',
+ );
+ if (searchInputEl) {
+ searchInputEl.blur();
}
- this.setSearchText(value);
+ },
+ handleSearchCommits(value = []) {
+ const searchValues = value.reduce((acc, searchFilter) => {
+ const isEqualSearch = searchFilter?.value?.operator === '=';
+
+ if (!isEqualSearch && typeof searchFilter === 'object') return acc;
+
+ if (typeof searchFilter === 'string' && searchFilter.length >= 3) {
+ acc.searchText = searchFilter;
+ } else if (searchFilter?.type === 'author' && searchFilter?.value?.data?.length >= 3) {
+ acc.author = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-before-date') {
+ acc.committed_before = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-after-date') {
+ acc.committed_after = searchFilter?.value?.data;
+ }
+
+ return acc;
+ }, {});
+
+ this.searchCommits(searchValues);
+ this.blurSearchInput();
+ this.setSearchText(searchValues.searchText);
},
handleCommitRowSelect(event) {
const index = event[0];
@@ -208,11 +271,12 @@ export default {
},
handleModalClose() {
this.resetModalState();
- clearTimeout(this.timeout);
},
handleModalHide() {
this.resetModalState();
- clearTimeout(this.timeout);
+ },
+ shouldShowInputDateFormat(value) {
+ return ['Committed-before', 'Committed-after'].indexOf(value) !== -1;
},
},
};
@@ -223,13 +287,14 @@ export default {
ref="modal"
cancel-variant="light"
size="md"
+ no-focus-on-show
+ modal-class="add-review-item-modal"
body-class="add-review-item pt-0"
:scrollable="true"
:ok-title="__('Save changes')"
modal-id="add-review-item"
:title="__('Add or remove previously merged commits')"
:ok-disabled="disableSaveButton"
- @shown="focusSearch"
@ok="handleCreateContextCommits"
@cancel="handleModalClose"
@close="handleModalClose"
@@ -245,11 +310,24 @@ export default {
</gl-sprintf>
</template>
<div class="gl-mt-3">
- <gl-search-box-by-type
- ref="searchInput"
- :placeholder="__(`Search by commit title or SHA`)"
- @input="handleSearchCommits"
- />
+ <gl-filtered-search
+ ref="filteredSearchInput"
+ class="flex-grow-1"
+ :placeholder="__(`Search or filter commits`)"
+ :available-tokens="availableTokens"
+ @clear="handleSearchCommits"
+ @submit="handleSearchCommits"
+ >
+ <template #title="{ value }">
+ <div>
+ {{ value }}
+ <span v-if="shouldShowInputDateFormat(value)" class="title-hint-text">
+ &lt;{{ __('yyyy-mm-dd') }}&gt;
+ </span>
+ </div>
+ </template>
+ </gl-filtered-search>
+
<review-tab-container
:is-loading="isLoadingCommits"
:loading-error="commitsLoadingError"
diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue
new file mode 100644
index 00000000000..c403adbbf60
--- /dev/null
+++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ val: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" />
+</template>
diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js
index de9c7488ace..f085b0d0e5e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/actions.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js
@@ -1,8 +1,11 @@
import _ from 'lodash';
+import * as Sentry from '@sentry/browser';
import Api from '~/api';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants';
import * as types from './mutation_types';
export const setBaseConfig = ({ commit }, options) => {
@@ -11,14 +14,14 @@ export const setBaseConfig = ({ commit }, options) => {
export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex);
-export const searchCommits = ({ dispatch, commit, state }, searchText) => {
+export const searchCommits = ({ dispatch, commit, state }, search = {}) => {
commit(types.FETCH_COMMITS);
let params = {};
- if (searchText) {
+ if (search) {
params = {
params: {
- search: searchText,
+ ...search,
per_page: 40,
},
};
@@ -37,7 +40,7 @@ export const searchCommits = ({ dispatch, commit, state }, searchText) => {
}
return c;
});
- if (!searchText) {
+ if (!search) {
dispatch('setCommits', { commits: [...commits, ...state.contextCommits] });
} else {
dispatch('setCommits', { commits });
@@ -131,6 +134,23 @@ export const setSelectedCommits = ({ commit }, selected) => {
commit(types.SET_SELECTED_COMMITS, selectedCommits);
};
+export const fetchAuthors = ({ dispatch, state }, author = null) => {
+ const { projectId } = state;
+ return axios
+ .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), {
+ params: {
+ project_id: projectId,
+ states: ACTIVE_AND_BLOCKED_USER_STATES,
+ search: author,
+ },
+ })
+ .then(({ data }) => data)
+ .catch((error) => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
+};
+
export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText);
export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data);
diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js
index 0bf3441379b..560834a26ae 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
@@ -12,4 +13,5 @@ export default () =>
state: state(),
actions,
mutations,
+ modules: { filters },
});
diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js
index 37239adccbb..fed3148bc9e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/state.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/state.js
@@ -1,4 +1,5 @@
export default () => ({
+ projectId: '',
contextCommitsPath: '',
tabIndex: 0,
isLoadingCommits: false,
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
new file mode 100644
index 00000000000..f2271f8af24
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
@@ -0,0 +1,148 @@
+<script>
+import { GlButton, GlDropdown, GlModal } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { ACTIONS_I18N } from '../constants';
+
+const modalActionButtonAttributes = {
+ block: {
+ text: __('OK'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ removeUserAndReport: {
+ text: __('OK'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ secondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+};
+
+export default {
+ name: 'AbuseReportActions',
+ components: {
+ GlButton,
+ GlDropdown,
+ GlModal,
+ },
+ modalId: 'abuse-report-row-action-confirm-modal',
+ modalActionButtonAttributes,
+ i18n: ACTIONS_I18N,
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ userBlocked: this.report.userBlocked,
+ confirmModalShown: false,
+ actionToConfirm: 'block',
+ };
+ },
+ computed: {
+ blockUserButtonText() {
+ const { alreadyBlocked, blockUser } = this.$options.i18n;
+
+ return this.userBlocked ? alreadyBlocked : blockUser;
+ },
+ removeUserAndReportConfirmText() {
+ return sprintf(this.$options.i18n.removeUserAndReportConfirm, {
+ user: this.report.reportedUser.name,
+ });
+ },
+ modalData() {
+ return {
+ block: {
+ action: this.blockUser,
+ confirmText: this.$options.i18n.blockUserConfirm,
+ },
+ removeUserAndReport: {
+ action: this.removeUserAndReport,
+ confirmText: this.removeUserAndReportConfirmText,
+ },
+ };
+ },
+ },
+ methods: {
+ showConfirmModal(action) {
+ this.confirmModalShown = true;
+ this.actionToConfirm = action;
+ },
+ blockUser() {
+ axios
+ .put(this.report.blockUserPath)
+ .then(this.handleBlockUserResponse)
+ .catch(this.handleError);
+ },
+ removeUserAndReport() {
+ axios
+ .delete(this.report.removeUserAndReportPath)
+ .then(this.handleRemoveReportResponse)
+ .catch(this.handleError);
+ },
+ removeReport() {
+ axios
+ .delete(this.report.removeReportPath)
+ .then(this.handleRemoveReportResponse)
+ .catch(this.handleError);
+ },
+ handleRemoveReportResponse() {
+ window.location.reload();
+ },
+ handleBlockUserResponse({ data }) {
+ const message = data?.error || data?.notice;
+ const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {};
+
+ if (message) {
+ createAlert({ message, ...alertOptions });
+ }
+
+ if (!data?.error) {
+ this.userBlocked = true;
+ }
+ },
+ handleError(error) {
+ createAlert({
+ message: __('Something went wrong. Please try again.'),
+ captureError: true,
+ error,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown text="Actions" text-sr-only icon="ellipsis_v" category="tertiary" no-caret right>
+ <div class="gl-px-2">
+ <gl-button block variant="danger" @click="showConfirmModal('removeUserAndReport')">
+ {{ $options.i18n.removeUserAndReport }}
+ </gl-button>
+ <gl-button block :disabled="userBlocked" @click="showConfirmModal('block')">
+ {{ blockUserButtonText }}
+ </gl-button>
+ <gl-button block @click="removeReport">
+ {{ $options.i18n.removeReport }}
+ </gl-button>
+ </div>
+ <gl-modal
+ v-model="confirmModalShown"
+ :modal-id="$options.modalId"
+ :title="modalData[actionToConfirm].confirmText"
+ size="sm"
+ :action-primary="$options.modalActionButtonAttributes[actionToConfirm]"
+ :action-secondary="$options.modalActionButtonAttributes.secondary"
+ @primary="modalData[actionToConfirm].action"
+ />
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue
new file mode 100644
index 00000000000..f49411604f1
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue
@@ -0,0 +1,66 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlCollapse } from '@gitlab/ui';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+ components: {
+ GlButton,
+ GlCollapse,
+ },
+ directives: { SafeHtml },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ collapseId: uniqueId('abuse-report-detail-'),
+ };
+ },
+ computed: {
+ toggleText() {
+ return this.isVisible ? __('Hide details') : __('Show details');
+ },
+ reportedUserCreatedAt() {
+ const { reportedUser } = this.report;
+ return sprintf(__('User joined %{timeAgo}'), {
+ timeAgo: getTimeago().format(reportedUser.createdAt),
+ });
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <gl-collapse :id="collapseId" v-model="isVisible">
+ <dl class="gl-mb-2">
+ <dd>{{ reportedUserCreatedAt }}</dd>
+
+ <dt>{{ __('Message') }}</dt>
+ <dd v-safe-html="report.message"></dd>
+ </dl>
+ </gl-collapse>
+ <div>
+ <gl-button
+ :aria-expanded="`${isVisible}`"
+ :aria-controls="collapseId"
+ size="small"
+ variant="link"
+ @click="toggleCollapse"
+ >{{ toggleText }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
index a4211002f71..a9fe59a7b85 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -1,11 +1,20 @@
<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { getTimeago } from '~/lib/utils/datetime_utility';
+import { queryToObject } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { SORT_UPDATED_AT } from '../constants';
+import AbuseReportActions from './abuse_report_actions.vue';
+import AbuseReportDetails from './abuse_report_details.vue';
export default {
name: 'AbuseReportRow',
components: {
+ AbuseReportDetails,
+ GlLink,
+ GlSprintf,
+ AbuseReportActions,
ListItem,
},
props: {
@@ -15,14 +24,31 @@ export default {
},
},
computed: {
- updatedAt() {
- const template = __('Updated %{timeAgo}');
- return sprintf(template, { timeAgo: getTimeago().format(this.report.updatedAt) });
+ displayDate() {
+ const { sort } = queryToObject(window.location.search);
+ const { createdAt, updatedAt } = this.report;
+ const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort)
+ ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt }
+ : { template: __('Created %{timeAgo}'), timeAgo: createdAt };
+
+ return sprintf(template, { timeAgo: getTimeago().format(timeAgo) });
+ },
+ reported() {
+ const { reportedUser } = this.report;
+ return sprintf('%{userLinkStart}%{reported}%{userLinkEnd}', {
+ reported: reportedUser.name,
+ });
+ },
+ reporter() {
+ const { reporter } = this.report;
+ return sprintf('%{reporterLinkStart}%{reporter}%{reporterLinkEnd}', {
+ reporter: reporter.name,
+ });
},
title() {
- const { reportedUser, reporter, category } = this.report;
+ const { category } = this.report;
const template = __('%{reported} reported for %{category} by %{reporter}');
- return sprintf(template, { reported: reportedUser.name, reporter: reporter.name, category });
+ return sprintf(template, { reported: this.reported, reporter: this.reporter, category });
},
},
};
@@ -31,11 +57,25 @@ export default {
<template>
<list-item data-testid="abuse-report-row">
<template #left-primary>
- <div class="gl-font-weight-normal" data-testid="title">{{ title }}</div>
+ <div class="gl-font-weight-normal gl-mb-2" data-testid="title">
+ <gl-sprintf :message="title">
+ <template #userLink="{ content }">
+ <gl-link :href="report.reportedUserPath">{{ content }}</gl-link>
+ </template>
+ <template #reporterLink="{ content }">
+ <gl-link :href="report.reporterPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <abuse-report-details :report="report" />
</template>
<template #right-secondary>
- <div data-testid="updated-at">{{ updatedAt }}</div>
+ <div data-testid="abuse-report-date">{{ displayDate }}</div>
+ <abuse-report-actions :report="report" />
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
index b60fe3ae9b8..e1989cadd86 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -8,7 +8,7 @@ import {
SORT_OPTIONS,
isValidSortKey,
} from '~/admin/abuse_reports/constants';
-import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils';
+import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
export default {
name: 'AbuseReportsFilteredSearchBar',
@@ -32,7 +32,7 @@ export default {
// Backend shows open reports by default if status param is not specified.
// To match that behavior, update the current URL to include status=open
// query when no status query is specified on load.
- if (!query.status) {
+ if (!isValidStatus(query.status)) {
query.status = 'open';
updateHistory({ url: setUrlParams(query), replace: true });
}
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index ee2e9ab2cbf..ee002f269ac 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -40,25 +40,24 @@ export const FILTERED_SEARCH_TOKEN_STATUS = {
};
export const DEFAULT_SORT = 'created_at_desc';
-
-export const SORT_OPTIONS = [
- {
- id: 10,
- title: __('Created date'),
- sortDirection: {
- descending: DEFAULT_SORT,
- ascending: 'created_at_asc',
- },
+export const SORT_UPDATED_AT = Object.freeze({
+ id: 20,
+ title: __('Updated date'),
+ sortDirection: {
+ descending: 'updated_at_desc',
+ ascending: 'updated_at_asc',
},
- {
- id: 20,
- title: __('Updated date'),
- sortDirection: {
- descending: 'updated_at_desc',
- ascending: 'updated_at_asc',
- },
+});
+const SORT_CREATED_AT = Object.freeze({
+ id: 10,
+ title: __('Created date'),
+ sortDirection: {
+ descending: DEFAULT_SORT,
+ ascending: 'created_at_asc',
},
-];
+});
+
+export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT];
export const isValidSortKey = (key) =>
SORT_OPTIONS.some(
@@ -79,3 +78,12 @@ export const FILTERED_SEARCH_TOKENS = [
FILTERED_SEARCH_TOKEN_REPORTER,
FILTERED_SEARCH_TOKEN_STATUS,
];
+
+export const ACTIONS_I18N = {
+ blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'),
+ blockUser: __('Block user'),
+ alreadyBlocked: __('Already blocked'),
+ removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'),
+ removeUserAndReport: __('Remove user & report'),
+ removeReport: __('Remove report'),
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
index 84221901089..d30e8fb0ae5 100644
--- a/app/assets/javascripts/admin/abuse_reports/utils.js
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -1,6 +1,9 @@
-import { FILTERED_SEARCH_TOKEN_CATEGORY } from './constants';
+import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants';
export const buildFilteredSearchCategoryToken = (categories) => {
const options = categories.map((c) => ({ value: c, title: c }));
return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options };
};
+
+export const isValidStatus = (status) =>
+ FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status);
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
index 4482198675d..3168d693234 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -3,6 +3,7 @@ import {
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
@@ -18,7 +19,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { THEMES, TYPES, TYPE_BANNER } from '../constants';
-import MessageFormGroup from './message_form_group.vue';
import DatetimePicker from './datetime_picker.vue';
const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } };
@@ -31,13 +31,13 @@ export default {
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlFormTextarea,
- MessageFormGroup,
},
directives: {
SafeHtml,
@@ -189,7 +189,7 @@ export default {
<div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div>
</gl-broadcast-message>
- <message-form-group :label="$options.i18n.message" label-for="message-textarea">
+ <gl-form-group :label="$options.i18n.message" label-for="message-textarea">
<gl-form-textarea
id="message-textarea"
v-model="message"
@@ -198,23 +198,23 @@ export default {
:placeholder="$options.i18n.messagePlaceholder"
data-testid="message-input"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.type" label-for="type-select">
+ <gl-form-group :label="$options.i18n.type" label-for="type-select">
<gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" />
- </message-form-group>
+ </gl-form-group>
<template v-if="isBanner">
- <message-form-group :label="$options.i18n.theme" label-for="theme-select">
+ <gl-form-group :label="$options.i18n.theme" label-for="theme-select">
<gl-form-select
id="theme-select"
v-model="theme"
:options="$options.messageThemes"
data-testid="theme-select"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
+ <gl-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
<gl-form-checkbox
id="dismissable-checkbox"
v-model="dismissable"
@@ -223,10 +223,10 @@ export default {
>
<span>{{ $options.i18n.dismissableDescription }}</span>
</gl-form-checkbox>
- </message-form-group>
+ </gl-form-group>
</template>
- <message-form-group
+ <gl-form-group
v-if="glFeatures.roleTargetedBroadcastMessages"
:label="$options.i18n.targetRoles"
data-testid="target-roles-checkboxes"
@@ -235,24 +235,24 @@ export default {
<gl-form-text>
{{ $options.i18n.targetRolesDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
+ <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
<gl-form-input id="target-path-input" v-model="targetPath" />
<gl-form-text>
{{ $options.i18n.targetPathDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.startsAt">
+ <gl-form-group :label="$options.i18n.startsAt">
<datetime-picker v-model="startsAt" />
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.endsAt">
+ <gl-form-group :label="$options.i18n.endsAt">
<datetime-picker v-model="endsAt" />
- </message-form-group>
+ </gl-form-group>
- <div class="form-actions gl-my-3">
+ <div class="gl-my-5">
<gl-button
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
deleted file mode 100644
index eec51c0c28b..00000000000
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<script>
-import { GlFormGroup } from '@gitlab/ui';
-
-export default {
- name: 'MessageFormGroup',
- components: {
- GlFormGroup,
- },
- props: {
- label: {
- type: String,
- required: true,
- },
- labelFor: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-form-group
- :label="label"
- :label-for="labelFor"
- label-cols-sm="2"
- label-class="gl-mt-3"
- label-align-sm="right"
- >
- <slot></slot>
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 0099c8da8e6..af09c7618e2 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -15,7 +15,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -52,7 +52,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 52560ebe5b1..2060528c7a0 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -17,7 +17,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -54,7 +54,9 @@ export default {
</script>
<template>
- <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 203d076914f..d7bdceb4798 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -30,7 +30,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -67,7 +67,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index d50b76aaa92..534e1c76b8f 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -18,7 +18,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -53,7 +53,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index ab1069601d2..40911131d6d 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -25,7 +25,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -62,7 +62,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index d4f9ff4e529..83aa78c9f03 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -49,9 +49,11 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 413804c9a3b..24f0cac73f5 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { associationsCount } from '~/api/user_api';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
@@ -9,7 +9,7 @@ export default {
loading: __('Loading'),
},
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
GlLoadingIcon,
},
props: {
@@ -71,13 +71,15 @@ export default {
</script>
<template>
- <gl-dropdown-item :disabled="loading" :aria-busy="loading" @click.capture.native.stop="onClick">
- <div v-if="loading" class="gl-display-flex gl-align-items-center">
- <gl-loading-icon class="gl-mr-3" />
- {{ $options.i18n.loading }}
- </div>
- <span v-else class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item :disabled="loading" :aria-busy="loading" @action="onClick">
+ <template #list-item>
+ <div v-if="loading" class="gl-display-flex gl-align-items-center">
+ <gl-loading-icon class="gl-mr-3" />
+ {{ $options.i18n.loading }}
+ </div>
+ <span v-else class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index 2b9c4acfcb5..7f786991709 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -28,7 +28,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -65,7 +65,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index 42b6fb3bdd4..f84c7594f87 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -11,7 +11,7 @@ const messageHtml = `<p>${s__(
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -48,7 +48,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index f94e128a945..064f05ef8b1 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -42,7 +42,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index c78c260b4fe..039ab3d651e 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -41,7 +41,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index c1fb80959cf..38c7d3f9b90 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -1,10 +1,9 @@
<script>
import {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlTooltipDirective,
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
@@ -17,10 +16,9 @@ import Actions from './actions';
export default {
components: {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
...Actions,
},
directives: {
@@ -63,6 +61,9 @@ export default {
hasEditAction() {
return this.userActions.includes('edit');
},
+ hasEditActionOnly() {
+ return this.hasEditAction === true && this.hasDeleteActions === false;
+ },
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
@@ -93,10 +94,13 @@ export default {
class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2"
:data-testid="`user-actions-${user.id}`"
>
- <div v-if="hasEditAction" class="gl-p-2">
- <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{
- $options.i18n.edit
- }}</gl-button>
+ <div v-if="hasEditAction" class="gl-p-2" :class="{ 'gl-mr-3': hasEditActionOnly }">
+ <gl-button
+ v-if="showButtonLabels"
+ v-bind="editButtonAttrs"
+ :class="{ 'gl-mr-7': hasEditActionOnly }"
+ >{{ $options.i18n.edit }}</gl-button
+ >
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
@@ -107,12 +111,15 @@ export default {
</div>
<div v-if="hasDropdownActions" class="gl-p-2">
- <gl-dropdown
- :text="$options.i18n.userAdministration"
+ <gl-disclosure-dropdown
+ icon="ellipsis_v"
+ category="tertiary"
+ :toggle-text="$options.i18n.userAdministration"
+ text-sr-only
data-testid="dropdown-toggle"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
- left
+ no-caret
>
<template v-for="action in dropdownSafeActions">
<component
@@ -125,28 +132,32 @@ export default {
>
{{ $options.i18n[action] }}
</component>
- <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
- {{ $options.i18n[action] }}
- </gl-dropdown-item>
- </template>
-
- <gl-dropdown-divider v-if="hasDeleteActions" />
-
- <template v-for="action in dropdownDeleteActions">
- <component
- :is="getActionComponent(action)"
- v-if="getActionComponent(action)"
+ <gl-disclosure-dropdown-item
+ v-else-if="isLdapAction(action)"
:key="action"
- :paths="userPaths"
- :username="user.name"
- :user-id="user.id"
- :user-deletion-obstacles="obstaclesForUserDeletion"
- :data-testid="`delete-${action}`"
+ :data-testid="action"
>
{{ $options.i18n[action] }}
- </component>
+ </gl-disclosure-dropdown-item>
</template>
- </gl-dropdown>
+
+ <gl-disclosure-dropdown-group v-if="hasDeleteActions" bordered>
+ <template v-for="action in dropdownDeleteActions">
+ <component
+ :is="getActionComponent(action)"
+ v-if="getActionComponent(action)"
+ :key="action"
+ :paths="userPaths"
+ :username="user.name"
+ :user-id="user.id"
+ :user-deletion-obstacles="obstaclesForUserDeletion"
+ :data-testid="`delete-${action}`"
+ >
+ {{ $options.i18n[action] }}
+ </component>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index e55622d40ba..2d2c598f953 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -135,7 +135,7 @@ export default {
</template>
<template #cell(settings)="{ item: user }">
- <user-actions :user="user" :paths="paths" />
+ <user-actions :user="user" :paths="paths" :show-button-labels="true" />
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 5229d4c9ae2..170bd6895aa 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -12,6 +12,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sortObjectToString } from '~/lib/utils/table_utility';
import { fetchPolicies } from '~/lib/graphql';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
@@ -229,7 +230,7 @@ export default {
},
getIssueMeta({ issue: { iid, state } }) {
return {
- state: state === 'closed' ? `(${this.$options.i18n.closed})` : '',
+ state: state === STATUS_CLOSED ? `(${this.$options.i18n.closed})` : '',
link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid),
};
},
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
index c98d3865621..4ab772e4523 100644
--- a/app/assets/javascripts/alert_management/constants.js
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -37,12 +37,10 @@ export const ALERTS_STATUS_TABS = [
},
];
-/* eslint-disable @gitlab/require-i18n-strings */
-
/**
* Tracks snowplow event when user views alerts list
*/
export const trackAlertListViewsOptions = {
- category: 'Alert Management',
+ category: 'Alert Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'view_alerts_list',
};
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 2733a59f62d..1a586bd1e91 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -142,7 +142,7 @@ export default {
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
v-gl-tooltip
- name="question"
+ name="question-o"
class="gl-text-gray-500"
:title="$options.i18n.fallbackTooltip"
/>
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index e45ea772ddd..4df5ed425a5 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import axios from '~/lib/utils/axios_utils';
export default {
@@ -9,7 +8,7 @@ export default {
return axios.post(endpoint, data, {
headers: {
'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
+ Authorization: `Bearer ${token}`, // eslint-disable-line @gitlab/require-i18n-strings
},
});
},
diff --git a/app/assets/javascripts/analytics/cycle_analytics/bundle.js b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
new file mode 100644
index 00000000000..9fe31620938
--- /dev/null
+++ b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
@@ -0,0 +1 @@
+export { default } from '~/analytics/cycle_analytics';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
index 704b4ce9c8a..365cbeaf6a2 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -107,7 +107,9 @@ export default {
},
showLinkToDashboard() {
return Boolean(
- this.features?.groupLevelAnalyticsDashboard && this.features?.groupAnalyticsDashboardsPage,
+ this.features?.groupLevelAnalyticsDashboard &&
+ this.features?.groupAnalyticsDashboardsPage &&
+ this.groupPath,
);
},
dashboardsPath() {
@@ -129,6 +131,9 @@ export default {
page: this.pagination?.page || null,
};
},
+ filterBarNamespacePath() {
+ return this.groupPath || this.namespace.fullPath;
+ },
},
methods: {
...mapActions([
@@ -168,10 +173,11 @@ export default {
<div>
<h3>{{ $options.i18n.pageTitle }}</h3>
<value-stream-filters
- :group-path="groupPath"
+ :namespace-path="filterBarNamespacePath"
:has-project-filter="false"
:start-date="createdAfter"
:end-date="createdBefore"
+ :group-path="groupPath"
@setDateRange="onSetDateRange"
/>
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
index 54b632968e2..133513d6c21 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -30,7 +30,7 @@ export default {
UrlSync,
},
props: {
- groupPath: {
+ namespacePath: {
type: String,
required: true,
},
@@ -141,7 +141,7 @@ export default {
<div>
<filtered-search-bar
class="gl-flex-grow-1"
- :namespace="groupPath"
+ :namespace="namespacePath"
recent-searches-storage-key="value-stream-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
index 4c7e18f9895..b9d1c4b0fe0 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
@@ -31,6 +31,10 @@ export default {
required: false,
default: true,
},
+ namespacePath: {
+ type: String,
+ required: true,
+ },
groupPath: {
type: String,
required: true,
@@ -69,7 +73,7 @@ export default {
<filter-bar
data-testid="vsa-filter-bar"
class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
- :group-path="groupPath"
+ :namespace-path="namespacePath"
/>
<div
v-if="hasDateRangeFilter || hasProjectFilter"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js
index 9265ff952e0..d7c3804113e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/utils.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js
@@ -1,3 +1,4 @@
+import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { joinPaths } from '~/lib/utils/url_utility';
@@ -64,28 +65,6 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
/**
- * @typedef {Object} MetricData
- * @property {String} title - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} [unit] - String representing the decimal point value, e.g '1.5'
- *
- * @typedef {Object} TransformedMetricData
- * @property {String} label - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute
- * @property {String} description - String to display for a description
- * @property {String} unit - String representing the decimal point value, e.g '1.5'
- */
-
-const extractFeatures = (gon) => ({
- // licensed feature toggles
- cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
- groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
- // feature flags
- groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage),
-});
-
-/**
* Builds the initial data object for Value Stream Analytics with data loaded from the backend
*
* @param {Object} dataset - dataset object paseed to the frontend via data-* properties
@@ -99,11 +78,10 @@ export const buildCycleAnalyticsInitialData = ({
createdBefore,
namespaceName,
namespaceFullPath,
- gon,
} = {}) => {
return {
projectId: parseInt(projectId, 10),
- groupPath: `groups/${groupPath}`,
+ groupPath,
namespace: {
name: namespaceName,
fullPath: namespaceFullPath,
@@ -111,7 +89,7 @@ export const buildCycleAnalyticsInitialData = ({
createdAfter: new Date(createdAfter),
createdBefore: new Date(createdBefore),
selectedStage: stage ? JSON.parse(stage) : null,
- features: extractFeatures(gon),
+ features: extractVSAFeaturesFromGON(),
};
};
diff --git a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
index 95a6447ebaf..6c79c8af54a 100644
--- a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
@@ -13,6 +13,7 @@ export default {
},
i18n: {
title: __('Related'),
+ // eslint-disable-next-line @gitlab/require-valid-i18n-helpers
linkText: __('Value Streams Dashboard | DORA'),
},
};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index a85f3fb3730..88a0f6f30cb 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -129,12 +129,28 @@ export const fetchMetricsData = (requests = [], requestPath, params) => {
* @param {Array} projectPaths - Array of project paths to include in the `query` parameter
* @returns a URL or blank string if there is no groupPath set
*/
-export const generateValueStreamsDashboardLink = (groupPath, projectPaths = []) => {
- if (groupPath.length) {
+export const generateValueStreamsDashboardLink = (namespacePath, projectPaths = []) => {
+ if (namespacePath.length) {
const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : '';
const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard';
- const segments = [gon.relative_url_root || '', '/', groupPath, dashboardsSlug];
+ const segments = [gon.relative_url_root || '', '/', namespacePath, dashboardsSlug];
return joinPaths(...segments).concat(query);
}
return '';
};
+
+/**
+ * Extracts the relevant feature and license flags needed for VSA
+ *
+ * @param {Object} gon the global `window.gon` object populated when the page loads
+ * @returns an object containing the extracted feature flags and their boolean status
+ */
+export const extractVSAFeaturesFromGON = () => ({
+ // licensed feature toggles
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+ cycleAnalyticsForProjects: Boolean(gon?.licensed_features?.cycleAnalyticsForProjects),
+ groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
+ // feature flags
+ groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage),
+ vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity),
+});
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 5c0d101ef5b..c72a913aacd 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -21,7 +21,7 @@ export function getProjects(query, options, callback = () => {}) {
defaults.membership = true;
}
- if (gon.features.fullPathProjectSearch && query?.includes('/')) {
+ if (query?.includes('/')) {
defaults.search_namespaces = true;
}
@@ -35,6 +35,13 @@ export function getProjects(query, options, callback = () => {}) {
});
}
+export function createProject(projectData) {
+ const url = buildApiUrl(PROJECTS_PATH);
+ return axios.post(url, projectData).then(({ data }) => {
+ return data;
+ });
+}
+
export function importProjectMembers(sourceId, targetId) {
const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
.replace(':id', sourceId)
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index bcb0f079d3d..3ebb07807d2 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,6 +1,4 @@
import { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/alert';
-import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
@@ -44,22 +42,12 @@ export function getUserStatus(id, options) {
});
}
-export function getUserProjects(userId, query, options, callback) {
+export function getUserProjects(userId, options) {
const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId);
- const defaults = {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- };
- return axios
- .get(url, {
- params: { ...defaults, ...options },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('Something went wrong while fetching projects'),
- }),
- );
+
+ return axios.get(url, {
+ params: options,
+ });
}
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
deleted file mode 100644
index cc08551fdb7..00000000000
--- a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
+++ /dev/null
@@ -1,182 +0,0 @@
-<script>
-import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/alert';
-import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
-import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
-import { removeArtifactFromStore } from '../graphql/cache_update';
-import {
- I18N_BULK_DELETE_BANNER,
- I18N_BULK_DELETE_CLEAR_SELECTION,
- I18N_BULK_DELETE_DELETE_SELECTED,
- I18N_BULK_DELETE_MODAL_TITLE,
- I18N_BULK_DELETE_BODY,
- I18N_BULK_DELETE_ACTION,
- I18N_BULK_DELETE_PARTIAL_ERROR,
- I18N_BULK_DELETE_ERROR,
- I18N_MODAL_CANCEL,
- BULK_DELETE_MODAL_ID,
-} from '../constants';
-
-export default {
- name: 'ArtifactsBulkDelete',
- components: {
- GlButton,
- GlModal,
- GlSprintf,
- },
- inject: ['projectId'],
- props: {
- selectedArtifacts: {
- type: Array,
- required: true,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- isModalVisible: false,
- isDeleting: false,
- };
- },
- computed: {
- checkedCount() {
- return this.selectedArtifacts.length || 0;
- },
- modalActionPrimary() {
- return {
- text: I18N_BULK_DELETE_ACTION(this.checkedCount),
- attributes: {
- loading: this.isDeleting,
- variant: 'danger',
- },
- };
- },
- modalActionCancel() {
- return {
- text: I18N_MODAL_CANCEL,
- attributes: {
- loading: this.isDeleting,
- },
- };
- },
- },
- methods: {
- async onConfirmDelete(e) {
- // don't close modal until deletion is complete
- if (e) {
- e.preventDefault();
- }
- this.isDeleting = true;
-
- try {
- await this.$apollo.mutate({
- mutation: bulkDestroyJobArtifactsMutation,
- variables: {
- projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
- ids: this.selectedArtifacts,
- },
- update: (store, { data }) => {
- const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
- if (errors?.length) {
- createAlert({
- message: I18N_BULK_DELETE_PARTIAL_ERROR,
- captureError: true,
- error: new Error(errors.join(' ')),
- });
- }
- if (destroyedIds?.length) {
- this.$emit('deleted', destroyedCount);
-
- // Remove deleted artifacts from the cache
- destroyedIds.forEach((id) => {
- removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
- });
- store.gc();
-
- this.$emit('clearSelectedArtifacts');
- }
- },
- });
- } catch (error) {
- this.onError(error);
- } finally {
- this.isDeleting = false;
- this.isModalVisible = false;
- }
- },
- onError(error) {
- createAlert({
- message: I18N_BULK_DELETE_ERROR,
- captureError: true,
- error,
- });
- },
- handleClearSelection() {
- this.$emit('clearSelectedArtifacts');
- },
- handleModalShow() {
- this.isModalVisible = true;
- },
- handleModalHide() {
- this.isModalVisible = false;
- },
- },
- i18n: {
- banner: I18N_BULK_DELETE_BANNER,
- clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
- deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
- modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
- modalBody: I18N_BULK_DELETE_BODY,
- },
- BULK_DELETE_MODAL_ID,
-};
-</script>
-<template>
- <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
- <div class="gl-display-flex gl-align-items-center">
- <div>
- <gl-sprintf :message="$options.i18n.banner(checkedCount)">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <div class="gl-ml-auto">
- <gl-button
- variant="default"
- data-testid="bulk-delete-clear-button"
- @click="handleClearSelection"
- >
- {{ $options.i18n.clearSelection }}
- </gl-button>
- <gl-button
- variant="danger"
- data-testid="bulk-delete-delete-button"
- @click="handleModalShow"
- >
- {{ $options.i18n.deleteSelected }}
- </gl-button>
- </div>
- </div>
- <gl-modal
- size="sm"
- :modal-id="$options.BULK_DELETE_MODAL_ID"
- :visible="isModalVisible"
- :title="$options.i18n.modalTitle(checkedCount)"
- :action-primary="modalActionPrimary"
- :action-cancel="modalActionCancel"
- @hide="handleModalHide"
- @primary="onConfirmDelete"
- >
- <gl-sprintf
- data-testid="bulk-delete-modal-content"
- :message="$options.i18n.modalBody(checkedCount)"
- />
- </gl-modal>
- </div>
-</template>
diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue
new file mode 100644
index 00000000000..7808620cca1
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/components/password_input.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { SHOW_PASSWORD, HIDE_PASSWORD, PASSWORD_TITLE } from '../constants';
+
+export default {
+ name: 'PasswordInput',
+ i18n: {
+ showPassword: SHOW_PASSWORD,
+ hidePassword: HIDE_PASSWORD,
+ },
+ components: {
+ GlFormInput,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ resourceName: {
+ type: String,
+ required: true,
+ },
+ minimumPasswordLength: {
+ type: String,
+ required: true,
+ },
+ qaSelector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMasked: true,
+ };
+ },
+ computed: {
+ passwordTitle() {
+ return sprintf(PASSWORD_TITLE, { minimum_password_length: this.minimumPasswordLength });
+ },
+ type() {
+ return this.isMasked ? 'password' : 'text';
+ },
+ toggleVisibilityLabel() {
+ return this.isMasked ? this.$options.i18n.showPassword : this.$options.i18n.hidePassword;
+ },
+ toggleVisibilityIcon() {
+ return this.isMasked ? 'eye' : 'eye-slash';
+ },
+ },
+ methods: {
+ handleToggleVisibilityButtonClick() {
+ this.isMasked = !this.isMasked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-field-error-anchor input-icon-wrapper">
+ <gl-form-input
+ :id="`${resourceName}_password`"
+ class="js-password-complexity-validation gl-pr-8!"
+ required
+ autocomplete="new-password"
+ :name="`${resourceName}[password]`"
+ :minlength="minimumPasswordLength"
+ :data-qa-selector="qaSelector"
+ :title="passwordTitle"
+ :type="type"
+ />
+ <gl-button
+ v-gl-tooltip="toggleVisibilityLabel"
+ class="input-icon-right gl-right-0!"
+ category="tertiary"
+ :aria-label="toggleVisibilityLabel"
+ :icon="toggleVisibilityIcon"
+ @click="handleToggleVisibilityButtonClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/password/constants.js b/app/assets/javascripts/authentication/password/constants.js
new file mode 100644
index 00000000000..97e1a882d9d
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/constants.js
@@ -0,0 +1,8 @@
+import { __, s__ } from '~/locale';
+
+export const SHOW_PASSWORD = __('Show password');
+export const HIDE_PASSWORD = __('Hide password');
+
+export const PASSWORD_TITLE = s__(
+ 'SignUp|Minimum length is %{minimum_password_length} characters.',
+);
diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js
new file mode 100644
index 00000000000..36e3b74263c
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import GlFieldErrors from '~/gl_field_errors';
+import PasswordInput from './components/password_input.vue';
+
+export const initTogglePasswordVisibility = () => {
+ const el = document.querySelector('.js-password');
+
+ if (!el) {
+ return null;
+ }
+
+ const { form } = el;
+ const { resourceName, minimumPasswordLength, qaSelector } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'PasswordInputRoot',
+ render(createElement) {
+ return createElement(PasswordInput, {
+ props: {
+ resourceName,
+ minimumPasswordLength,
+ qaSelector,
+ },
+ });
+ },
+ });
+
+ // Since we replaced password input, we need to re-initialize the field errors handler
+ return new GlFieldErrors(form);
+};
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
index 98ed2a31730..907b68e6ffc 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
@@ -7,7 +7,6 @@ export const i18n = {
currentPassword: __('Current password'),
confirmTitle: __('Are you sure?'),
confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'),
- confirm: __('This will invalidate your registered applications and WebAuthn devices.'),
disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
@@ -41,7 +40,6 @@ export default {
GlModal,
},
inject: [
- 'webauthnEnabled',
'isCurrentPasswordRequired',
'profileTwoFactorAuthPath',
'profileTwoFactorAuthMethod',
@@ -59,11 +57,7 @@ export default {
},
computed: {
confirmText() {
- if (this.webauthnEnabled) {
- return i18n.confirmWebAuthn;
- }
-
- return i18n.confirm;
+ return i18n.confirmWebAuthn;
},
},
methods: {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js
index 7d21c19ac4c..cec80335ba0 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/index.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/index.js
@@ -13,7 +13,6 @@ export const initManageTwoFactorForm = () => {
}
const {
- webauthnEnabled = false,
currentPasswordRequired,
profileTwoFactorAuthPath = '',
profileTwoFactorAuthMethod = '',
@@ -26,7 +25,6 @@ export const initManageTwoFactorForm = () => {
return new Vue({
el,
provide: {
- webauthnEnabled,
isCurrentPasswordRequired,
profileTwoFactorAuthPath,
profileTwoFactorAuthMethod,
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index beda251aa1e..107796a31e0 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,19 +1,10 @@
<script>
-import {
- GlDropdown,
- GlButton,
- GlIcon,
- GlForm,
- GlFormGroup,
- GlLink,
- GlFormCheckbox,
-} from '@gitlab/ui';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { createAlert } from '~/alert';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
-import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -22,7 +13,6 @@ export default {
GlIcon,
GlForm,
GlFormGroup,
- GlLink,
GlFormCheckbox,
MarkdownField,
ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
@@ -102,9 +92,6 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
- helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
- anchor: 'submit-a-review',
- }),
};
</script>
@@ -126,14 +113,6 @@ export default {
<gl-form-group label-for="review-note-body" label-class="gl-mb-2">
<template #label>
{{ __('Summary comment (optional)') }}
- <gl-link
- :href="$options.helpPagePath"
- :aria-label="__('More information')"
- target="_blank"
- class="gl-ml-2"
- >
- <gl-icon name="question-o" />
- </gl-link>
</template>
<div class="common-note-form gfm-form">
<div
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 2a8786134cc..f6e9bfd6690 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -7,6 +7,8 @@ import store from '~/mr_notes/stores';
export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
+ if (!el) return;
+
Vue.use(VueApollo);
// eslint-disable-next-line no-new
@@ -18,7 +20,7 @@ export const initReviewBar = () => {
ReviewBar: () => import('./components/review_bar.vue'),
},
provide: {
- newSavedRepliesPath: el.dataset.savedRepliesNewPath,
+ newCommentTemplatePath: el.dataset.newCommentTemplatePath,
},
computed: {
...mapGetters('batchComments', ['draftsCount']),
diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js
index 11fe01ca48d..89f1ad9c89e 100644
--- a/app/assets/javascripts/behaviors/date_picker.js
+++ b/app/assets/javascripts/behaviors/date_picker.js
@@ -9,7 +9,7 @@ export default function initDatePickers() {
const calendar = new Pikaday({
field: $datePicker.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index b1bf6ebcb13..b2348cf0bad 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -97,7 +97,7 @@ class SafeMathRenderer {
<button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button>
</div>
</div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <button type="button" class="close js-close" aria-label="Close">
${spriteIcon('close', 's16')}
</button>
</div>
@@ -184,17 +184,24 @@ class SafeMathRenderer {
attachEvents() {
document.body.addEventListener('click', (event) => {
- if (!event.target.classList.contains('js-lazy-render-math')) {
+ const alert = event.target.closest('.js-lazy-render-math-container');
+
+ if (!alert) {
return;
}
- const parent = event.target.closest('.js-lazy-render-math-container');
-
- const pre = parent.nextElementSibling;
-
- parent.remove();
+ // Handle alert close
+ if (event.target.closest('.js-close')) {
+ alert.remove();
+ return;
+ }
- this.renderElement(pre);
+ // Handle "render anyway"
+ if (event.target.classList.contains('js-lazy-render-math')) {
+ const pre = alert.nextElementSibling;
+ alert.remove();
+ this.renderElement(pre);
+ }
});
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 6a7ce4f1c41..301dd1c5669 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -204,7 +204,11 @@ export default class Shortcuts {
}
static focusSearch(e) {
- $('#search').focus();
+ if (gon.use_new_navigation) {
+ document.querySelector('#super-sidebar-search')?.click();
+ } else {
+ document.querySelector('#search')?.focus();
+ }
if (e.preventDefault) {
e.preventDefault();
diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js
index 2c1c6339fdb..834aa3e5354 100644
--- a/app/assets/javascripts/blob/sketch_viewer.js
+++ b/app/assets/javascripts/blob/sketch_viewer.js
@@ -1,8 +1,7 @@
-/* eslint-disable no-new */
import SketchLoader from './sketch';
export default () => {
const el = document.getElementById('js-sketch-viewer');
- new SketchLoader(el);
+ new SketchLoader(el); // eslint-disable-line no-new
};
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 7eb699eacbe..59b7f82c10e 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,5 +1,3 @@
-/* eslint-disable class-methods-use-this */
-
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -70,6 +68,7 @@ export default class TemplateSelector {
return this.requestFile(item);
}
+ // eslint-disable-next-line class-methods-use-this
requestFile() {
// This `requestFile` method is an abstract method that should
// be added by all subclasses.
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 01d35a0980f..7e667409556 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { createAlert } from '~/alert';
@@ -54,6 +52,7 @@ export default () => {
import('./edit_blob')
.then(({ default: EditBlob } = {}) => {
+ // eslint-disable-next-line no-new
new EditBlob({
assetsPath: `${urlRoot}${assetsPath}`,
filePath,
@@ -80,7 +79,7 @@ export default () => {
window.onbeforeunload = null;
});
- new NewCommitForm(editBlobForm);
+ new NewCommitForm(editBlobForm); // eslint-disable-line no-new
// returning here blocks page navigation
window.onbeforeunload = () => '';
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 48dfcf81f1e..c7e6cb38d15 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -4,6 +4,7 @@ import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export default {
components: {
@@ -11,7 +12,7 @@ export default {
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['initialBoardId', 'initialFilterParams'],
+ inject: ['initialBoardId', 'initialFilterParams', 'isIssueBoard', 'isApolloBoard'],
data() {
return {
boardId: this.initialBoardId,
@@ -19,11 +20,31 @@ export default {
isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
};
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
+
computed: {
...mapGetters(['isSidebarOpen']),
isSwimlanesOn() {
return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false;
},
+ isAnySidebarOpen() {
+ if (this.isApolloBoard) {
+ return this.activeBoardItem?.id;
+ }
+ return this.isSidebarOpen;
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -45,7 +66,7 @@ export default {
</script>
<template>
- <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
+ <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }">
<board-top-bar
:board-id="boardId"
:is-swimlanes-on="isSwimlanesOn"
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3071c1f334e..18495f285da 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,6 +1,8 @@
<script>
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import BoardCardInner from './board_card_inner.vue';
export default {
@@ -9,7 +11,7 @@ export default {
BoardCardInner,
},
mixins: [Tracking.mixin()],
- inject: ['disabled', 'isApolloBoard'],
+ inject: ['disabled', 'isIssueBoard', 'isApolloBoard'],
props: {
list: {
type: Object,
@@ -37,14 +39,30 @@ export default {
default: true,
},
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
...mapState(['selectedBoardItems', 'activeId']),
+ activeItemId() {
+ return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId;
+ },
isActive() {
- return this.item.id === this.activeId;
+ return this.item.id === this.activeItemId;
},
multiSelectVisible() {
return (
- !this.activeId &&
+ !this.activeItemId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
);
},
@@ -83,10 +101,23 @@ export default {
if (isMultiSelect && gon?.features?.boardMultiSelect) {
this.toggleBoardItemMultiSelection(this.item);
} else {
- this.toggleBoardItem({ boardItem: this.item });
+ if (this.isApolloBoard) {
+ this.toggleItem();
+ } else {
+ this.toggleBoardItem({ boardItem: this.item });
+ }
this.track('click_card', { label: 'right_sidebar' });
}
},
+ toggleItem() {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: this.item,
+ isIssue: this.isIssueBoard,
+ },
+ });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 88f51c71e06..befd04c29ae 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -275,7 +275,7 @@ export default {
<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 gl-text-secondary"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
<work-item-type-icon
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 84a8781db1c..946f3712834 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -206,6 +206,7 @@ export default {
<epics-swimlanes
v-else-if="boardListsToUse.length"
ref="swimlanes"
+ :board-id="boardId"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:filters="filterParams"
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 675878683ab..1b97214ff8b 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,10 +3,12 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import { __, sprintf } from '~/locale';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { ISSUABLE, INCIDENT } from '~/boards/constants';
+import { INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
@@ -16,7 +18,6 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -39,7 +40,6 @@ export default {
SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
multipleAssigneesFeatureAvailable: {
default: false,
@@ -71,31 +71,46 @@ export default {
isGroupBoard: {
default: false,
},
+ isApolloBoard: {
+ default: false,
+ },
},
inheritAttrs: false,
+ apollo: {
+ activeBoardCard: {
+ query: activeBoardItemQuery,
+ variables: {
+ isIssue: true,
+ },
+ update(data) {
+ if (!data.activeBoardItem?.id) {
+ return { id: '', iid: '' };
+ }
+ return {
+ ...data.activeBoardItem,
+ assignees: data.activeBoardItem.assignees?.nodes || [],
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
- ...mapGetters([
- 'isSidebarOpen',
- 'activeBoardItem',
- 'groupPathForActiveIssue',
- 'projectPathForActiveIssue',
- ]),
+ ...mapGetters(['activeBoardItem']),
...mapState(['sidebarType']),
- isIssuableSidebar() {
- return this.sidebarType === ISSUABLE;
+ activeBoardIssuable() {
+ return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem;
},
- isIncidentSidebar() {
- return this.activeBoardItem.type === INCIDENT;
+ isSidebarOpen() {
+ return Boolean(this.activeBoardIssuable?.id);
},
- showSidebar() {
- return this.isIssuableSidebar && this.isSidebarOpen;
+ isIncidentSidebar() {
+ return this.activeBoardIssuable?.type === INCIDENT;
},
sidebarTitle() {
return this.isIncidentSidebar ? __('Incident details') : __('Issue details');
},
- fullPath() {
- return this.activeBoardItem?.referencePath?.split('#')[0] || '';
- },
parentType() {
return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
@@ -120,6 +135,14 @@ export default {
? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue)
: this.labelsFilterBasePath;
},
+ groupPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.lastIndexOf('/'));
+ },
+ projectPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
},
methods: {
...mapActions([
@@ -131,7 +154,19 @@ export default {
'setActiveItemHealthStatus',
]),
handleClose() {
- this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: null,
+ },
+ });
+ } else {
+ this.toggleBoardItem({
+ boardItem: this.activeBoardIssuable,
+ sidebarType: this.sidebarType,
+ });
+ }
},
handleUpdateSelectedLabels({ labels, id }) {
this.setActiveBoardItemLabels({
@@ -143,7 +178,7 @@ export default {
},
handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
- iid: this.activeBoardItem.iid,
+ iid: this.activeBoardIssuable.iid,
projectPath: this.projectPathForActiveIssue,
removeLabelIds: [removeLabelId],
});
@@ -156,7 +191,7 @@ export default {
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
<gl-drawer
v-bind="$attrs"
- :open="showSidebar"
+ :open="isSidebarOpen"
class="boards-sidebar"
variant="sidebar"
@close="handleClose"
@@ -167,25 +202,27 @@ export default {
<template #header>
<sidebar-todo-widget
class="gl-mt-3"
- :issuable-id="activeBoardItem.id"
- :issuable-iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :issuable-id="activeBoardIssuable.id"
+ :issuable-iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
/>
</template>
<template #default>
- <board-sidebar-title data-testid="sidebar-title" />
+ <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" />
<sidebar-assignees-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :initial-assignees="activeBoardItem.assignees"
+ v-if="activeBoardItem.assignees"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
+ :initial-assignees="activeBoardIssuable.assignees"
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
:editable="canUpdate"
- @assignees-updated="setAssignees"
+ @assignees-updated="!isApolloBoard && setAssignees($event)"
/>
<sidebar-dropdown-widget
v-if="epicFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`epic-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="epic"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
@@ -194,7 +231,8 @@ export default {
/>
<div>
<sidebar-dropdown-widget
- :iid="activeBoardItem.iid"
+ :key="`milestone-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="milestone"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="projectPathForActiveIssue"
@@ -203,7 +241,8 @@ export default {
/>
<sidebar-iteration-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`iteration-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
@@ -213,14 +252,14 @@ export default {
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-due-date"
/>
<sidebar-labels-widget
class="block labels"
- :iid="activeBoardItem.iid"
+ :iid="activeBoardIssuable.iid"
:full-path="projectPathForActiveIssue"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
@@ -232,40 +271,40 @@ export default {
workspace-type="project"
:issuable-type="issuableType"
:label-create-type="labelType"
- @onLabelRemove="handleLabelRemove"
- @updateSelectedLabels="handleUpdateSelectedLabels"
+ @onLabelRemove="!isApolloBoard && handleLabelRemove($event)"
+ @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)"
>
{{ __('None') }}
</sidebar-labels-widget>
<sidebar-severity-widget
v-if="isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :project-path="fullPath"
- :initial-severity="activeBoardItem.severity"
+ :iid="activeBoardIssuable.iid"
+ :project-path="projectPathForActiveIssue"
+ :initial-severity="activeBoardIssuable.severity"
/>
<sidebar-weight-widget
v-if="weightFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @weightUpdated="setActiveItemWeight($event)"
+ @weightUpdated="!isApolloBoard && setActiveItemWeight($event)"
/>
<sidebar-health-status-widget
v-if="healthStatusFeatureAvailable"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @statusUpdated="setActiveItemHealthStatus($event)"
+ @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)"
/>
<sidebar-confidentiality-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @confidentialityUpdated="setActiveItemConfidential($event)"
+ @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)"
/>
<sidebar-subscriptions-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-notifications"
/>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 2e14afad963..46612320136 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -22,7 +22,7 @@ import {
TOKEN_TYPE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { AssigneeFilterType } from '~/boards/constants';
+import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants';
import { TYPENAME_ITERATION } from '~/graphql_shared/constants';
import eventHub from '../eventhub';
@@ -33,6 +33,11 @@ export default {
components: { FilteredSearch },
inject: ['initialFilterParams', 'isApolloBoard'],
props: {
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tokens: {
type: Array,
required: true,
@@ -321,6 +326,7 @@ export default {
release_tag: releaseTag,
confidential,
health_status: healthStatus,
+ group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined,
},
(value) => {
if (value || value === false) {
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 9ea801dc9a2..604e71f5993 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -226,12 +226,10 @@ export default {
}
this.cancel();
- if (!this.isApolloBoard) {
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
- }
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
} catch {
this.setError({ message: this.$options.i18n.saveErrorMessage });
} finally {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index a47db661445..5f082066ad4 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sprintf, __ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
@@ -158,10 +159,10 @@ export default {
return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore;
},
epicCreateFormVisible() {
- return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
+ return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm;
},
issueCreateFormVisible() {
- return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
+ return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm;
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
@@ -418,7 +419,6 @@ export default {
v-if="loadingMore"
size="sm"
:label="$options.i18n.loadingMoreboardItems"
- data-testid="count-loading-icon"
/>
<span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index f4358315d45..7dc3e464af0 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -229,9 +229,6 @@ export default {
context: {
isSingleRequest: true,
},
- skip() {
- return this.isEpicBoard;
- },
},
},
created() {
@@ -426,7 +423,7 @@ export default {
<div v-if="list.maxIssueCount !== 0">
•
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ itemsTooltipLabel }}</template>
+ <template #issuesSize>{{ itemsCount }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index fad57758be1..c186346b2ac 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -98,6 +98,7 @@ export default {
<issue-board-filtered-search
v-if="isIssueBoard"
:board="board"
+ :is-swimlanes-on="isSwimlanesOn"
@setFilters="$emit('setFilters', $event)"
/>
<epic-board-filtered-search
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index cdcc7b8e5a6..3c056f296e1 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -52,6 +52,11 @@ export default {
required: false,
default: () => {},
},
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tokensCE() {
@@ -203,6 +208,7 @@ export default {
data-testid="issue-board-filtered-search"
:tokens="tokens"
:board="board"
+ :is-swimlanes-on="isSwimlanesOn"
@setFilters="$emit('setFilters', $event)"
/>
</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index c3f7c7d3ca2..1f28974afd1 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -95,9 +95,12 @@ export default {
class="board-card-info-icon gl-mr-2"
name="calendar"
/>
- <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
- body
- }}</time>
+ <time
+ :class="{ 'text-danger': isPastDue }"
+ datetime="date"
+ class="gl-font-sm board-card-info-text"
+ >{{ body }}</time
+ >
</span>
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index bc12717a92d..611e875fa40 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -38,7 +38,7 @@ export default {
<span>
<span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help">
<gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
- <time class="board-card-info-text">{{ timeEstimate }}</time>
+ <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 43a2b13b81c..020edcb01b8 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { titleQueries } from 'ee_else_ce/boards/constants';
export default {
components: {
@@ -19,6 +20,13 @@ export default {
directives: {
autofocusonshow,
},
+ inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'],
+ props: {
+ activeItem: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
title: '',
@@ -27,7 +35,10 @@ export default {
};
},
computed: {
- ...mapGetters({ item: 'activeBoardItem' }),
+ ...mapGetters(['activeBoardItem']),
+ item() {
+ return this.isApolloBoard ? this.activeItem : this.activeBoardItem;
+ },
pendingChangesStorageKey() {
return this.getPendingChangesKey(this.item);
},
@@ -67,8 +78,9 @@ export default {
},
async setPendingState() {
const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey);
+ const shouldOpen = pendingChanges !== this.title;
- if (pendingChanges) {
+ if (pendingChanges && shouldOpen) {
this.title = pendingChanges;
this.showChangesAlert = true;
await this.$nextTick();
@@ -83,6 +95,26 @@ export default {
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
},
+ async setActiveBoardItemTitle() {
+ if (!this.isApolloBoard) {
+ await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ return;
+ }
+ const { fullPath, issuableType, isEpicBoard, title } = this;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: this.projectPath };
+ await this.$apollo.mutate({
+ mutation: titleQueries[issuableType].mutation,
+ variables: {
+ input: {
+ ...workspacePath,
+ iid: String(this.item.iid),
+ title,
+ },
+ },
+ });
+ },
async setTitle() {
this.$refs.sidebarItem.collapse();
@@ -92,7 +124,7 @@ export default {
try {
this.loading = true;
- await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ await this.setActiveBoardItemTitle();
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index b557dc9205e..d12270e58a4 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -12,6 +12,11 @@ import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
+export const BoardType = {
+ project: 'project',
+ group: 'group',
+};
+
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
new file mode 100644
index 00000000000..81b1b68a038
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+query activeBoardItem {
+ activeBoardItem @client {
+ ...Issue
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
new file mode 100644
index 00000000000..cce558c649e
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+mutation setActiveBoardItem($boardItem: Issue) {
+ setActiveBoardItem(boardItem: $boardItem) @client {
+ ...Issue
+ }
+}
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index e895df01f2c..4d1c4be73a3 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import { hide, initTooltips, show } from '~/tooltips';
import { parseBoolean } from './lib/utils/common_utils';
@@ -24,6 +22,7 @@ export default class BuildArtifacts {
// eslint-disable-next-line class-methods-use-this
setupEntryClick() {
+ // eslint-disable-next-line func-names
return $('.tree-holder').on('click', 'tr[data-link]', function () {
visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink));
});
diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/ci/artifacts/components/app.vue
index 3a07be65341..3a07be65341 100644
--- a/app/assets/javascripts/artifacts/components/app.vue
+++ b/app/assets/javascripts/ci/artifacts/components/app.vue
diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
index 14edd73824e..14edd73824e 100644
--- a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
index f37c4c6f107..f37c4c6f107 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
diff --git a/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
new file mode 100644
index 00000000000..b864fc00bdb
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlSprintf,
+ },
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ },
+};
+</script>
+<template>
+ <div
+ v-if="selectedArtifacts.length > 0"
+ class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"
+ data-testid="bulk-delete-container"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="$options.i18n.banner(checkedCount)">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button
+ variant="default"
+ data-testid="bulk-delete-clear-button"
+ @click="$emit('clearSelectedArtifacts')"
+ >
+ {{ $options.i18n.clearSelection }}
+ </gl-button>
+ <gl-button
+ variant="danger"
+ data-testid="bulk-delete-delete-button"
+ @click="$emit('showBulkDeleteModal')"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
index 7d675251ffd..7d675251ffd 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
new file mode 100644
index 00000000000..00f5b2eab7d
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_MODAL_TITLE,
+ I18N_BULK_DELETE_BODY,
+ I18N_BULK_DELETE_ACTION,
+ I18N_MODAL_CANCEL,
+ BULK_DELETE_MODAL_ID,
+} from '../constants';
+
+export default {
+ name: 'BulkDeleteModal',
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ artifactsToDelete: {
+ type: Array,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.artifactsToDelete.length || 0;
+ },
+ modalActionPrimary() {
+ return {
+ text: I18N_BULK_DELETE_ACTION(this.checkedCount),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: I18N_MODAL_CANCEL,
+ attributes: {
+ disabled: this.isDeleting,
+ },
+ };
+ },
+ },
+ BULK_DELETE_MODAL_ID,
+ i18n: {
+ modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
+ modalBody: I18N_BULK_DELETE_BODY,
+ },
+};
+</script>
+<template>
+ <gl-modal
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :visible="visible"
+ :title="$options.i18n.modalTitle(checkedCount)"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ <gl-sprintf :message="$options.i18n.modalBody(checkedCount)" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
index d2c96b1a201..d2c96b1a201 100644
--- a/app/assets/javascripts/artifacts/components/feedback_banner.vue
+++ b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
index ba4026190a2..a93964eef99 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
@@ -11,12 +11,15 @@ import {
GlFormCheckbox,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
+import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { removeArtifactFromStore } from '../graphql/cache_update';
import {
STATUS_BADGE_VARIANTS,
I18N_DOWNLOAD,
@@ -36,10 +39,13 @@ import {
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_ERROR,
+ I18N_BULK_DELETE_PARTIAL_ERROR,
I18N_BULK_DELETE_CONFIRMATION_TOAST,
} from '../constants';
import JobCheckbox from './job_checkbox.vue';
import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
+import BulkDeleteModal from './bulk_delete_modal.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
@@ -67,11 +73,12 @@ export default {
TimeAgo,
JobCheckbox,
ArtifactsBulkDelete,
+ BulkDeleteModal,
ArtifactsTableRowDetails,
FeedbackBanner,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectPath', 'canDestroyArtifacts'],
+ inject: ['projectId', 'projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
query: getJobArtifactsQuery,
@@ -106,6 +113,9 @@ export default {
expandedJobs: [],
selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
+ isBulkDeleteModalVisible: false,
+ jobArtifactsToDelete: [],
+ isBulkDeleting: false,
};
},
computed: {
@@ -144,6 +154,12 @@ export default {
canBulkDestroyArtifacts() {
return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
},
+ isDeletingArtifactsForJob() {
+ return this.jobArtifactsToDelete.length > 0;
+ },
+ artifactsToDelete() {
+ return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts;
+ },
},
methods: {
refetchArtifacts() {
@@ -191,12 +207,70 @@ export default {
this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
}
},
+ onConfirmBulkDelete(e) {
+ // don't close modal until deletion is complete
+ if (e) {
+ e.preventDefault();
+ }
+ this.isBulkDeleting = true;
+
+ this.$apollo
+ .mutate({
+ mutation: bulkDestroyJobArtifactsMutation,
+ variables: {
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
+ ids: this.artifactsToDelete,
+ },
+ update: (store, { data }) => {
+ const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
+ if (errors?.length) {
+ createAlert({
+ message: I18N_BULK_DELETE_PARTIAL_ERROR,
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
+ }
+ if (destroyedIds?.length) {
+ this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(destroyedCount));
+
+ // Remove deleted artifacts from the cache
+ destroyedIds.forEach((id) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ });
+ store.gc();
+
+ if (!this.isDeletingArtifactsForJob) {
+ this.clearSelectedArtifacts();
+ }
+ }
+ },
+ })
+ .catch((error) => {
+ this.onError(error);
+ })
+ .finally(() => {
+ this.isBulkDeleting = false;
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ });
+ },
+ onError(error) {
+ createAlert({
+ message: I18N_BULK_DELETE_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ handleBulkDeleteModalShow() {
+ this.isBulkDeleteModalVisible = true;
+ },
+ handleBulkDeleteModalHidden() {
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ },
clearSelectedArtifacts() {
this.selectedArtifacts = [];
},
- showDeletedToast(deletedCount) {
- this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount));
- },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -206,6 +280,13 @@ export default {
browseButtonDisabled(job) {
return !job.browseArtifactsPath;
},
+ deleteButtonDisabled(job) {
+ return !job.hasArtifacts || !this.canBulkDestroyArtifacts;
+ },
+ deleteArtifactsForJob(job) {
+ this.jobArtifactsToDelete = job.artifacts.nodes.map((node) => node.id);
+ this.handleBulkDeleteModalShow();
+ },
},
fields: [
{
@@ -257,11 +338,17 @@ export default {
<div>
<feedback-banner />
<artifacts-bulk-delete
- v-if="canBulkDestroyArtifacts && anyArtifactsSelected"
+ v-if="canBulkDestroyArtifacts"
:selected-artifacts="selectedArtifacts"
- :query-variables="queryVariables"
@clearSelectedArtifacts="clearSelectedArtifacts"
- @deleted="showDeletedToast"
+ @showBulkDeleteModal="handleBulkDeleteModalShow"
+ />
+ <bulk-delete-modal
+ :visible="isBulkDeleteModalVisible"
+ :artifacts-to-delete="artifactsToDelete"
+ :is-deleting="isBulkDeleting"
+ @primary="onConfirmBulkDelete"
+ @hidden="handleBulkDeleteModalHidden"
/>
<gl-table
:items="jobArtifacts"
@@ -382,10 +469,11 @@ export default {
<gl-button
v-if="canDestroyArtifacts"
icon="remove"
+ :disabled="deleteButtonDisabled(item)"
:title="$options.i18n.delete"
:aria-label="$options.i18n.delete"
data-testid="job-artifacts-delete-button"
- disabled
+ @click="deleteArtifactsForJob(item)"
/>
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
index ce49b3f8678..ce49b3f8678 100644
--- a/app/assets/javascripts/artifacts/components/job_checkbox.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js
index 4ac20d963d1..4ac20d963d1 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/ci/artifacts/constants.js
diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
index 9fa6114c7d4..9fa6114c7d4 100644
--- a/app/assets/javascripts/artifacts/graphql/cache_update.js
+++ b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
index 421b9258ca0..421b9258ca0 100644
--- a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
index 529224b47e6..529224b47e6 100644
--- a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
index 23da65ad0bb..23da65ad0bb 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
index 5737f9f8e8d..5737f9f8e8d 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js
index 6e795fd9bd7..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/ci/artifacts/index.js
diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js
index ebcf0af8d2a..ebcf0af8d2a 100644
--- a/app/assets/javascripts/artifacts/utils.js
+++ b/app/assets/javascripts/ci/artifacts/utils.js
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
index 7387a490177..09b02068388 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,16 +1,25 @@
<script>
-import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { debounce, uniq } from 'lodash';
+import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { __, s__, sprintf } from '~/locale';
import { convertEnvironmentScope } from '../utils';
+import { ENVIRONMENT_QUERY_LIMIT } from '../constants';
export default {
name: 'CiEnvironmentsDropdown',
components: {
+ GlCollapsibleListbox,
GlDropdownDivider,
GlDropdownItem,
- GlCollapsibleListbox,
+ GlSprintf,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
environments: {
type: Array,
required: true,
@@ -33,24 +42,52 @@ export default {
},
filteredEnvironments() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.environments.filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ });
+ },
+ isEnvScopeLimited() {
+ return this.glFeatures?.ciLimitEnvironmentScope;
+ },
+ searchedEnvironments() {
+ // If FF is enabled, search query will be fired so this component will already
+ // receive filtered environments during the refetch.
+ // If FF is disabled, search the existing list of environments in the frontend
+ let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments;
+
+ // If there is no search term, make sure to include *
+ if (this.isEnvScopeLimited && !this.searchTerm) {
+ filtered = uniq([...filtered, '*']);
+ }
- return this.environments
- .filter((environment) => {
- return environment.toLowerCase().includes(lowerCasedSearchTerm);
- })
- .map((environment) => ({
- value: environment,
- text: environment,
- }));
+ return filtered.sort().map((environment) => ({
+ value: environment,
+ text: environment,
+ }));
+ },
+ shouldShowSearchLoading() {
+ return this.areEnvironmentsLoading && this.isEnvScopeLimited;
},
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
},
+ shouldRenderDivider() {
+ return (
+ (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading
+ );
+ },
environmentScopeLabel() {
return convertEnvironmentScope(this.selectedEnvironmentScope);
},
},
methods: {
+ debouncedSearch: debounce(function debouncedSearch(searchTerm) {
+ const newSearchTerm = searchTerm.trim();
+ this.searchTerm = newSearchTerm;
+ if (this.isEnvScopeLimited) {
+ this.$emit('search-environment-scope', newSearchTerm);
+ }
+ }, 500),
selectEnvironment(selected) {
this.$emit('select-environment', selected);
this.selectedEnvironment = selected;
@@ -60,22 +97,46 @@ export default {
this.selectEnvironment(this.searchTerm);
},
},
+ ENVIRONMENT_QUERY_LIMIT,
+ i18n: {
+ maxEnvsNote: s__(
+ 'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.',
+ ),
+ },
};
</script>
<template>
<gl-collapsible-listbox
v-model="selectedEnvironment"
+ block
searchable
- :items="filteredEnvironments"
+ :items="searchedEnvironments"
+ :searching="shouldShowSearchLoading"
:toggle-text="environmentScopeLabel"
- @search="searchTerm = $event.trim()"
+ @search="debouncedSearch"
@select="selectEnvironment"
>
- <template v-if="shouldRenderCreateButton" #footer>
- <gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
- {{ composedCreateButtonLabel }}
- </gl-dropdown-item>
+ <template #footer>
+ <gl-dropdown-divider v-if="shouldRenderDivider" />
+ <div v-if="isEnvScopeLimited" data-testid="max-envs-notice">
+ <gl-dropdown-item class="gl-list-style-none" disabled>
+ <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm">
+ <template #limit>
+ {{ $options.ENVIRONMENT_QUERY_LIMIT }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </div>
+ <div v-if="shouldRenderCreateButton">
+ <!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 -->
+ <gl-dropdown-item
+ class="gl-list-style-none"
+ data-testid="create-wildcard-button"
+ @click="createEnvironmentScope"
+ >
+ {{ composedCreateButtonLabel }}
+ </gl-dropdown-item>
+ </div>
</template>
</gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 16034cce381..b3ecaceba69 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -74,6 +74,10 @@ export default {
'maskableRegex',
],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -142,7 +146,11 @@ export default {
isTipVisible() {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
},
- joinedEnvironments() {
+ environmentsList() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return this.environments;
+ }
+
return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
},
maskedFeedback() {
@@ -368,10 +376,12 @@ export default {
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
+ :are-environments-loading="areEnvironmentsLoading"
:selected-environment-scope="variable.environmentScope"
- :environments="joinedEnvironments"
+ :environments="environmentsList"
@select-environment="setEnvironmentScope"
@create-environment-scope="createEnvironmentScope"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
<gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly />
@@ -450,7 +460,7 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3">
+ <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3">
<div>
<p>
<gl-sprintf :message="$options.i18n.awsTipMessage">
@@ -505,7 +515,6 @@ export default {
ref="deleteCiVariable"
variant="danger"
category="secondary"
- data-qa-selector="ci_variable_delete_button"
@click="deleteVarAndClose"
>{{ __('Delete variable') }}</gl-button
>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index 257c3309e10..26e20c690bc 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
@@ -9,6 +9,10 @@ export default {
CiVariableModal,
},
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -100,6 +104,7 @@ export default {
/>
<ci-variable-modal
v-if="showModal"
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
:hide-environment-scope="hideEnvironmentScope"
@@ -110,6 +115,7 @@ export default {
@delete-variable="deleteVariable"
@hideModal="hideModal"
@update-variable="updateVariable"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index 9db9bea63b2..ee2c0a771cf 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
@@ -6,8 +6,10 @@ import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
+ ENVIRONMENT_QUERY_LIMIT,
SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
+ mapMutationActionToToast,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
@@ -162,6 +164,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.environmentQueryVariables,
};
},
update(data) {
@@ -173,10 +176,26 @@ export default {
},
},
computed: {
+ areEnvironmentsLoading() {
+ return this.$apollo.queries.environments.loading;
+ },
+ environmentQueryVariables() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return {
+ first: ENVIRONMENT_QUERY_LIMIT,
+ search: '',
+ };
+ }
+
+ return {};
+ },
isLoading() {
+ // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when
+ // environment query is loading and FF is enabled
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/396990
return (
(this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
- this.$apollo.queries.environments.loading ||
+ this.areEnvironmentsLoading ||
this.isLoadingMoreItems
);
},
@@ -228,6 +247,11 @@ export default {
updateVariable(variable) {
this.variableMutation(UPDATE_MUTATION_ACTION, variable);
},
+ async searchEnvironmentScope(searchTerm) {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ this.$apollo.queries.environments.refetch({ search: searchTerm });
+ }
+ },
async variableMutation(mutationAction, variable) {
try {
const currentMutation = this.mutationData[mutationAction];
@@ -245,11 +269,15 @@ export default {
if (data.ciVariableMutation?.errors?.length) {
const { errors } = data.ciVariableMutation;
createAlert({ message: errors[0] });
- } else if (this.refetchAfterMutation) {
- // The writing to cache for admin variable is not working
- // because there is no ID in the cache at the top level.
- // We therefore need to manually refetch.
- this.$apollo.queries.ciVariables.refetch();
+ } else {
+ this.$toast.show(mapMutationActionToToast[mutationAction](variable.key));
+
+ if (this.refetchAfterMutation) {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.ciVariables.refetch();
+ }
}
} catch (e) {
createAlert({ message: genericMutationErrorText });
@@ -264,6 +292,7 @@ export default {
<template>
<ci-variable-settings
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:entity="entity"
:environments="environments"
@@ -277,6 +306,7 @@ export default {
@handle-prev-page="handlePrevPage"
@handle-next-page="handleNextPage"
@sort-changed="handleSortChanged"
+ @search-environment-scope="searchEnvironmentScope"
@update-variable="updateVariable"
/>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index 5e367ff33b2..6f6c55e07c7 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
@@ -175,12 +175,7 @@ export default {
v-if="glFeatures.ciVariablesPages"
class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
>
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
<gl-button
v-gl-modal-directive="$options.modalId"
class="gl-mx-3"
@@ -317,12 +312,7 @@ export default {
@click="setSelectedVariable()"
>{{ __('Add variable') }}</gl-button
>
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
</div>
<div v-else class="gl-display-flex gl-justify-content-center gl-mt-6">
<gl-keyset-pagination
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index c77d8c67bc8..c8f67bd3436 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -1,6 +1,7 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+export const ENVIRONMENT_QUERY_LIMIT = 30;
export const SORT_DIRECTIONS = {
ASC: 'KEY_ASC',
@@ -97,6 +98,19 @@ export const ADD_MUTATION_ACTION = 'add';
export const UPDATE_MUTATION_ACTION = 'update';
export const DELETE_MUTATION_ACTION = 'delete';
+export const ADD_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been successfully added.'), { key });
+export const UPDATE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been updated.'), { key });
+export const DELETE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been deleted.'), { key });
+
+export const mapMutationActionToToast = {
+ [ADD_MUTATION_ACTION]: ADD_VARIABLE_TOAST,
+ [UPDATE_MUTATION_ACTION]: UPDATE_VARIABLE_TOAST,
+ [DELETE_MUTATION_ACTION]: DELETE_VARIABLE_TOAST,
+};
+
export const EXPANDED_VARIABLES_NOTE = __(
'%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.',
);
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
index 921e0ca25b9..26d1b6a3aaa 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
@@ -1,7 +1,7 @@
-query getProjectEnvironments($fullPath: ID!) {
+query getProjectEnvironments($fullPath: ID!, $first: Int, $search: String) {
project(fullPath: $fullPath) {
id
- environments {
+ environments(first: $first, search: $search) {
nodes {
id
name
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
index 4775836fcc6..3fe9103c2b3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
@@ -146,7 +146,7 @@ export default {
</gl-sprintf>
</gl-form-checkbox>
</gl-form-group>
- <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
+ <div class="gl-display-flex gl-py-5">
<gl-button
type="submit"
class="js-no-auto-disable gl-mr-3"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
new file mode 100644
index 00000000000..25bbd6b3180
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { get, toPath } from 'lodash';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlButton,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ formOptions() {
+ return [
+ {
+ key: 'artifacts.paths',
+ title: i18n.ARTIFACTS_AND_CACHE,
+ paths: this.job.artifacts.paths,
+ generateInputDataTestId: (index) => `artifacts-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-paths-button',
+ },
+ {
+ key: 'artifacts.exclude',
+ title: i18n.ARTIFACTS_EXCLUDE_PATHS,
+ paths: this.job.artifacts.exclude,
+ generateInputDataTestId: (index) => `artifacts-exclude-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-exclude-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-exclude-button',
+ },
+ {
+ key: 'cache.paths',
+ title: i18n.CACHE_PATHS,
+ paths: this.job.cache.paths,
+ generateInputDataTestId: (index) => `cache-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-cache-paths-button-${index}`,
+ addButtonDataTestId: 'add-cache-paths-button',
+ },
+ ];
+ },
+ },
+ methods: {
+ deleteStringArrayItem(path) {
+ const parentPath = toPath(path).slice(0, -1);
+ const array = get(this.job, parentPath);
+ if (array.length <= 1) {
+ return;
+ }
+ this.$emit('update-job', path);
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE">
+ <div v-for="entry in formOptions" :key="entry.key" class="form-group">
+ <div class="gl-display-flex">
+ <label class="gl-font-weight-bold gl-mb-3">{{ entry.title }}</label>
+ </div>
+ <div
+ v-for="(path, index) in entry.paths"
+ :key="index"
+ class="gl-display-flex gl-align-items-center gl-mb-3"
+ >
+ <div class="gl-flex-grow-1 gl-flex-basis-0 gl-mr-3">
+ <gl-form-input
+ class="gl-w-full!"
+ :value="path"
+ :data-testid="entry.generateInputDataTestId(index)"
+ @input="$emit('update-job', `${entry.key}[${index}]`, $event)"
+ />
+ </div>
+ <gl-button
+ category="tertiary"
+ icon="remove"
+ :data-testid="entry.generateDeleteButtonDataTestId(index)"
+ @click="deleteStringArrayItem(`${entry.key}[${index}]`)"
+ />
+ </div>
+ <gl-button
+ category="secondary"
+ variant="confirm"
+ :data-testid="entry.addButtonDataTestId"
+ @click="$emit('update-job', `${entry.key}[${entry.paths.length}]`, '')"
+ >{{ $options.i18n.ADD_PATH }}</gl-button
+ >
+ </div>
+ <gl-form-group :label="$options.i18n.CACHE_KEY">
+ <gl-form-input
+ :value="job.cache.key"
+ data-testid="cache-key-input"
+ @input="$emit('update-job', 'cache.key', $event)"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
index c2ae7d7be49..c23a0b866d3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -20,14 +20,20 @@ export default {
<template>
<gl-accordion-item :title="$options.i18n.IMAGE">
<div class="gl-display-flex">
- <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME">
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ :label="$options.i18n.IMAGE_NAME"
+ >
<gl-form-input
:value="job.image.name"
data-testid="image-name-input"
@input="$emit('update-job', 'image.name', $event)"
/>
</gl-form-group>
- <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT">
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ :label="$options.i18n.IMAGE_ENTRYPOINT"
+ >
<gl-form-input
:value="job.image.entrypoint.join(' ')"
data-testid="image-entrypoint-input"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
index a25b3ca09fd..b49355d539c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
@@ -7,7 +7,6 @@ import {
GlTokenSelector,
GlFormCombobox,
} from '@gitlab/ui';
-import { mapState } from 'vuex';
import { i18n } from '../constants';
export default {
@@ -37,9 +36,10 @@ export default {
type: Boolean,
required: true,
},
- },
- computed: {
- ...mapState(['availableStages']),
+ availableStages: {
+ type: Array,
+ required: true,
+ },
},
};
</script>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
new file mode 100644
index 00000000000..d068b370852
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants';
+
+export default {
+ i18n,
+ whenOptions: Object.values(JOB_RULES_WHEN),
+ unitOptions: Object.values(JOB_RULES_START_IN),
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ isStartValid: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ startInNumber: 1,
+ startInUnit: JOB_RULES_START_IN.second.value,
+ };
+ },
+ computed: {
+ isDelayed() {
+ return this.job.rules[0].when === JOB_RULES_WHEN.delayed.value;
+ },
+ },
+ methods: {
+ updateStartIn() {
+ const plural = this.startInNumber > 1 ? 's' : '';
+ this.$emit(
+ 'update-job',
+ 'rules[0].start_in',
+ `${this.startInNumber} ${this.startInUnit}${plural}`,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.RULES">
+ <div class="gl-display-flex">
+ <gl-form-group class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" :label="$options.i18n.WHEN">
+ <gl-form-select
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ :options="$options.whenOptions"
+ data-testid="rules-when-select"
+ :value="job.rules[0].when"
+ @input="$emit('update-job', 'rules[0].when', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ :invalid-feedback="$options.i18n.INVALID_START_IN"
+ :state="isStartValid"
+ >
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-input
+ v-model="startInNumber"
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ data-testid="rules-start-in-number-input"
+ type="number"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ number
+ @input="updateStartIn"
+ />
+ <gl-form-select
+ v-model="startInUnit"
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ data-testid="rules-start-in-unit-select"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ :options="$options.unitOptions"
+ @input="updateStartIn"
+ />
+ </div>
+ </gl-form-group>
+ </div>
+ <gl-form-group>
+ <gl-form-checkbox
+ :checked="job.rules[0].allow_failure"
+ data-testid="rules-allow-failure-checkbox"
+ @input="$emit('update-job', 'rules[0].allow_failure', $event)"
+ >
+ {{ $options.i18n.ALLOW_FAILURE }}
+ </gl-form-checkbox>
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
index 994a6e719fe..df3a2c64e25 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
@@ -2,6 +2,59 @@ import { __, s__ } from '~/locale';
export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+export const JOB_RULES_WHEN = {
+ onSuccess: {
+ value: 'on_success',
+ text: s__('JobAssistant|on_success'),
+ },
+ onFailure: {
+ value: 'on_failure',
+ text: s__('JobAssistant|on_failure'),
+ },
+ manual: {
+ value: 'manual',
+ text: s__('JobAssistant|manual'),
+ },
+ always: {
+ value: 'always',
+ text: s__('JobAssistant|always'),
+ },
+ delayed: {
+ value: 'delayed',
+ text: s__('JobAssistant|delayed'),
+ },
+ never: {
+ value: 'never',
+ text: s__('JobAssistant|never'),
+ },
+};
+
+export const JOB_RULES_START_IN = {
+ second: {
+ value: 'second',
+ text: s__('JobAssistant|second(s)'),
+ },
+ minute: {
+ value: 'minute',
+ text: s__('JobAssistant|minute(s)'),
+ },
+ day: {
+ value: 'day',
+ text: s__('JobAssistant|day(s)'),
+ },
+ week: {
+ value: 'week',
+ text: s__('JobAssistant|week(s)'),
+ },
+};
+
+export const SECONDS_MULTIPLE_MAP = {
+ second: 1,
+ minute: 60,
+ day: 3600 * 24,
+ week: 3600 * 24 * 7,
+};
+
export const JOB_TEMPLATE = {
name: '',
stage: '',
@@ -25,6 +78,13 @@ export const JOB_TEMPLATE = {
paths: [''],
key: '',
},
+ rules: [
+ {
+ allow_failure: false,
+ when: 'on_success',
+ start_in: '',
+ },
+ ],
};
export const i18n = {
@@ -38,4 +98,14 @@ export const i18n = {
IMAGE_NAME: s__('JobAssistant|Image name (optional)'),
IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'),
THIS_FIELD_IS_REQUIRED: __('This field is required'),
+ CACHE_PATHS: s__('JobAssistant|Cache paths (optional)'),
+ CACHE_KEY: s__('JobAssistant|Cache key (optional)'),
+ ARTIFACTS_EXCLUDE_PATHS: s__('JobAssistant|Artifacts exclude paths (optional)'),
+ ARTIFACTS_PATHS: s__('JobAssistant|Artifacts paths (optional)'),
+ ARTIFACTS_AND_CACHE: s__('JobAssistant|Artifacts and cache'),
+ ADD_PATH: s__('JobAssistant|Add path'),
+ RULES: s__('JobAssistant|Rules'),
+ WHEN: s__('JobAssistant|When'),
+ ALLOW_FAILURE: s__('JobAssistant|Allow failure'),
+ INVALID_START_IN: s__('JobAssistant|Error - Valid value is between 1 second and 1 week'),
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
index 9f68b97b329..8cde20bc22e 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -1,16 +1,16 @@
<script>
import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
-import { stringify } from 'yaml';
-import { mapMutations, mapState } from 'vuex';
-import { set, omit, trim } from 'lodash';
+import { stringify, parse } from 'yaml';
+import { get, omit, toPath } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
-import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types';
-import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
-import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
-import { removeEmptyObj, trimFields } from './utils';
+import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
+import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants';
+import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils';
import JobSetupItem from './accordion_items/job_setup_item.vue';
import ImageItem from './accordion_items/image_item.vue';
+import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue';
+import RulesItem from './accordion_items/rules_item.vue';
export default {
i18n,
@@ -20,6 +20,8 @@ export default {
GlButton,
JobSetupItem,
ImageItem,
+ ArtifactsAndCacheItem,
+ RulesItem,
},
props: {
isVisible: {
@@ -32,24 +34,38 @@ export default {
required: false,
default: 200,
},
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
isNameValid: true,
isScriptValid: true,
+ isStartValid: true,
job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
};
},
apollo: {
runners: {
- query: getAllRunners,
+ query: getRunnerTags,
update(data) {
return data?.runners?.nodes || [];
},
},
},
computed: {
- ...mapState(['currentCiFileContent']),
+ availableStages() {
+ if (this.ciConfigData?.mergedYaml) {
+ return parse(this.ciConfigData.mergedYaml).stages;
+ }
+ return [];
+ },
tagOptions() {
const options = [];
this.runners?.forEach((runner) => options.push(...runner.tagList));
@@ -63,25 +79,36 @@ export default {
drawerHeightOffset() {
return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
},
+ isJobValid() {
+ return this.isNameValid && this.isScriptValid && this.isStartValid;
+ },
+ },
+
+ watch: {
+ 'job.name': function jobNameWatch(name) {
+ this.isNameValid = validateEmptyValue(name);
+ },
+ 'job.script': function jobScriptWatch(script) {
+ this.isScriptValid = validateEmptyValue(script);
+ },
+ 'job.rules.0.start_in': function JobRulesStartInWatch(startIn) {
+ this.isStartValid = validateStartIn(this.job.rules[0].when, startIn);
+ },
},
methods: {
- ...mapMutations({
- updateCiConfig: UPDATE_CI_CONFIG,
- }),
closeDrawer() {
this.clearJob();
this.$emit('close-job-assistant-drawer');
},
addCiConfig() {
- this.isNameValid = this.validate(this.job.name);
- this.isScriptValid = this.validate(this.job.script);
+ this.validateJob();
- if (!this.isNameValid || !this.isScriptValid) {
+ if (!this.isJobValid) {
return;
}
const newJobString = this.generateYmlString();
- this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`);
+ this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`);
eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
this.closeDrawer();
@@ -89,27 +116,53 @@ export default {
generateYmlString() {
let job = JSON.parse(JSON.stringify(this.job));
const jobName = job.name;
- job = omit(job, ['name']);
+ job = this.removeUnnecessaryKeys(job);
job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules
const cleanedJob = trimFields(removeEmptyObj(job));
return stringify({ [jobName]: cleanedJob });
},
+ removeUnnecessaryKeys(job) {
+ const keys = ['name'];
+
+ // rules[0].allow_failure value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].allow_failure === false) {
+ keys.push('rules[0].allow_failure');
+ }
+ // rules[0].when value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].when === JOB_RULES_WHEN.onSuccess.value) {
+ keys.push('rules[0].when');
+ }
+ // rules[0].start_in value should not be passed down
+ // if rules[0].start_in doesn't equal 'delayed'
+ if (this.job.rules[0].when !== JOB_RULES_WHEN.delayed.value) {
+ keys.push('rules[0].start_in');
+ }
+ return omit(job, keys);
+ },
clearJob() {
this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE));
- this.isNameValid = true;
- this.isScriptValid = true;
+ this.$nextTick(() => {
+ this.isNameValid = true;
+ this.isScriptValid = true;
+ this.isStartValid = true;
+ });
},
updateJob(key, value) {
- set(this.job, key, value);
- if (key === 'name') {
- this.isNameValid = this.validate(this.job.name);
- }
- if (key === 'script') {
- this.isScriptValid = this.validate(this.job.script);
+ const path = toPath(key);
+ const targetObj = path.length === 1 ? this.job : get(this.job, path.slice(0, -1));
+ const lastKey = path[path.length - 1];
+ if (value !== undefined) {
+ this.$set(targetObj, lastKey, value);
+ } else {
+ this.$delete(targetObj, lastKey);
}
},
- validate(value) {
- return trim(value) !== '';
+ validateJob() {
+ this.isNameValid = validateEmptyValue(this.job.name);
+ this.isScriptValid = validateEmptyValue(this.job.script);
+ this.isStartValid = validateStartIn(this.job.rules[0].when, this.job.rules[0].start_in);
},
},
};
@@ -131,9 +184,12 @@ export default {
:job="job"
:is-name-valid="isNameValid"
:is-script-valid="isScriptValid"
+ :available-stages="availableStages"
@update-job="updateJob"
/>
<image-item :job="job" @update-job="updateJob" />
+ <artifacts-and-cache-item :job="job" @update-job="updateJob" />
+ <rules-item :job="job" :is-start-valid="isStartValid" @update-job="updateJob" />
</gl-accordion>
<template #footer>
<div class="gl-display-flex gl-justify-content-end">
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
index 83e7574c4de..a604d79259d 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
@@ -1,4 +1,8 @@
import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash';
+import {
+ JOB_RULES_WHEN,
+ SECONDS_MULTIPLE_MAP,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val);
const trimText = (val) => (isString(val) ? trim(val) : val);
@@ -20,3 +24,30 @@ export const trimFields = (data) => {
}
return trimText(data);
};
+
+export const validateEmptyValue = (value) => {
+ return trim(value) !== '';
+};
+
+export const validateStartIn = (when, startIn) => {
+ const hasNoValue = when !== JOB_RULES_WHEN.delayed.value;
+ if (hasNoValue) {
+ return true;
+ }
+
+ let [startInNumber, startInUnit] = startIn.split(' ');
+
+ startInNumber = Number(startInNumber);
+ if (!Number.isInteger(startInNumber)) {
+ return false;
+ }
+
+ const isPlural = startInUnit.slice(-1) === 's';
+ if (isPlural) {
+ startInUnit = startInUnit.slice(0, -1);
+ }
+
+ const multiple = SECONDS_MULTIPLE_MAP[startInUnit];
+
+ return startInNumber * multiple >= 1 && startInNumber * multiple <= SECONDS_MULTIPLE_MAP.week;
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
index dd25c4d433b..e775dc5147a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -86,25 +86,8 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-
export const COMMIT_SHA_POLL_INTERVAL = 1000;
-export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
-export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
-export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
-export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
export const I18N = {
title: s__('Pipelines|Get started with GitLab CI/CD'),
- runners: {
- title: s__('Pipelines|Runners are available to run your jobs now'),
- subtitle: s__(
- 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
- ),
- },
- noRunners: {
- title: s__('Pipelines|No runners detected'),
- subtitle: s__(
- 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
- ),
- cta: s__('Pipelines|Install GitLab Runner'),
- },
learnBasics: {
title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
subtitle: s__(
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
new file mode 100644
index 00000000000..aab30257d13
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerTags {
+ runners {
+ nodes {
+ id
+ tagList
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
index d65a7c321ce..09acd805410 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -12,7 +12,6 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
-import createStore from './store';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
@@ -112,11 +111,8 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
},
});
- const store = createStore();
-
return new Vue({
el,
- store,
apolloProvider,
provide: {
ciConfigPath,
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
index 7b3c4d6f74f..ff848a973e3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -1,12 +1,10 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { mapState, mapMutations } from 'vuex';
-import { parse } from 'yaml';
import { fetchPolicies } from '~/lib/graphql';
import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
-import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
@@ -46,6 +44,7 @@ export default {
data() {
return {
ciConfigData: {},
+ currentCiFileContent: '',
failureType: null,
failureReasons: [],
hasBranchLoaded: false,
@@ -95,7 +94,7 @@ export default {
const fileContent = rawBlob ?? '';
this.lastCommittedContent = fileContent;
- this.updateCiConfig(fileContent);
+ this.currentCiFileContent = fileContent;
// If rawBlob is defined and returns a string, it means that there is
// a CI config file with empty content. If `rawBlob` is not defined
@@ -156,10 +155,6 @@ export default {
this.isLintUnavailable = false;
}
}
-
- if (data?.ciConfig?.mergedYaml) {
- this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages);
- }
},
error() {
// We are not using `reportFailure` here because we don't
@@ -236,7 +231,6 @@ export default {
},
},
computed: {
- ...mapState(['currentCiFileContent']),
hasUnsavedChanges() {
return this.lastCommittedContent !== this.currentCiFileContent;
},
@@ -300,10 +294,6 @@ export default {
this.checkShouldSkipStartScreen();
},
methods: {
- ...mapMutations({
- updateCiConfig: UPDATE_CI_CONFIG,
- updateAvailableStages: UPDATE_AVAILABLE_STAGES,
- }),
checkShouldSkipStartScreen() {
const params = queryToObject(window.location.search);
this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
@@ -354,7 +344,7 @@ export default {
},
resetContent() {
this.showResetConfirmationModal = false;
- this.updateCiConfig(this.lastCommittedContent);
+ this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) {
@@ -371,6 +361,9 @@ export default {
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
+ updateCiConfig(ciFileContent) {
+ this.currentCiFileContent = ciFileContent;
+ },
updateCommitSha() {
this.isFetchingCommitSha = true;
this.$apollo.queries.commitSha.refetch();
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
index 59863edbe0b..1329042ee4c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -195,6 +195,8 @@ export default {
@close-drawer="closeDrawer"
/>
<job-assistant-drawer
+ :ci-config-data="ciConfigData"
+ :ci-file-content="ciFileContent"
:is-visible="showJobAssistantDrawer"
:z-index="jobAssistantIndex"
v-on="$listeners"
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js
deleted file mode 100644
index d7d5aed79e2..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export default () =>
- new Vuex.Store({
- mutations,
- state: state(),
- });
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
deleted file mode 100644
index 035d3c90c14..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG';
-export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES';
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
deleted file mode 100644
index 552c1df9a2c..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.UPDATE_CI_CONFIG](state, content) {
- state.currentCiFileContent = content;
- },
- [types.UPDATE_AVAILABLE_STAGES](state, stages) {
- state.availableStages = stages || [];
- },
-};
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js
deleted file mode 100644
index 34146cd54c4..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/state.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export default () => ({
- currentCiFileContent: '',
- availableStages: [],
-});
diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
index 648cd8b66b5..f93f5ad4f11 100644
--- a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
@@ -1,7 +1,7 @@
query ciConfigVariables($fullPath: ID!, $ref: String!) {
project(fullPath: $fullPath) {
id
- ciConfigVariables(sha: $ref) {
+ ciConfigVariables(ref: $ref) {
description
key
value
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
index 56461165588..92f461c72d7 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="last-pipeline-status">
<ci-badge-link
v-if="hasPipeline"
:status="lastPipelineStatus"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
index 48d59bf6e7c..9c0fc148dac 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="next-run-cell">
<time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" />
<span v-else data-testid="pipeline-schedule-inactive">
{{ s__('PipelineSchedules|Inactive') }}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index 0b95e2037e8..b97914f8c26 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -68,7 +68,12 @@ export default {
</script>
<template>
- <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md">
+ <gl-table-lite
+ :fields="$options.fields"
+ :items="schedules"
+ :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }"
+ stacked="md"
+ >
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
index 79600012838..43d0dae6e78 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
@@ -6,7 +6,7 @@ import { s__ } from '~/locale';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
-import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM } from '../constants';
+import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
@@ -34,21 +34,21 @@ export default {
},
methods: {
onSaved(runner) {
- const registerUrl = setUrlParams(
- { [PARAM_KEY_PLATFORM]: this.platform },
- runner.registerAdminUrl,
- );
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
saveAlertToLocalStorage({
message: s__('Runners|Runner created.'),
variant: VARIANT_SUCCESS,
});
- redirectTo(registerUrl);
+ redirectTo(ephemeralRegisterUrl);
},
onError(error) {
createAlert({ message: error.message });
},
},
modalId: 'runners-legacy-registration-instructions-modal',
+ INSTANCE_TYPE,
};
</script>
@@ -84,6 +84,6 @@ export default {
<hr aria-hidden="true" />
- <runner-create-form @saved="onSaved" @error="onError" />
+ <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index 97dfbe1a051..24c1b4f5c3b 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -1,6 +1,8 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
@@ -14,6 +16,7 @@ import {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
} from '../../constants';
import RunnerSummaryField from './runner_summary_field.vue';
@@ -28,6 +31,7 @@ export default {
RunnerTypeBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
+ UserAvatarLink,
TooltipOnTruncate,
},
directives: {
@@ -43,6 +47,16 @@ export default {
jobCount() {
return formatJobCount(this.runner.jobCount);
},
+ createdBy() {
+ return this.runner?.createdBy;
+ },
+ createdByImgAlt() {
+ const name = this.createdBy?.name;
+ if (name) {
+ return sprintf(__("%{name}'s avatar"), { name });
+ }
+ return null;
+ },
},
i18n: {
I18N_NO_DESCRIPTION,
@@ -50,6 +64,7 @@ export default {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
},
};
</script>
@@ -106,11 +121,30 @@ export default {
</runner-summary-field>
<runner-summary-field icon="calendar">
- <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
- <template #timeAgo>
- <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
- </template>
- </gl-sprintf>
+ <template v-if="createdBy">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ <template #avatar>
+ <user-avatar-link
+ :link-href="createdBy.webUrl"
+ :img-src="createdBy.avatarUrl"
+ img-css-classes="gl-vertical-align-top"
+ :img-size="16"
+ :img-alt="createdByImgAlt"
+ :tooltip-text="createdBy.username"
+ />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
</runner-summary-field>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
index 1bbbd55089a..20681873436 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-4">
<gl-icon v-if="icon" :name="icon" />
<!-- display tooltip as a label for screen readers -->
<span class="gl-sr-only">{{ tooltip }}</span>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
index 2f3c172666d..69021dde0e9 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -70,7 +70,7 @@ export default {
captureException({ error, component: this.$options.name });
},
pollInterval() {
- if (this.runner?.status === STATUS_ONLINE) {
+ if (this.isRunnerOnline) {
// stop polling
return 0;
}
@@ -97,9 +97,6 @@ export default {
}
return s__('Runners|Register runner');
},
- status() {
- return this.runner?.status;
- },
tokenMessage() {
if (this.token) {
return s__(
@@ -116,22 +113,40 @@ export default {
registerCommand() {
return registerCommand({
platform: this.platform,
- registrationToken: this.token,
- description: this.description,
+ token: this.token,
});
},
runCommand() {
return runCommand({ platform: this.platform });
},
+ isRunnerOnline() {
+ return this.runner?.status === STATUS_ONLINE;
+ },
+ },
+ created() {
+ window.addEventListener('beforeunload', this.onBeforeunload);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.onBeforeunload);
},
methods: {
toggleDrawer() {
this.$emit('toggleDrawer');
},
+ onBeforeunload(event) {
+ if (this.isRunnerOnline) {
+ return undefined;
+ }
+
+ const str = s__('Runners|You may lose access to the runner token if you leave this page.');
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = str; // Chrome requires returnValue to be set
+ return str;
+ },
},
EXECUTORS_HELP_URL,
SERVICE_COMMANDS_HELP_URL,
- STATUS_ONLINE,
I18N_REGISTRATION_SUCCESS,
};
</script>
@@ -226,7 +241,7 @@ export default {
</gl-sprintf>
</p>
</section>
- <section v-if="status == $options.STATUS_ONLINE">
+ <section v-if="isRunnerOnline">
<h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2>
<p class="gl-pl-6">
diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js
index 94d75bc4562..c8a75506c9c 100644
--- a/app/assets/javascripts/ci/runner/components/registration/utils.js
+++ b/app/assets/javascripts/ci/runner/components/registration/utils.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import {
DEFAULT_PLATFORM,
LINUX_PLATFORM,
@@ -28,20 +27,6 @@ const OS = {
},
};
-const escapedParam = (param, shell = 'bash') => {
- let escaped;
- if (shell === 'bash') {
- // replace single-quotes by the sequence '\''
- escaped = param.replaceAll("'", "'\\''");
- } else if (shell === 'powershell') {
- // replace single-quotes by the sequence ''
- // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.3
- escaped = param.replaceAll("'", "''");
- }
- // surround with single quotes.
- return `'${escaped}'`;
-};
-
export const commandPrompt = ({ platform }) => {
return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt;
};
@@ -50,32 +35,19 @@ export const executable = ({ platform }) => {
return (OS[platform] || OS[DEFAULT_PLATFORM]).executable;
};
-const shell = ({ platform }) => {
- return (OS[platform] || OS[DEFAULT_PLATFORM]).shell;
-};
-
-export const registerCommand = ({
- platform,
- url = gon.gitlab_url,
- registrationToken,
- description,
-}) => {
- const lines = [`${executable({ platform })} register`];
+export const registerCommand = ({ platform, url = gon.gitlab_url, token }) => {
+ const lines = [`${executable({ platform })} register`]; // eslint-disable-line @gitlab/require-i18n-strings
if (url) {
lines.push(` --url ${url}`);
}
- if (registrationToken) {
- lines.push(` --registration-token ${registrationToken}`);
- }
- if (description) {
- const escapedDescription = escapedParam(description, shell({ platform }));
- lines.push(` --description ${escapedDescription}`);
+ if (token) {
+ lines.push(` --token ${token}`);
}
return lines;
};
export const runCommand = ({ platform }) => {
- return `${executable({ platform })} run`;
+ return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings
};
const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => {
diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
index 5d2a3c53842..d3e02f5cd6e 100644
--- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -4,7 +4,7 @@ import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql';
import { modelToUpdateMutationVariables } from 'ee_else_ce/ci/runner/runner_update_form_utils';
import { captureException } from '../sentry_utils';
-import { DEFAULT_ACCESS_LEVEL } from '../constants';
+import { RUNNER_TYPES, DEFAULT_ACCESS_LEVEL, GROUP_TYPE, INSTANCE_TYPE } from '../constants';
export default {
name: 'RunnerCreateForm',
@@ -13,6 +13,18 @@ export default {
GlButton,
RunnerFormFields,
},
+ props: {
+ runnerType: {
+ type: String,
+ required: true,
+ validator: (t) => RUNNER_TYPES.includes(t),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
data() {
return {
saving: false,
@@ -27,6 +39,23 @@ export default {
},
};
},
+ computed: {
+ mutationInput() {
+ const { input } = modelToUpdateMutationVariables(this.runner);
+
+ if (this.runnerType === GROUP_TYPE) {
+ return {
+ ...input,
+ runnerType: GROUP_TYPE,
+ groupId: this.groupId,
+ };
+ }
+ return {
+ ...input,
+ runnerType: INSTANCE_TYPE,
+ };
+ },
+ },
methods: {
async onSubmit() {
this.saving = true;
@@ -37,7 +66,9 @@ export default {
},
} = await this.$apollo.mutate({
mutation: runnerCreateMutation,
- variables: modelToUpdateMutationVariables(this.runner),
+ variables: {
+ input: this.mutationInput,
+ },
});
if (errors?.length) {
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 6237dcd0c03..1cae9df713b 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -93,6 +93,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
+export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}');
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
export const I18N_ADMIN = s__('Runners|Administrator');
@@ -141,6 +142,7 @@ export const PARAM_KEY_PLATFORM = 'platform';
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
+export const RUNNER_TYPES = [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE];
// CiRunnerStatus
diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index 6f72509f599..0a449ef0435 100644
--- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
fragment ListItemShared on CiRunner {
id
description
@@ -10,6 +12,9 @@ fragment ListItemShared on CiRunner {
jobCount
tagList
createdAt
+ createdBy {
+ ...User
+ }
contactedAt
status(legacyMode: null)
jobExecutionStatus
diff --git a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
index d14a594e378..07236808dca 100644
--- a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
@@ -2,7 +2,7 @@ mutation runnerCreate($input: RunnerCreateInput!) {
runnerCreate(input: $input) {
runner {
id
- registerAdminUrl
+ ephemeralRegisterUrl
}
errors
}
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
new file mode 100644
index 00000000000..35c75a917c7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'GroupNewRunnerApp',
+ components: {
+ GlLink,
+ GlSprintf,
+ RunnerInstructionsModal,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ legacyRegistrationToken: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ };
+ },
+ methods: {
+ onSaved(runner) {
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(ephemeralRegisterUrl);
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ modalId: 'runners-legacy-registration-instructions-modal',
+ GROUP_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Create a group runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{
+ content
+ }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="legacyRegistrationToken"
+ />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
+
+ <hr aria-hidden="true" />
+
+ <runner-create-form
+ :runner-type="$options.GROUP_TYPE"
+ :group-id="groupId"
+ @saved="onSaved"
+ @error="onError"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/index.js b/app/assets/javascripts/ci/runner/group_new_runner/index.js
new file mode 100644
index 00000000000..b314c3aa1e7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import GroupNewRunnerApp from './group_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupNewRunner = (selector = '#js-group-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { legacyRegistrationToken, groupId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupNewRunnerApp, {
+ props: {
+ groupId,
+ legacyRegistrationToken,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue
new file mode 100644
index 00000000000..533d31b70a3
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { getParameterByName, updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM } from '../constants';
+import RegistrationInstructions from '../components/registration/registration_instructions.vue';
+import PlatformsDrawer from '../components/registration/platforms_drawer.vue';
+
+export default {
+ name: 'GroupRegisterRunnerApp',
+ components: {
+ GlButton,
+ RegistrationInstructions,
+ PlatformsDrawer,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ runnersPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: getParameterByName(PARAM_KEY_PLATFORM) || DEFAULT_PLATFORM,
+ isDrawerOpen: false,
+ };
+ },
+ watch: {
+ platform(platform) {
+ updateHistory({
+ url: mergeUrlParams({ [PARAM_KEY_PLATFORM]: platform }, window.location.href),
+ });
+ },
+ },
+ methods: {
+ onSelectPlatform(platform) {
+ this.platform = platform;
+ },
+ onToggleDrawer(val = !this.isDrawerOpen) {
+ this.isDrawerOpen = val;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <registration-instructions
+ :runner-id="runnerId"
+ :platform="platform"
+ @toggleDrawer="onToggleDrawer"
+ >
+ <template #runner-list-name>{{ s__('Runners|Group area › Runners') }}</template>
+ </registration-instructions>
+
+ <platforms-drawer
+ :platform="platform"
+ :open="isDrawerOpen"
+ @selectPlatform="onSelectPlatform"
+ @close="onToggleDrawer(false)"
+ />
+
+ <gl-button :href="runnersPath" variant="confirm">{{
+ s__('Runners|Go to runners page')
+ }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/group_register_runner/index.js b/app/assets/javascripts/ci/runner/group_register_runner/index.js
new file mode 100644
index 00000000000..a00db8853a2
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_register_runner/index.js
@@ -0,0 +1,36 @@
+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 GroupRegisterRunnerApp from './group_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupRegisterRunner = (selector = '#js-group-register-runner') => {
+ showAlertFromLocalStorage();
+
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId, runnersPath } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 294d06a66e7..f8386214698 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
@@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlButton,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -58,6 +59,11 @@ export default {
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
+ newRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
registrationToken: {
type: String,
required: false,
@@ -150,6 +156,10 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow_for_namespace feature flag
+ return this.glFeatures.createRunnerWorkflowForNamespace;
+ },
},
watch: {
search: {
@@ -219,8 +229,13 @@ export default {
nav-class="gl-border-none!"
/>
+ <template v-if="shouldShowCreateRunnerWorkflow">
+ <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm">
+ {{ s__('Runners|New group runner') }}
+ </gl-button>
+ </template>
<registration-dropdown
- v-if="registrationToken"
+ v-else-if="registrationToken"
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 46514d5afe8..4fcf484317d 100644
--- a/app/assets/javascripts/ci/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
@@ -18,6 +18,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
const {
registrationToken,
runnerInstallHelpPage,
+ newRunnerPath,
groupId,
groupFullPath,
onlineContactTimeoutSecs,
@@ -49,6 +50,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: {
registrationToken,
groupFullPath,
+ newRunnerPath,
},
});
},
diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
index ca65665b9ed..24a776e1a29 100644
--- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
+++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
@@ -164,7 +164,7 @@ export default {
:href="$options.emptyHelpLink"
:title="$options.i18n.emptyTooltip"
:aria-label="$options.i18n.emptyTooltip"
- ><gl-icon name="question" :size="14"
+ ><gl-icon name="question-o" :size="14"
/></gl-link>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index e0e3b961c51..d7e98638a11 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -8,9 +8,13 @@ import {
GlTooltipDirective,
GlPopover,
} from '@gitlab/ui';
+import semverLt from 'semver/functions/lt';
+import semverInc from 'semver/functions/inc';
+import semverPrerelease from 'semver/functions/prerelease';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
import DeleteAgentButton from './delete_agent_button.vue';
@@ -81,6 +85,11 @@ export default {
tdClass,
},
{
+ key: 'agentID',
+ label: this.$options.i18n.agentIdLabel,
+ tdClass,
+ },
+ {
key: 'configuration',
label: this.$options.i18n.configurationLabel,
tdClass,
@@ -116,6 +125,9 @@ export default {
getPopoverTestId(item) {
return `popover-${item.name}`;
},
+ getAgentId(item) {
+ return getIdFromGraphQLId(item.id);
+ },
getAgentConfigPath,
getAgentVersions(agent) {
const agentConnections = agent.connections?.nodes || [];
@@ -134,18 +146,26 @@ export default {
isVersionMismatch(agent) {
return agent.versions.length > 1;
},
+ // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version
+ // using the following heuristics:
+ // - KAS Version is used as *server* version if available, otherwise the GitLab version is used.
+ // - returns `outdated` if the agent has a different major version than the server
+ // - returns `outdated` if the agents minor version is at least two proper versions older than the server
+ // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version
+ //
+ // Note that it does NOT support if the agent is newer than the server version.
isVersionOutdated(agent) {
if (!agent.versions.length) return false;
- const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
- const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.');
-
- const majorVersionMismatch = agentMajorVersion !== serverMajorVersion;
+ const agentVersion = this.getAgentVersionString(agent);
+ let allowableAgentVersion = semverInc(agentVersion, 'minor');
- // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
- const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1;
+ const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion));
+ if (isServerPrerelease) {
+ allowableAgentVersion = semverInc(allowableAgentVersion, 'minor');
+ }
- return majorVersionMismatch || minorVersionMismatch;
+ return semverLt(allowableAgentVersion, this.serverVersion);
},
getVersionPopoverTitle(agent) {
@@ -265,6 +285,12 @@ export default {
</gl-popover>
</template>
+ <template #cell(agentID)="{ item }">
+ <span data-testid="cluster-agent-id">
+ {{ getAgentId(item) }}
+ </span>
+ </template>
+
<template #cell(configuration)="{ item }">
<span data-testid="cluster-agent-configuration-link">
<gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
@@ -279,7 +305,7 @@ export default {
:title="$options.i18n.defaultConfigTooltip"
:aria-label="$options.i18n.defaultConfigTooltip"
class="gl-vertical-align-middle"
- ><gl-icon name="question" :size="14" /></gl-link
+ ><gl-icon name="question-o" :size="14" /></gl-link
></span>
</span>
</template>
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue
index e4b481f0908..9e0d2cc73ec 100644
--- a/app/assets/javascripts/saved_replies/components/app.vue
+++ b/app/assets/javascripts/comment_templates/components/app.vue
@@ -6,12 +6,12 @@ export default {};
<div class="row gl-mt-5">
<div class="col-lg-4">
<h4 class="gl-mt-0">
- {{ __('Saved Replies') }}
+ {{ __('Comment templates') }}
</h4>
<p>
{{
__(
- 'Saved replies can be used when creating comments inside issues, merge requests, and epics.',
+ 'Comment templates can be used when creating comments inside issues, merge requests, and epics.',
)
}}
</p>
diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
index efec9b96764..47efccc3d0c 100644
--- a/app/assets/javascripts/saved_replies/components/form.vue
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -38,7 +38,7 @@ export default {
errors: [],
saving: false,
showValidation: false,
- updateSavedReply: {
+ updateCommentTemplate: {
name: this.name,
content: this.content,
},
@@ -46,12 +46,12 @@ export default {
},
computed: {
isNameValid() {
- if (this.showValidation) return Boolean(this.updateSavedReply.name);
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.name);
return true;
},
isContentValid() {
- if (this.showValidation) return Boolean(this.updateSavedReply.content);
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.content);
return true;
},
@@ -73,15 +73,15 @@ export default {
mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation,
variables: {
id: this.id,
- name: this.updateSavedReply.name,
- content: this.updateSavedReply.content,
+ name: this.updateCommentTemplate.name,
+ content: this.updateCommentTemplate.content,
},
update: (store, { data: { savedReplyMutation } }) => {
if (savedReplyMutation.errors.length) {
this.errors = savedReplyMutation.errors.map((e) => e);
} else {
this.$emit('saved');
- this.updateSavedReply = { name: '', content: '' };
+ this.updateCommentTemplate = { name: '', content: '' };
this.showValidation = false;
}
},
@@ -112,8 +112,8 @@ export default {
<template>
<gl-form
- class="new-note common-note-form"
- data-testid="saved-reply-form"
+ class="new-note common-note-form gl-mb-6"
+ data-testid="comment-template-form"
@submit.prevent="onSubmit"
>
<gl-alert
@@ -128,26 +128,26 @@ export default {
<gl-form-group
:label="__('Name')"
:state="isNameValid"
- :invalid-feedback="__('Please enter a name for the saved reply.')"
- data-testid="saved-reply-name-form-group"
+ :invalid-feedback="__('Please enter a name for the comment template.')"
+ data-testid="comment-template-name-form-group"
>
<gl-form-input
- v-model="updateSavedReply.name"
- :placeholder="__('Enter a name for your saved reply')"
- data-testid="saved-reply-name-input"
+ v-model="updateCommentTemplate.name"
+ :placeholder="__('Enter a name for your comment template')"
+ data-testid="comment-template-name-input"
/>
</gl-form-group>
<gl-form-group
:label="__('Content')"
:state="isContentValid"
- :invalid-feedback="__('Please enter the saved reply content.')"
- data-testid="saved-reply-content-form-group"
+ :invalid-feedback="__('Please enter the comment template content.')"
+ data-testid="comment-template-content-form-group"
>
<markdown-field
:enable-preview="false"
:is-submitting="saving"
:add-spacing-classes="false"
- :textarea-value="updateSavedReply.content"
+ :textarea-value="updateCommentTemplate.content"
:markdown-docs-path="$options.markdownDocsPath"
:restricted-tool-bar-items="$options.restrictedToolbarItems"
:force-autosize="false"
@@ -155,13 +155,13 @@ export default {
>
<template #textarea>
<textarea
- v-model="updateSavedReply.content"
+ v-model="updateCommentTemplate.content"
dir="auto"
class="note-textarea js-gfm-input js-autosize markdown-area"
data-supports-quick-actions="false"
:aria-label="__('Content')"
- :placeholder="__('Write saved reply content here…')"
- data-testid="saved-reply-content-input"
+ :placeholder="__('Write comment template content here…')"
+ data-testid="comment-template-content-input"
@keydown.meta.enter="onSubmit"
@keydown.ctrl.enter="onSubmit"
></textarea>
@@ -173,7 +173,7 @@ export default {
class="gl-mr-3 js-no-auto-disable"
type="submit"
:loading="saving"
- data-testid="saved-reply-form-submit-btn"
+ data-testid="comment-template-form-submit-btn"
>
{{ __('Save') }}
</gl-button>
diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
index dbe326d429a..52bebfd050c 100644
--- a/app/assets/javascripts/saved_replies/components/list.vue
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -44,16 +44,16 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-border-t gl-pt-4">
<gl-loading-icon v-if="loading" size="lg" />
<template v-else>
<h5 class="gl-font-lg" data-testid="title">
- <gl-sprintf :message="__('My saved replies (%{count})')">
+ <gl-sprintf :message="__('My comment templates (%{count})')">
<template #count>{{ count }}</template>
</gl-sprintf>
</h5>
<ul class="gl-list-style-none gl-p-0 gl-m-0">
- <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" />
+ <list-item v-for="template in savedReplies" :key="template.id" :template="template" />
</ul>
<gl-keyset-pagination
v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue
new file mode 100644
index 00000000000..d763700db42
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/list_item.vue
@@ -0,0 +1,116 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ template: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleting: false,
+ modalId: uniqueId('delete-comment-template-'),
+ toggleId: uniqueId('actions-toggle-'),
+ };
+ },
+ computed: {
+ id() {
+ return getIdFromGraphQLId(this.template.id);
+ },
+ dropdownItems() {
+ return [
+ {
+ text: __('Edit'),
+ action: () => this.$router.push({ name: 'edit', params: { id: this.id } }),
+ extraAttrs: {
+ 'data-testid': 'comment-template-edit-btn',
+ },
+ },
+ {
+ text: __('Delete'),
+ action: () => this.$refs['delete-modal'].show(),
+ extraAttrs: {
+ 'data-testid': 'comment-template-delete-btn',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
+ },
+ methods: {
+ onDelete() {
+ this.isDeleting = true;
+
+ this.$apollo.mutate({
+ mutation: deleteSavedReplyMutation,
+ variables: {
+ id: this.template.id,
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(this.template);
+ cache.evict({ id: cacheId });
+ },
+ });
+ },
+ },
+ actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
+ actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
+};
+</script>
+
+<template>
+ <li class="gl-pt-4 gl-pb-5 gl-border-b">
+ <div class="gl-display-flex gl-align-items-center">
+ <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6>
+ <div class="gl-ml-auto">
+ <gl-disclosure-dropdown
+ :items="dropdownItems"
+ :toggle-id="toggleId"
+ icon="ellipsis_v"
+ no-caret
+ text-sr-only
+ placement="right"
+ :toggle-text="__('Comment template actions')"
+ :loading="isDeleting"
+ category="tertiary"
+ />
+ <gl-tooltip :target="toggleId">
+ {{ __('Comment template actions') }}
+ </gl-tooltip>
+ </div>
+ </div>
+ <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div>
+ <gl-modal
+ ref="delete-modal"
+ :title="__('Delete comment template')"
+ :action-primary="$options.actionPrimary"
+ :action-secondary="$options.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="onDelete"
+ >
+ <gl-sprintf
+ :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
+ >
+ <template #name
+ ><strong>{{ template.name }}</strong></template
+ >
+ </gl-sprintf>
+ </gl-modal>
+ </li>
+</template>
diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/comment_templates/index.js
index 5022ff62b10..8cd763e7a9e 100644
--- a/app/assets/javascripts/saved_replies/index.js
+++ b/app/assets/javascripts/comment_templates/index.js
@@ -5,11 +5,11 @@ import createDefaultClient from '~/lib/graphql';
import routes from './routes';
import App from './components/app.vue';
-export const initSavedReplies = () => {
+export const initCommentTemplates = () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
- const el = document.getElementById('js-saved-replies-root');
+ const el = document.getElementById('js-comment-templates-root');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/saved_replies/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue
index 94215389844..343efdccefa 100644
--- a/app/assets/javascripts/saved_replies/pages/edit.vue
+++ b/app/assets/javascripts/comment_templates/pages/edit.vue
@@ -32,7 +32,7 @@ export default {
},
}) {
if (!savedReply) {
- createAlert({ message: __('Unable to find saved reply') });
+ createAlert({ message: __('Unable to find comment template') });
this.redirectToRoot();
}
},
@@ -54,7 +54,7 @@ export default {
<template>
<div>
<h5 class="gl-mt-0 gl-font-lg">
- {{ __('Edit saved reply') }}
+ {{ __('Edit comment template') }}
</h5>
<gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" />
<create-form
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
index 3e96fc0714e..72a94dafc58 100644
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -53,7 +53,7 @@ export default {
<template>
<div>
<h5 class="gl-mt-0 gl-font-lg">
- {{ __('Add new saved reply') }}
+ {{ __('Add new comment template') }}
</h5>
<create-form @saved="refetchSavedReplies" />
<list
diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
index c4e632d0f16..c4e632d0f16 100644
--- a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
index 76571ba628c..76571ba628c 100644
--- a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
index 66f5f43af49..66f5f43af49 100644
--- a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql
+++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql
index d8e76b5e2a8..d8e76b5e2a8 100644
--- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
+++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
index 14a47d7bc9c..14a47d7bc9c 100644
--- a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/comment_templates/routes.js
index 7687c6f335a..7687c6f335a 100644
--- a/app/assets/javascripts/saved_replies/routes.js
+++ b/app/assets/javascripts/comment_templates/routes.js
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index e5e23f2fb5e..17c9f55a8a0 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -1,10 +1,6 @@
import $ from 'jquery';
// bootstrap jQuery plugins
-import 'bootstrap/js/dist/alert';
-import 'bootstrap/js/dist/button';
-import 'bootstrap/js/dist/collapse';
-import 'bootstrap/js/dist/modal';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/tab';
diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js
index defc2cbe276..f43a2d5d8ff 100644
--- a/app/assets/javascripts/constants.js
+++ b/app/assets/javascripts/constants.js
@@ -1,6 +1,5 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
export const getModifierKey = (removeSuffix = false) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
const winKey = `Ctrl${removeSuffix ? '' : '+'}`;
return window.gl?.client?.isMac ? '⌘' : winKey;
};
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 06b80a65528..cef446c4cf8 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -35,9 +35,6 @@ export default {
);
},
},
- toggleLinkCommandParams: {
- href: '',
- },
};
</script>
<template>
@@ -122,8 +119,7 @@ export default {
data-testid="link"
content-type="link"
icon-name="link"
- editor-command="toggleLink"
- :editor-command-params="$options.toggleLinkCommandParams"
+ editor-command="editLink"
category="tertiary"
size="medium"
:label="__('Insert link')"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index a4713eb3275..a3065be3772 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -8,6 +8,7 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
+import { getMarkType, getMarkRange } from '@tiptap/core';
import Link from '../../extensions/link';
import EditorStateObserver from '../editor_state_observer.vue';
import BubbleMenu from './bubble_menu.vue';
@@ -31,12 +32,36 @@ export default {
return {
linkHref: undefined,
linkCanonicalSrc: undefined,
- linkTitle: undefined,
+ linkText: undefined,
isEditing: false,
};
},
methods: {
+ linkIsEmpty() {
+ return (
+ !this.linkCanonicalSrc &&
+ !this.linkHref &&
+ (!this.linkText || this.linkText === this.linkTextInDoc())
+ );
+ },
+
+ linkTextInDoc() {
+ const { state } = this.tiptapEditor;
+ const type = getMarkType(Link.name, state.schema);
+ let { selection: range } = state;
+ if (range.from === range.to) {
+ range =
+ getMarkRange(state.selection.$from, type) ||
+ getMarkRange(state.selection.$to, type) ||
+ {};
+ }
+
+ if (!range.from || !range.to) return '';
+
+ return state.doc.textBetween(range.from, range.to, ' ');
+ },
+
shouldShow() {
return this.tiptapEditor.isActive(Link.name);
},
@@ -52,31 +77,51 @@ export default {
this.isEditing = false;
this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc);
-
- if (!this.linkCanonicalSrc && !this.linkHref) {
- this.removeLink();
- }
},
cancelEditingLink() {
this.endEditingLink();
- this.updateLinkToState();
+
+ if (this.linkIsEmpty()) {
+ this.removeLink();
+ } else {
+ this.updateLinkToState();
+ }
},
async saveEditedLink() {
- if (!this.linkCanonicalSrc) {
+ const chain = this.tiptapEditor.chain().focus();
+
+ const attrs = {
+ href: this.linkCanonicalSrc,
+ canonicalSrc: this.linkCanonicalSrc,
+ };
+
+ // if nothing was entered by the user and the link is empty, remove it
+ // since we don't want to insert an empty link
+ if (this.linkIsEmpty()) {
this.removeLink();
- } else {
- this.tiptapEditor
- .chain()
- .focus()
+ return;
+ }
+
+ if (!this.linkText) {
+ this.linkText = this.linkCanonicalSrc;
+ }
+
+ // if link text was updated, insert a new link in the doc with the new text
+ if (this.linkTextInDoc() !== this.linkText) {
+ chain
.extendMarkRange(Link.name)
- .updateAttributes(Link.name, {
- href: this.linkCanonicalSrc,
- canonicalSrc: this.linkCanonicalSrc,
- title: this.linkTitle,
+ .setMeta('preventAutolink', true)
+ .insertContent({
+ marks: [{ type: Link.name, attrs }],
+ type: 'text',
+ text: this.linkText,
})
.run();
+ } else {
+ // if link text was not updated, just update the attributes
+ chain.updateAttributes(Link.name, attrs).run();
}
this.endEditingLink();
@@ -84,22 +129,27 @@ export default {
updateLinkToState() {
const editor = this.tiptapEditor;
-
- const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
+ const { href, canonicalSrc } = editor.getAttributes(Link.name);
+ const text = this.linkTextInDoc();
if (
canonicalSrc === this.linkCanonicalSrc &&
href === this.linkHref &&
- title === this.linkTitle
+ text === this.linkText
) {
return;
}
- this.linkTitle = title;
+ this.linkText = text;
this.linkHref = href;
this.linkCanonicalSrc = canonicalSrc || href;
+ },
- this.isEditing = !this.linkCanonicalSrc;
+ onTransaction({ transaction }) {
+ this.linkText = this.linkTextInDoc();
+ if (transaction.getMeta('creatingLink')) {
+ this.isEditing = true;
+ }
},
copyLinkHref() {
@@ -107,31 +157,49 @@ export default {
},
removeLink() {
- this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run();
+ const chain = this.tiptapEditor.chain().focus();
+ if (this.linkTextInDoc()) {
+ chain.unsetLink().run();
+ } else {
+ chain
+ .insertContent({
+ type: 'text',
+ text: ' ',
+ })
+ .extendMarkRange(Link.name)
+ .unsetLink()
+ .deleteSelection()
+ .run();
+ }
},
resetBubbleMenuState() {
- this.linkTitle = undefined;
+ this.linkText = undefined;
this.linkHref = undefined;
this.linkCanonicalSrc = undefined;
},
},
tippyOptions: {
placement: 'bottom',
+ appendTo: () => document.body,
},
};
</script>
<template>
- <bubble-menu
- data-testid="link-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuLink"
- :should-show="shouldShow"
- :tippy-options="$options.tippyOptions"
- @show="updateLinkToState"
- @hidden="resetBubbleMenuState"
+ <editor-state-observer
+ :debounce="0"
+ @transaction="onTransaction"
+ @selectionUpdate="updateLinkToState"
>
- <editor-state-observer @selectionUpdate="updateLinkToState">
+ <bubble-menu
+ data-testid="link-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuLink"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ @show="updateLinkToState"
+ @hidden="resetBubbleMenuState"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-link
v-gl-tooltip
@@ -178,12 +246,12 @@ export default {
/>
</gl-button-group>
<gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink">
+ <gl-form-group :label="__('Text')" label-for="link-text">
+ <gl-form-input id="link-text" v-model="linkText" data-testid="link-text" />
+ </gl-form-group>
<gl-form-group :label="__('URL')" label-for="link-href">
<gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" />
</gl-form-group>
- <gl-form-group :label="__('Title')" label-for="link-title">
- <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" />
- </gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink">
{{ __('Cancel') }}
@@ -193,6 +261,6 @@ export default {
</gl-button>
</div>
</gl-form>
- </editor-state-observer>
- </bubble-menu>
+ </bubble-menu>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 9e08a257abf..f9d48708473 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -82,6 +82,16 @@ export default {
required: false,
default: true,
},
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -109,6 +119,8 @@ export default {
autofocus,
drawioEnabled,
editable,
+ enableAutocomplete,
+ autocompleteDataSources,
} = this;
// This is a non-reactive attribute intentionally since this is a complex object.
@@ -118,6 +130,8 @@ export default {
extensions,
serializerConfig,
drawioEnabled,
+ enableAutocomplete,
+ autocompleteDataSources,
tiptapOptions: {
autofocus,
editable,
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 ccb46e3b593..62f2113a8f4 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -16,14 +16,21 @@ const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEv
export default {
inject: ['tiptapEditor', 'eventHub'],
+ props: {
+ debounce: {
+ type: Number,
+ required: false,
+ default: 100,
+ },
+ },
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
- const eventHandler = debounce(
- (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
- 100,
- );
+ let eventHandler = (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params);
+ if (this.debounce) {
+ eventHandler = debounce(eventHandler, this.debounce);
+ }
this.tiptapEditor?.on(tiptapEvent, eventHandler);
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index a5be63fa89f..cd9fdeeca46 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,22 +1,17 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
-import ToolbarImageButton from './toolbar_image_button.vue';
-import ToolbarLinkButton from './toolbar_link_button.vue';
+import ToolbarAttachmentButton from './toolbar_attachment_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
- GlTabs,
- GlTab,
ToolbarButton,
ToolbarTextStyleDropdown,
- ToolbarLinkButton,
ToolbarTableButton,
- ToolbarImageButton,
+ ToolbarAttachmentButton,
ToolbarMoreDropdown,
},
methods: {
@@ -27,84 +22,87 @@ export default {
};
</script>
<template>
- <gl-tabs content-class="gl-display-none">
- <gl-tab title-link-class="gl-py-4 gl-px-3" :title="__('Write')" />
- <template #tabs-end>
- <div class="gl-ml-auto gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end">
- <toolbar-text-style-dropdown
- data-testid="text-styles"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- editor-command="toggleBold"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- editor-command="toggleItalic"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- editor-command="toggleCode"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-button
- data-testid="bullet-list"
- content-type="bulletList"
- icon-name="list-bulleted"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleBulletList"
- :label="__('Add a bullet list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="ordered-list"
- content-type="orderedList"
- icon-name="list-numbered"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleOrderedList"
- :label="__('Add a numbered list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="task-list"
- content-type="taskList"
- icon-name="list-task"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleTaskList"
- :label="__('Add a checklist')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
- <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
- </div>
- </template>
- </gl-tabs>
+ <div
+ class="gl-w-full gl-border-b gl-display-flex gl-justify-content-end"
+ data-testid="formatting-toolbar"
+ >
+ <div class="gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end">
+ <toolbar-text-style-dropdown
+ data-testid="text-styles"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="__('Bold text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="__('Italic text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="__('Code')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="link"
+ content-type="link"
+ icon-name="link"
+ editor-command="editLink"
+ :label="__('Insert link')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleBulletList"
+ :label="__('Add a bullet list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="ordered-list"
+ content-type="orderedList"
+ icon-name="list-numbered"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleOrderedList"
+ :label="__('Add a numbered list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a checklist')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-attachment-button data-testid="attachment" @execute="trackToolbarControlExecution" />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
+ </div>
+ </div>
</template>
<style>
.gl-spinner-container {
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
index 37e6ef61d50..4074e50a706 100644
--- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui';
+import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
GlAvatarLabeled,
+ GlLoadingIcon,
},
props: {
@@ -32,6 +33,12 @@ export default {
type: Function,
required: true,
},
+
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
@@ -208,65 +215,75 @@ export default {
</script>
<template>
- <ul
- :class="{ show: items.length > 0 }"
- class="gl-dropdown dropdown-menu gl-relative"
- data-testid="content-editor-suggestions-dropdown"
- >
- <div class="gl-dropdown-inner gl-overflow-y-auto">
- <gl-dropdown-item
- v-for="(item, index) in items"
- ref="dropdownItems"
- :key="index"
- :class="{ 'gl-bg-gray-50': index === selectedIndex }"
- @click="selectItem(index)"
- >
- <gl-avatar-labeled
- v-if="isUser"
- :label="item.username"
- :sub-label="avatarSubLabel(item)"
- :src="item.avatar_url"
- :entity-name="item.username"
- :shape="item.type === 'Group' ? 'rect' : 'circle'"
- :size="32"
- />
- <span v-if="isIssue || isMergeRequest">
- <small>{{ item.iid }}</small>
- {{ item.title }}
- </span>
- <span v-if="isVulnerability || isSnippet">
- <small>{{ item.id }}</small>
- {{ item.title }}
- </span>
- <span v-if="isEpic">
- <small>{{ item.reference }}</small>
- {{ item.title }}
- </span>
- <span v-if="isMilestone">
- {{ item.title }}
- </span>
- <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
- <span
- data-testid="label-color-box"
- class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
- :style="{ backgroundColor: item.color }"
- ></span>
- {{ item.title }}
- </span>
- <span v-if="isCommand">
- /{{ item.name }} <small> {{ item.params[0] }} </small><br />
- <em>
- <small> {{ item.description }} </small>
- </em>
- </span>
- <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
- <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
- <div class="gl-flex-grow-1">
- {{ item.name }}<br />
- <small>{{ item.d }}</small>
+ <div>
+ <ul
+ v-if="!loading"
+ :class="{ show: items.length > 0 }"
+ class="gl-dropdown dropdown-menu gl-relative gl-m-0!"
+ data-testid="content-editor-suggestions-dropdown"
+ >
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <gl-dropdown-item
+ v-for="(item, index) in items"
+ ref="dropdownItems"
+ :key="index"
+ :class="{ 'gl-bg-gray-50': index === selectedIndex }"
+ @click="selectItem(index)"
+ >
+ <gl-avatar-labeled
+ v-if="isUser"
+ :label="item.username"
+ :sub-label="avatarSubLabel(item)"
+ :src="item.avatar_url"
+ :entity-name="item.username"
+ :shape="item.type === 'Group' ? 'rect' : 'circle'"
+ :size="32"
+ />
+ <span v-if="isIssue || isMergeRequest">
+ <small>{{ item.iid }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isVulnerability || isSnippet">
+ <small>{{ item.id }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isEpic">
+ <small>{{ item.reference }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isMilestone">
+ {{ item.title }}
+ </span>
+ <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
+ <span
+ data-testid="label-color-box"
+ class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
+ :style="{ backgroundColor: item.color }"
+ ></span>
+ {{ item.title }}
+ </span>
+ <span v-if="isCommand">
+ /{{ item.name }} <small> {{ item.params[0] }} </small><br />
+ <em>
+ <small> {{ item.description }} </small>
+ </em>
+ </span>
+ <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
+ <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
+ <div class="gl-flex-grow-1">
+ {{ item.name }}<br />
+ <small>{{ item.d }}</small>
+ </div>
</div>
+ </gl-dropdown-item>
+ </div>
+ </ul>
+ <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!">
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <div class="gl-px-5">
+ <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }}
</div>
- </gl-dropdown-item>
+ </div>
</div>
- </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
new file mode 100644
index 00000000000..efb9a5b07b5
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import Link from '../extensions/link';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ linkHref: '',
+ };
+ },
+ methods: {
+ emitExecute(source = 'url') {
+ this.$emit('execute', { contentType: Link.name, value: source });
+ },
+ openFileUpload() {
+ this.$refs.fileSelector.click();
+ },
+ onFileSelect(e) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .uploadAttachment({
+ file: e.target.files[0],
+ })
+ .run();
+
+ // Reset the file input so that the same file can be uploaded again
+ this.$refs.fileSelector.value = '';
+ this.emitExecute('upload');
+ },
+ },
+};
+</script>
+<template>
+ <span class="gl-display-inline-flex">
+ <gl-button
+ v-gl-tooltip
+ :text="__('Attach a file or image')"
+ :title="__('Attach a file or image')"
+ category="tertiary"
+ icon="paperclip"
+ lazy
+ @click="openFileUpload"
+ />
+ <input
+ ref="fileSelector"
+ type="file"
+ name="content_editor_image"
+ class="gl-display-none"
+ data-qa-selector="file_upload_field"
+ @change="onFileSelect"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
deleted file mode 100644
index 8ed4dfce6de..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import { acceptedMimes } from '../services/upload_helpers';
-import { extractFilename } from '../services/utils';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- imgSrc: '',
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- insertImage() {
- this.tiptapEditor
- .chain()
- .focus()
- .setImage({
- src: this.imgSrc,
- canonicalSrc: this.imgSrc,
- alt: extractFilename(this.imgSrc),
- })
- .run();
-
- this.resetFields();
- this.emitExecute();
- },
- emitExecute(source = 'url') {
- this.$emit('execute', { contentType: 'image', value: source });
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.emitExecute('upload');
- },
- },
- acceptedMimes: acceptedMimes.image,
-};
-</script>
-<template>
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :text="__('Insert image')"
- :title="__('Insert image')"
- size="small"
- category="tertiary"
- icon="media"
- lazy
- text-sr-only
- data-testid="insert-image-toolbar-button"
- @hidden="resetFields()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
- <template #append>
- <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="openFileUpload">
- {{ __('Upload image') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_image"
- :accept="$options.acceptedMimes"
- class="gl-display-none"
- data-qa-selector="file_upload_field"
- @change="onFileSelect"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
deleted file mode 100644
index 4fb1e8ce16f..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import Link from '../extensions/link';
-import { hasSelection } from '../services/utils';
-import EditorStateObserver from './editor_state_observer.vue';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- EditorStateObserver,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- linkHref: '',
- isActive: false,
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- updateLinkState({ editor }) {
- const { canonicalSrc, href } = editor.getAttributes(Link.name);
-
- this.isActive = editor.isActive(Link.name);
- this.linkHref = canonicalSrc || href;
- },
- updateLink() {
- this.tiptapEditor
- .chain()
- .focus()
- .unsetLink()
- .setLink({
- href: this.linkHref,
- canonicalSrc: this.linkHref,
- })
- .run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- selectLink() {
- const { tiptapEditor } = this;
-
- // a selection has already been made by the user, so do nothing
- if (!hasSelection(tiptapEditor)) {
- tiptapEditor.chain().focus().extendMarkRange(Link.name).run();
- }
- },
- removeLink() {
- this.tiptapEditor.chain().focus().unsetLink().run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.$emit('execute', { contentType: Link.name });
- },
- },
-};
-</script>
-<template>
- <editor-state-observer @transaction="updateLinkState">
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :title="__('Insert link')"
- :text="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- text-sr-only
- lazy
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="isActive" @click="removeLink">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-else @click="openFileUpload">
- {{ __('Upload file') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_attachment"
- class="gl-display-none"
- @change="onFileSelect"
- />
- </span>
- </editor-state-observer>
-</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 4b1929e1a20..bf2740f9864 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -83,7 +83,7 @@ export default {
text-sr-only
lazy
>
- <gl-dropdown-form class="gl-px-3!">
+ <gl-dropdown-form class="gl-px-3! gl-pb-2!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
v-for="c of list(maxCols)"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 81f9b1f0af5..55cf38dfcbb 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -80,7 +80,7 @@ export default {
<template>
<editor-state-observer @transaction="updateDiagramPreview">
<node-view-wrapper
- :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`"
+ :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`"
as="pre"
>
<div
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
new file mode 100644
index 00000000000..4126c65d87f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
@@ -0,0 +1,45 @@
+<script>
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLink,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return this.node.attrs.text;
+ },
+ isCommand() {
+ return this.node.attrs.referenceType === 'command';
+ },
+ isMember() {
+ return this.node.attrs.referenceType === 'user';
+ },
+ isCurrentUser() {
+ return gon.current_username === this.text.substring(1);
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span v-if="isCommand">{{ text }}</span>
+ <gl-link
+ v-else
+ href="#"
+ class="gfm"
+ :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }"
+ @click.prevent.stop
+ >{{ text }}</gl-link
+ >
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
index 4206c866032..4206c866032 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/label.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 6a3740a5952..490025a9ac6 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -12,6 +12,11 @@ export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
export const TEXT_STYLE_DROPDOWN_ITEMS = [
{
+ contentType: 'paragraph',
+ editorCommand: 'setParagraph',
+ label: __('Normal text'),
+ },
+ {
contentType: 'heading',
commandParams: { level: 1 },
editorCommand: 'setHeading',
@@ -35,11 +40,6 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
commandParams: { level: 4 },
label: __('Heading 4'),
},
- {
- contentType: 'paragraph',
- editorCommand: 'setParagraph',
- label: __('Normal text'),
- },
];
export const ALERT_EVENT = 'alert';
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index e985e561fda..314d5230b01 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -18,6 +18,8 @@ export const extractHrefFromMarkdownLink = (match) => {
};
export default Link.extend({
+ inclusive: false,
+
addOptions() {
return {
...this.parent?.(),
@@ -64,4 +66,18 @@ export default Link.extend({
},
};
},
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ editLink: (attrs) => ({ chain }) => {
+ chain().setMeta('creatingLink', true).setLink(attrs).run();
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-k': () => this.editor.commands.editLink(),
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 0a9a0d8d4c1..82fa5ce6c1d 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -37,8 +37,18 @@ export default Extension.create({
const { state, view } = editor;
const { tr, selection } = state;
+ const { firstChild } = document.content;
+ const content =
+ document.content.childCount === 1 && firstChild.type.name === 'paragraph'
+ ? firstChild.content
+ : document.content;
+
+ if (selection.to - selection.from > 0) {
+ tr.replaceWith(selection.from, selection.to, content);
+ } else {
+ tr.insert(selection.from, content);
+ }
- tr.replaceWith(selection.from - 1, selection.to, document.content);
view.dispatch(tr);
})
.catch(() => {
@@ -53,13 +63,29 @@ export default Extension.create({
};
},
addProseMirrorPlugins() {
+ let pasteRaw = false;
+
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
- handlePaste: (_, event) => {
+ handleKeyDown: (_, event) => {
+ pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey;
+ },
+
+ handlePaste: (view, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
+ const { state } = view;
+ const { tr, selection } = state;
+ const { from, to } = selection;
+
+ if (pasteRaw) {
+ tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to);
+ view.dispatch(tr);
+ return true;
+ }
+
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index ed343d8acf8..01ffc217894 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import { Node } from '@tiptap/core';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -44,7 +42,7 @@ export default Node.create({
parseHTML() {
return [
{
- tag: `.${this.options.mediaType}-container`,
+ tag: `.${this.options.mediaType}-container`, // eslint-disable-line @gitlab/require-i18n-strings
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 707beaf1231..b56aa8596a0 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,6 @@
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import ReferenceWrapper from '../components/wrappers/reference.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const getAnchor = (element) => {
@@ -49,7 +51,7 @@ export default Node.create({
];
},
- renderHTML({ node }) {
- return ['a', { href: '#' }, node.attrs.text];
+ addNodeView() {
+ return new VueNodeViewRenderer(ReferenceWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js
index 9dff0b7a689..0441f8ef8d2 100644
--- a/app/assets/javascripts/content_editor/extensions/reference_label.js
+++ b/app/assets/javascripts/content_editor/extensions/reference_label.js
@@ -1,6 +1,6 @@
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants';
-import LabelWrapper from '../components/wrappers/label.vue';
+import LabelWrapper from '../components/wrappers/reference_label.vue';
import Reference from './reference';
export default Reference.extend({
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index eb53a3a61b3..e72b5c7365c 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -57,14 +57,25 @@ function createSuggestionPlugin({
let component;
let popup;
+ const onUpdate = (props) => {
+ component?.updateProps({ ...props, loading: false });
+
+ if (!props.clientRect) return;
+
+ popup?.[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ };
+
return {
- onStart: (props) => {
+ onBeforeStart: (props) => {
component = new VueRenderer(SuggestionsDropdown, {
propsData: {
...props,
char,
nodeType,
nodeProps,
+ loading: true,
},
editor: props.editor,
});
@@ -84,17 +95,8 @@ function createSuggestionPlugin({
});
},
- onUpdate(props) {
- component?.updateProps(props);
-
- if (!props.clientRect) {
- return;
- }
-
- popup?.[0].setProps({
- getReferenceClientRect: props.clientRect,
- });
- },
+ onStart: onUpdate,
+ onUpdate,
onKeyDown(props) {
if (props.event.key === 'Escape') {
@@ -118,12 +120,18 @@ function createSuggestionPlugin({
export default Node.create({
name: 'suggestions',
+ addOptions() {
+ return {
+ autocompleteDataSources: {},
+ };
+ },
+
addProseMirrorPlugins() {
return [
createSuggestionPlugin({
editor: this.editor,
char: '@',
- dataSource: gl.GfmAutoComplete?.dataSources.members,
+ dataSource: this.options.autocompleteDataSources.members,
nodeType: 'reference',
nodeProps: {
referenceType: 'user',
@@ -133,7 +141,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '#',
- dataSource: gl.GfmAutoComplete?.dataSources.issues,
+ dataSource: this.options.autocompleteDataSources.issues,
nodeType: 'reference',
nodeProps: {
referenceType: 'issue',
@@ -143,7 +151,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '$',
- dataSource: gl.GfmAutoComplete?.dataSources.snippets,
+ dataSource: this.options.autocompleteDataSources.snippets,
nodeType: 'reference',
nodeProps: {
referenceType: 'snippet',
@@ -153,7 +161,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '~',
- dataSource: gl.GfmAutoComplete?.dataSources.labels,
+ dataSource: this.options.autocompleteDataSources.labels,
nodeType: 'reference_label',
nodeProps: {
referenceType: 'label',
@@ -163,7 +171,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '&',
- dataSource: gl.GfmAutoComplete?.dataSources.epics,
+ dataSource: this.options.autocompleteDataSources.epics,
nodeType: 'reference',
nodeProps: {
referenceType: 'epic',
@@ -173,7 +181,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '[vulnerability:',
- dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities,
+ dataSource: this.options.autocompleteDataSources.vulnerabilities,
nodeType: 'reference',
nodeProps: {
referenceType: 'vulnerability',
@@ -183,7 +191,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '!',
- dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests,
+ dataSource: this.options.autocompleteDataSources.mergeRequests,
nodeType: 'reference',
nodeProps: {
referenceType: 'merge_request',
@@ -193,7 +201,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '%',
- dataSource: gl.GfmAutoComplete?.dataSources.milestones,
+ dataSource: this.options.autocompleteDataSources.milestones,
nodeType: 'reference',
nodeProps: {
referenceType: 'milestone',
@@ -203,7 +211,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '/',
- dataSource: gl.GfmAutoComplete?.dataSources.commands,
+ dataSource: this.options.autocompleteDataSources.commands,
nodeType: 'reference',
nodeProps: {
referenceType: 'command',
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 9d536793287..f1d4f85dcb0 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -88,6 +88,8 @@ export const createContentEditor = ({
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
drawioEnabled = false,
+ enableAutocomplete,
+ autocompleteDataSources = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -144,7 +146,6 @@ export const createContentEditor = ({
Sourcemap,
Strike,
Subscript,
- Suggestions,
Superscript,
TableCell,
TableHeader,
@@ -160,6 +161,7 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+ if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources }));
if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown }));
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index e27a427372c..9ff50b45088 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -194,6 +195,7 @@ const defaultSerializerConfig = {
inline: true,
}),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
+ [Loading.name]: () => {},
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: renderReference,
@@ -227,6 +229,7 @@ const defaultSerializerConfig = {
[TableRow.name]: renderTableRow,
[TaskItem.name]: preserveUnchanged((state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ if (!node.textContent) state.write('&nbsp;');
state.renderContent(node);
}),
[TaskList.name]: preserveUnchanged((state, node) => {
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index fe1b32c5b0a..11a11ed43bd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => {
const range = getRangeFromSourcePos(element.dataset.sourcepos);
let elSource = '';
+ if (!source.length) return undefined;
+
for (let i = range.start.row; i <= range.end.row; i += 1) {
if (i === range.start.row) {
elSource += source[i].substring(range.start.col);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 540815f57c9..664473fccfe 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -309,12 +309,15 @@ export function renderHardBreak(state, node, parent, index) {
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs;
+ let realSrc = canonicalSrc || src || '';
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (realSrc.startsWith('data:')) realSrc = '';
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
const sourceExpression = isReference
? `[${canonicalSrc}]`
- : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
+ : `(${state.esc(realSrc)}${quotedTitle})`;
const sizeAttributes = [];
if (width) {
@@ -604,7 +607,7 @@ export const link = {
return '[';
}
- const attrs = { href: state.esc(href || canonicalSrc) };
+ const attrs = { href: state.esc(href || canonicalSrc || '') };
if (title) {
attrs.title = title;
@@ -620,14 +623,14 @@ export const link = {
const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
if (isReference) {
- return `][${state.esc(canonicalSrc || href)}]`;
+ return `][${state.esc(canonicalSrc || href || '')}]`;
}
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
}
- return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
+ return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`;
},
};
@@ -638,9 +641,8 @@ const generateStrikeTag = (wrapTagName = openTag) => {
switch (type) {
case '~~':
return type;
- /* eslint-disable @gitlab/require-i18n-strings */
- case '<del':
- case '<strike':
+ case '<del': // eslint-disable-line @gitlab/require-i18n-strings
+ case '<strike': // eslint-disable-line @gitlab/require-i18n-strings
case '<s':
return wrapTagName(type.substring(1));
default:
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index c9097b9384f..94f27dbf048 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -131,7 +131,7 @@ export default {
</dl>
</div>
</div>
- <div class="table-section section-30 section-wrap">
+ <div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
@@ -168,7 +168,7 @@ export default {
<span v-else class="text-secondary">{{ __('None') }}</span>
</div>
</div>
- <div class="table-section section-15 text-right">
+ <div class="table-section section-15">
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
@@ -176,7 +176,23 @@ export default {
</span>
</div>
</div>
- <div class="table-section section-15 table-button-footer deploy-key-actions">
+ <div class="table-section section-15">
+ <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div>
+ <div class="table-mobile-content text-secondary key-expires-at">
+ <span
+ v-if="deployKey.expires_at"
+ v-gl-tooltip
+ :title="tooltipTitle(deployKey.expires_at)"
+ data-testid="expires-at-tooltip"
+ >
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
+ </span>
+ <span v-else>
+ <span data-testid="expires-never">{{ __('Never') }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="table-section section-10 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }}
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 77ec1ef590f..e04cbbe72b9 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -34,10 +34,12 @@ export default {
<div role="rowheader" class="table-section section-40">
{{ s__('DeployKeys|Deploy key') }}
</div>
- <div role="rowheader" class="table-section section-30">
+ <div role="rowheader" class="table-section section-20">
{{ s__('DeployKeys|Project usage') }}
</div>
- <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div>
+ <!-- leave 10% space for actions --->
</div>
<deploy-key
v-for="deployKey in keys"
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
index 8ca4dc587a8..2cb9e9a56a3 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
import { debounce } from 'lodash';
@@ -59,6 +57,7 @@ export class GitLabDropdownFilter {
return BLUR_KEYCODES.indexOf(keyCode) !== -1;
}
+ // eslint-disable-next-line consistent-return
filter(searchText) {
let group;
let results;
@@ -114,9 +113,10 @@ export class GitLabDropdownFilter {
const matches = fuzzaldrinPlus.match($el.text().trim(), searchText);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
- return $el.show().removeClass('option-hidden');
+ $el.show().removeClass('option-hidden');
+ } else {
+ $el.hide().addClass('option-hidden');
}
- return $el.hide().addClass('option-hidden');
}
});
} else {
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 0008c3504ce..537c810bcff 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1179,9 +1179,11 @@ export default class Notes {
const form = textarea.parents('form');
const reopenbtn = form.find('.js-note-target-reopen');
const closebtn = form.find('.js-note-target-close');
+ const savebtn = form.find('.js-comment-save-button');
const commentTypeComponent = form.get(0)?.commentTypeComponent;
if (textarea.val().trim().length > 0) {
+ savebtn.enable();
reopentext = reopenbtn.attr('data-alternative-text');
closetext = closebtn.attr('data-alternative-text');
if (reopenbtn.text() !== reopentext) {
@@ -1200,6 +1202,7 @@ export default class Notes {
commentTypeComponent.disabled = false;
}
} else {
+ savebtn.disable();
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
@@ -1395,7 +1398,7 @@ export default class Notes {
*/
static isNewNote(noteEntity, note_ids) {
if (note_ids.length === 0) {
- Notes.loadNotesIds(note_ids);
+ note_ids = Notes.getNotesIds();
}
const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1;
if (isNewEntry) {
@@ -1405,16 +1408,17 @@ export default class Notes {
}
/**
- * Load notes ids
+ * Get notes ids
*/
- static loadNotesIds(note_ids) {
- const $notesList = $('.main-notes-list li[id^=note_]');
- for (const $noteItem of $notesList) {
- if (Notes.isNodeTypeElement($noteItem)) {
- const noteId = parseInt($noteItem.id.split('_')[1], 10);
- note_ids.push(noteId);
- }
- }
+ static getNotesIds() {
+ /**
+ * The selector covers following notes
+ * - notes and thread below the snippets and commit page
+ * - notes on the file of commit page
+ * - notes on an image file of commit page
+ */
+ const notesList = [...document.querySelectorAll('.notes:not(.notes-form) li[id]')];
+ return notesList.map((noteItem) => parseInt(noteItem.dataset.noteId, 10));
}
/**
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index 80b146c9209..12bb4b830f8 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -14,7 +14,7 @@ export default () => {
issuePath,
registerPath,
signInPath,
- savedRepliesNewPath,
+ newCommentTemplatePath,
} = el.dataset;
const router = createRouter(issuePath);
@@ -39,7 +39,7 @@ export default () => {
issueIid,
registerPath,
signInPath,
- newSavedRepliesPath: savedRepliesNewPath,
+ newCommentTemplatePath,
},
mounted() {
performanceMarkAndMeasure({
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 0251ffe28f9..2f2b2ed1a90 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -332,7 +332,7 @@ export default {
<template>
<div
- class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
+ class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
<div
class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index ab003fb2879..e270613e4eb 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -104,7 +104,7 @@ export default {
return this.permissions.createDesign;
},
showToolbar() {
- return this.canCreateDesign && this.allVersions.length > 0;
+ return this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
@@ -375,6 +375,7 @@ export default {
<design-version-dropdown />
</div>
<div
+ v-if="canCreateDesign"
v-show="hasDesigns"
class="gl-display-flex gl-align-items-center"
data-testid="design-selector-toolbar"
@@ -489,7 +490,11 @@ export default {
/>
</li>
<template #header>
- <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
+ <li
+ v-if="canCreateDesign"
+ :class="designDropzoneWrapperClass"
+ data-testid="design-dropzone-wrapper"
+ >
<design-dropzone
:enable-drag-behavior="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 9ef0f336d43..1ae7b6a2110 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -1,8 +1,7 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import produce from 'immer';
import { differenceBy } from 'lodash';
import { createAlert } from '~/alert';
+import { TYPENAME_DISCUSSION, TYPENAME_TODO, TYPENAME_USER } from '~/graphql_shared/constants';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
@@ -60,7 +59,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
});
const newDiscussion = {
- __typename: 'Discussion',
+ __typename: TYPENAME_DISCUSSION,
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
@@ -86,7 +85,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
design.issue.participants.nodes = [
...design.issue.participants.nodes,
{
- __typename: 'User',
+ __typename: TYPENAME_USER,
...createImageDiffNote.note.author,
},
];
@@ -199,7 +198,7 @@ export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables)
const data = produce(sourceData, (draftData) => {
const design = extractDesign(draftData);
const existingTodos = design.currentUserTodos?.nodes || [];
- const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }];
+ const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: TYPENAME_TODO }];
if (!design.currentUserTodos) {
design.currentUserTodos = {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 9ccba88f7e6..9b3db78724d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -45,7 +45,9 @@ import {
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
+import { updateChangesTabCount } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
+import FindingsDrawer from './shared/findings_drawer.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import CommitWidget from './commit_widget.vue';
import CompareVersions from './compare_versions.vue';
@@ -59,6 +61,7 @@ import PreRenderer from './pre_renderer.vue';
export default {
name: 'DiffsApp',
components: {
+ FindingsDrawer,
DynamicScroller,
DynamicScrollerItem,
PreRenderer,
@@ -199,6 +202,7 @@ export default {
numTotalFiles: 'realSize',
numVisibleFiles: 'size',
}),
+ ...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
'showTreeList',
'isLoading',
@@ -233,6 +237,7 @@ export default {
'flatBlobsList',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
+ ...mapGetters('findingsDrawer', ['activeDrawer']),
diffs() {
if (!this.viewDiffsFileByFile) {
return this.diffFiles;
@@ -248,6 +253,9 @@ export default {
renderDiffFiles() {
return this.flatBlobsList.length > 0;
},
+ diffsIncomplete() {
+ return this.flatBlobsList.length !== this.diffFiles.length;
+ },
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
},
@@ -308,6 +316,11 @@ export default {
diffViewType() {
this.adjustView();
},
+ viewDiffsFileByFile(newViewFileByFile) {
+ if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) {
+ this.refetchDiffData({ refetchMeta: false });
+ }
+ },
shouldShow() {
// When the shouldShow property changed to true, the route is rendered for the first time
// and if we have the isLoading as true this means we didn't fetch the data
@@ -337,8 +350,6 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
- this.interfaceWithDOM();
-
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -426,41 +437,48 @@ export default {
'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
+ 'fetchFileByFile',
'fetchCoverageFiles',
'fetchCodequality',
+ 'rereadNoteHash',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
'cacheTreeListWidth',
- 'scrollToFile',
+ 'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
'setFileByFile',
'disableVirtualScroller',
]),
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ closeDrawer() {
+ this.setDrawer({});
+ },
subscribeToEvents() {
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
+ if (this.glFeatures.singleFileFileByFile) {
+ diffsEventHub.$on('diffFilesModified', this.setDiscussions);
+ notesEventHub.$on('fetchedNotesData', this.rereadNoteHash);
+ }
},
unsubscribeFromEvents() {
+ if (this.glFeatures.singleFileFileByFile) {
+ notesEventHub.$off('fetchedNotesData', this.rereadNoteHash);
+ diffsEventHub.$off('diffFilesModified', this.setDiscussions);
+ }
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
- interfaceWithDOM() {
- this.diffsTab = document.querySelector('.js-diffs-tab');
- },
- updateChangesTabCount() {
- const badge = this.diffsTab.querySelector('.gl-badge');
-
- if (this.diffsTab && badge) {
- badge.textContent = this.diffFilesLength;
- }
- },
navigateToDiffFileNumber(number) {
- this.navigateToDiffFileIndex(number - 1);
+ this.navigateToDiffFileIndex({
+ index: number - 1,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
},
- refetchDiffData() {
- this.fetchData(false);
+ refetchDiffData({ refetchMeta = true } = {}) {
+ this.fetchData({ toggleTree: false, fetchMeta: refetchMeta });
},
needsReload() {
return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]);
@@ -468,42 +486,52 @@ export default {
needsFirstLoad() {
return !this.diffFiles.length;
},
- fetchData(toggleTree = true) {
- this.fetchDiffFilesMeta()
- .then((data) => {
- let realSize = 0;
-
- if (data) {
- realSize = data.real_size;
- }
-
- this.diffFilesLength = parseInt(realSize, 10) || 0;
- if (toggleTree) {
- this.setTreeDisplay();
- }
-
- this.updateChangesTabCount();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ fetchData({ toggleTree = true, fetchMeta = true } = {}) {
+ if (fetchMeta) {
+ this.fetchDiffFilesMeta()
+ .then((data) => {
+ let realSize = 0;
+
+ if (data) {
+ realSize = data.real_size;
+
+ if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) {
+ this.fetchFileByFile();
+ }
+ }
+
+ this.diffFilesLength = parseInt(realSize, 10) || 0;
+ if (toggleTree) {
+ this.setTreeDisplay();
+ }
+
+ updateChangesTabCount({
+ count: this.diffFilesLength,
+ });
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
- this.fetchDiffFilesBatch()
- .then(() => {
- if (toggleTree) this.setTreeDisplay();
- // Guarantee the discussions are assigned after the batch finishes.
- // Just watching the length of the discussions or the diff files
- // isn't enough, because with split diff loading, neither will
- // change when loading the other half of the diff files.
- this.setDiscussions();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) {
+ this.fetchDiffFilesBatch()
+ .then(() => {
+ if (toggleTree) this.setTreeDisplay();
+ // Guarantee the discussions are assigned after the batch finishes.
+ // Just watching the length of the discussions or the diff files
+ // isn't enough, because with split diff loading, neither will
+ // change when loading the other half of the diff files.
+ this.setDiscussions();
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
if (this.endpointCoverage) {
this.fetchCoverageFiles();
@@ -579,7 +607,10 @@ export default {
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) {
- this.scrollToFile({ path: this.flatBlobsList[targetIndex].path });
+ this.goToFile({
+ path: this.flatBlobsList[targetIndex].path,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
}
},
setTreeDisplay() {
@@ -640,6 +671,11 @@ export default {
<template>
<div v-show="shouldShow">
+ <findings-drawer
+ v-if="glFeatures.codeQualityInlineDrawer"
+ :drawer="activeDrawer"
+ @close="closeDrawer"
+ />
<div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
<compare-versions :diff-files-count-text="numTotalFiles" />
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 1857ff557e6..d050f2fb9ae 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
@@ -30,6 +30,7 @@ export default {
CommitPipelineStatus,
GlButtonGroup,
GlButton,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -117,12 +118,11 @@ export default {
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
- <input
+ <gl-form-checkbox
v-if="isSelectable"
- class="gl-mr-3"
- type="checkbox"
:checked="checked"
- @change="$emit('handleCheckboxChange', $event.target.checked)"
+ class="gl-mt-3"
+ @change="$emit('handleCheckboxChange', !checked)"
/>
<user-avatar-link
:link-href="authorUrl"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index 5392c631c14..f3f05e3d9d9 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -1,27 +1,19 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { GlButton } from '@gitlab/ui';
import { NEW_CODE_QUALITY_FINDINGS } from '../i18n';
+import DiffCodeQualityItem from './diff_code_quality_item.vue';
export default {
i18n: {
newFindings: NEW_CODE_QUALITY_FINDINGS,
},
- components: { GlButton, GlIcon },
+ components: { GlButton, DiffCodeQualityItem },
props: {
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>
@@ -37,23 +29,11 @@ export default {
{{ $options.i18n.newFindings }}
</h4>
<ul class="gl-list-style-none gl-mb-0 gl-p-0">
- <li
+ <diff-code-quality-item
v-for="finding in codeQuality"
:key="finding.description"
- class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex"
- >
- <span class="gl-mr-3">
- <gl-icon
- :size="12"
- :name="severityIcon(finding.severity)"
- :class="severityClass(finding.severity)"
- class="codequality-severity-icon"
- />
- </span>
- <span>
- <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }}
- </span>
- </li>
+ :finding="finding"
+ />
</ul>
<gl-button
data-testid="diff-codequality-close"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
new file mode 100644
index 00000000000..eede110f46c
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: { GlLink, GlIcon },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ finding: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ toggleDrawer() {
+ this.setDrawer(this.finding);
+ },
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-1 gl-font-regular gl-display-flex">
+ <span class="gl-mr-3">
+ <gl-icon
+ :size="12"
+ :name="severityIcon(finding.severity)"
+ :class="severityClass(finding.severity)"
+ class="codequality-severity-icon"
+ />
+ </span>
+ <span
+ v-if="glFeatures.codeQualityInlineDrawer"
+ data-testid="description-button-section"
+ class="gl-display-flex"
+ >
+ <gl-link category="primary" variant="link" @click="toggleDrawer">
+ {{ finding.severity }} - {{ finding.description }}</gl-link
+ >
+ </span>
+ <span v-else data-testid="description-plain-text" class="gl-display-flex">
+ {{ finding.severity }} - {{ finding.description }}
+ </span>
+ </li>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index c19174dda8a..a58178eaef7 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -209,7 +209,11 @@ export default {
if (this.hasDiff) {
this.postRender();
- } else if (this.viewDiffsFileByFile && !this.isCollapsed) {
+ } else if (
+ this.viewDiffsFileByFile &&
+ !this.isCollapsed &&
+ !this.glFeatures.singleFileFileByFile
+ ) {
this.requestDiff();
}
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 16f45c3ad6a..c3a4897ce78 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -50,6 +50,7 @@ export default {
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
+ fileModeTooltip: __('File permissions'),
},
props: {
discussionPath: {
@@ -201,6 +202,9 @@ export default {
externalUrlLabel() {
return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
},
+ labelToggleFile() {
+ return this.expanded ? __('Hide file contents') : __('Show file contents');
+ },
},
watch: {
'idState.moreActionsShown': {
@@ -287,12 +291,14 @@ export default {
@click.self="handleToggleFile"
>
<div class="file-header-content">
- <gl-icon
+ <gl-button
v-if="collapsible"
- ref="collapseIcon"
- :name="collapseIcon"
- :size="16"
- class="diff-toggle-caret gl-mr-2"
+ ref="collapseButton"
+ class="gl-mr-2"
+ category="tertiary"
+ size="small"
+ :icon="collapseIcon"
+ :aria-label="labelToggleFile"
@click.stop="handleToggleFile"
/>
<a
@@ -342,7 +348,13 @@ export default {
data-track-property="diff_copy_file"
/>
- <small v-if="isModeChanged" ref="fileMode" class="mr-1">
+ <small
+ v-if="isModeChanged"
+ ref="fileMode"
+ v-gl-tooltip.hover
+ class="mr-1"
+ :title="$options.i18n.fileModeTooltip"
+ >
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -364,7 +376,7 @@ export default {
v-if="isReviewable && showLocalFileReviews"
v-gl-tooltip.hover
data-testid="fileReviewCheckbox"
- class="gl-mr-5 gl-display-flex gl-align-items-center"
+ class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
:checked="reviewed"
@change="toggleReview"
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 f63ab1bb067..43ba527dad8 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -8,7 +8,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
import NoteForm from '~/notes/components/note_form.vue';
-import autosave from '~/notes/mixins/autosave';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import {
DIFF_NOTE_TYPE,
INLINE_DIFF_LINES_KEY,
@@ -21,7 +21,7 @@ export default {
NoteForm,
MultilineCommentForm,
},
- mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
+ mixins: [diffLineNoteFormMixin, glFeatureFlagsMixin()],
props: {
diffFileHash: {
type: String,
@@ -146,6 +146,27 @@ export default {
return lines;
},
+ autosaveKey() {
+ if (!this.isLoggedIn) return '';
+
+ const {
+ id,
+ noteable_type: noteableTypeUnderscored,
+ noteableType,
+ diff_head_sha: diffHeadSha,
+ source_project_id: sourceProjectId,
+ } = this.noteableData;
+
+ return [
+ s__('Autosave|Note'),
+ capitalizeFirstCharacter(noteableTypeUnderscored || noteableType),
+ id,
+ diffHeadSha,
+ DIFF_NOTE_TYPE,
+ sourceProjectId,
+ this.line.line_code,
+ ].join('/');
+ },
},
created() {
if (this.range) {
@@ -155,17 +176,6 @@ export default {
}
},
mounted() {
- if (this.isLoggedIn) {
- const keys = [
- this.noteableData.diff_head_sha,
- DIFF_NOTE_TYPE,
- this.noteableData.source_project_id,
- this.line.line_code,
- ];
-
- this.initAutoSave(this.noteableData, keys);
- }
-
if (this.selectedCommentPosition) {
this.commentLineStart = this.selectedCommentPosition.start;
}
@@ -196,9 +206,6 @@ export default {
lineCode: this.line.line_code,
fileHash: this.diffFileHash,
});
- this.$nextTick(() => {
- this.resetAutoSave();
- });
}),
handleSaveNote(note) {
return this.saveDiffDiscussion({ note, formData: this.formData }).then(() =>
@@ -232,6 +239,7 @@ export default {
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
:save-button-title="__('Comment')"
+ :autosave-key="autosaveKey"
class="diff-comment-form gl-mt-3"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index e8b4ff16aec..7de8eff7863 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -76,7 +76,7 @@ export default {
class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center"
:class="{ bold: isCompareVersionsHeader }"
>
- <span>-</span>
+ <span>−</span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index a2e052e0f93..348d6d1d78d 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,7 +1,6 @@
<script>
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';
@@ -21,11 +20,7 @@ export default {
DiffCommentCell,
DraftNote,
},
- mixins: [
- draftCommentsMixin,
- IdState({ idProp: (vm) => vm.diffFile.file_hash }),
- glFeatureFlagsMixin(),
- ],
+ mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
props: {
diffFile: {
type: Object,
@@ -265,10 +260,7 @@ export default {
@stopdragging="onStopDragging"
/>
<diff-line
- v-if="
- glFeatures.refactorCodeQualityInlineFindings &&
- codeQualityExpandedLines.includes(getCodeQualityLine(line))
- "
+ v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:key="line.line_code"
:line="line"
@hideCodeQualityFindings="hideCodeQualityFindings"
diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index f6a8c679f3b..26d37484541 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,17 +1,18 @@
<script>
-import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
export const i18n = {
- title: __('Too many changes to show.'),
+ title: __('Some changes are not shown.'),
plainDiff: __('Plain diff'),
- emailPatch: __('Email patch'),
+ emailPatch: __('Patches'),
};
export default {
i18n,
components: {
GlAlert,
+ GlButton,
GlSprintf,
},
props: {
@@ -38,18 +39,15 @@ export default {
<template>
<gl-alert
variant="warning"
+ class="gl-mx-5 gl-mb-4 gl-mt-3"
:title="$options.i18n.title"
- :primary-button-text="$options.i18n.plainDiff"
- :primary-button-link="plainDiffPath"
- :secondary-button-text="$options.i18n.emailPatch"
- :secondary-button-link="emailPatchPath"
:dismissible="false"
>
<gl-sprintf
:message="
sprintf(
__(
- 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.',
+ 'For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes.',
),
{ visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */,
)
@@ -59,5 +57,13 @@ export default {
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
+ <template #actions>
+ <gl-button :href="plainDiffPath" class="gl-mr-3 gl-alert-action">
+ {{ $options.i18n.plainDiff }}
+ </gl-button>
+ <gl-button :href="emailPatchPath" class="gl-alert-action">
+ {{ $options.i18n.emailPatch }}
+ </gl-button>
+ </template>
</gl-alert>
</template>
diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
new file mode 100644
index 00000000000..da880c6f3ca
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { s__ } from '~/locale';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+
+export const i18n = {
+ severity: s__('FindingsDrawer|Severity:'),
+ engine: s__('FindingsDrawer|Engine:'),
+ category: s__('FindingsDrawer|Category:'),
+ otherLocations: s__('FindingsDrawer|Other locations:'),
+};
+
+export default {
+ i18n,
+ components: { GlDrawer, GlIcon, GlLink },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ drawer: {
+ type: Object,
+ required: true,
+ },
+ },
+ safeHtmlConfig: {
+ ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'],
+ ALLOWED_ATTR: ['href', 'rel'],
+ },
+ computed: {
+ drawerOffsetTop() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ },
+ DRAWER_Z_INDEX,
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ :header-height="drawerOffsetTop"
+ :z-index="$options.DRAWER_Z_INDEX"
+ class="findings-drawer"
+ :open="Object.keys(drawer).length !== 0"
+ @close="$emit('close')"
+ >
+ <template #title>
+ <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0">
+ {{ drawer.description }}
+ </h2>
+ </template>
+
+ <template #default>
+ <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!">
+ <li data-testid="findings-drawer-severity" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span>
+ <gl-icon
+ data-testid="findings-drawer-severity-icon"
+ :size="12"
+ :name="severityIcon(drawer.severity)"
+ :class="severityClass(drawer.severity)"
+ class="codequality-severity-icon"
+ />
+
+ {{ drawer.severity }}
+ </li>
+ <li data-testid="findings-drawer-engine" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span>
+ {{ drawer.engineName }}
+ </li>
+ <li data-testid="findings-drawer-category" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span>
+ {{ drawer.categories ? drawer.categories[0] : '' }}
+ </li>
+ <li data-testid="findings-drawer-other-locations" class="gl-mb-4">
+ <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{
+ $options.i18n.otherLocations
+ }}</span>
+ <ul class="gl-pl-6">
+ <li
+ v-for="otherLocation in drawer.otherLocations"
+ :key="otherLocation.path"
+ class="gl-mb-1"
+ >
+ <gl-link
+ data-testid="findings-drawer-other-locations-link"
+ :href="otherLocation.href"
+ >{{ otherLocation.path }}</gl-link
+ >
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''"
+ data-testid="findings-drawer-body"
+ class="drawer-body gl-display-block gl-px-3 gl-py-0!"
+ ></span>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index ab08c72b08f..4f1875e9175 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -5,6 +5,7 @@ import micromatch from 'micromatch';
import { debounce } from 'lodash';
import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RecycleScroller } from 'vendor/vue-virtual-scroller';
import DiffFileRow from './diff_file_row.vue';
@@ -19,6 +20,7 @@ export default {
DiffFileRow,
RecycleScroller,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
hideFileStats: {
type: Boolean,
@@ -105,7 +107,7 @@ export default {
this.resizeObserver.disconnect();
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
+ ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
clearSearch() {
this.search = '';
},
@@ -128,7 +130,7 @@ export default {
>
<div class="gl-pb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
- <gl-icon name="search" class="position-absolute tree-list-icon" />
+ <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
id="diff-tree-search"
@@ -154,7 +156,7 @@ export default {
<div
ref="scrollRoot"
:class="{ 'tree-list-blobs': !renderTreeList || search }"
- class="gl-flex-grow-1"
+ class="gl-flex-grow-1 mr-tree-list"
>
<recycle-scroller
v-if="flatFilteredTreeList.length"
@@ -172,10 +174,10 @@ export default {
:hide-file-stats="hideFileStats"
:current-diff-file-id="currentDiffFileId"
:style="{ '--level': item.level }"
- :class="{ 'tree-list-parent': item.tree.length }"
+ :class="{ 'tree-list-parent': item.level > 0 }"
class="gl-relative"
@toggleTreeOpen="toggleTreeOpen"
- @clickFile="(path) => scrollToFile({ path })"
+ @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })"
/>
</template>
<template #after>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 873c4819669..a459def6b4b 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -112,3 +112,6 @@ export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace';
export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting';
export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file';
export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files';
+
+// UI
+export const ZERO_CHANGES_ALT_DISPLAY = '-';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 0f44eb06cb3..e233a0cef0a 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -1,6 +1,12 @@
import { __, s__ } from '~/locale';
export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
+export const LOAD_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.",
+);
+export const DISCUSSION_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the single file diff for the discussion. Please reload this page.",
+);
export const DIFF_FILE_HEADER = {
optionsDropdownTitle: __('Options'),
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 00a08434dac..ad7182024da 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -27,7 +27,7 @@ export default function initDiffsApp(store = notesStore) {
store,
apolloProvider,
provide: {
- newSavedRepliesPath: dataset.savedRepliesNewPath,
+ newCommentTemplatePath: dataset.newCommentTemplatePath,
},
data() {
return {
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 9236e14beb1..a70c907314b 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -16,6 +16,7 @@ import { __, s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
import { sortTree } from '~/ide/stores/utils';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -49,6 +50,7 @@ import {
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
+import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n';
import eventHub from '../event_hub';
import { isCollapsed } from '../utils/diff_file';
import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews';
@@ -62,6 +64,8 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareLineForRenamedFile,
+ parseUrlHashAsFileHash,
+ isUrlHashNoteLink,
} from './utils';
export const setBaseConfig = ({ commit }, options) => {
@@ -101,6 +105,47 @@ export const setBaseConfig = ({ commit }, options) => {
});
};
+export const fetchFileByFile = async ({ state, getters, commit }) => {
+ const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
+ const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId);
+ const treeEntry = id
+ ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id)
+ : getters.flatBlobsList[0];
+
+ if (treeEntry && !treeEntry.diffLoaded && !getters.getDiffFileByHash(id)) {
+ // Overloading "batch" loading indicators so the UI stays mostly the same
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
+ commit(types.SET_RETRIEVING_BATCHES, true);
+
+ const urlParams = {
+ old_path: treeEntry.filePaths.old,
+ new_path: treeEntry.filePaths.new,
+ w: state.showWhitespace ? '0' : '1',
+ view: 'inline',
+ };
+
+ axios
+ .get(mergeUrlParams({ ...urlParams }, state.endpointDiffForPath))
+ .then(({ data: diffData }) => {
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files });
+
+ if (!isNoteLink && !state.currentDiffFileId) {
+ commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0]?.file_hash || '');
+ }
+
+ commit(types.SET_BATCH_LOADING_STATE, 'loaded');
+
+ eventHub.$emit('diffFilesModified');
+ })
+ .catch(() => {
+ commit(types.SET_BATCH_LOADING_STATE, 'error');
+ })
+ .finally(() => {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ });
+ }
+};
+
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
let perPage = state.viewDiffsFileByFile ? 1 : 5;
let increaseAmount = 1.4;
@@ -512,13 +557,20 @@ export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
}
};
-export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
+export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
note,
...formData,
});
+ if (containsSensitiveToken(note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return null;
+ }
+ }
+
return dispatch('saveNote', postData, { root: true })
.then((result) => dispatch('updateDiscussion', result.discussion, { root: true }))
.then((discussion) => dispatch('assignDiscussionsToDiff', [discussion]))
@@ -539,6 +591,31 @@ export const setCurrentFileHash = ({ commit }, hash) => {
commit(types.SET_CURRENT_DIFF_FILE, hash);
};
+export const goToFile = ({ state, commit, dispatch, getters }, { path, singleFile }) => {
+ if (!state.viewDiffsFileByFile || !singleFile) {
+ dispatch('scrollToFile', { path });
+ } else {
+ if (!state.treeEntries[path]) return;
+
+ const { fileHash } = state.treeEntries[path];
+
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+ document.location.hash = fileHash;
+
+ if (!getters.isTreePathLoaded(path)) {
+ dispatch('fetchFileByFile')
+ .then(() => {
+ dispatch('scrollToFile', { path });
+ })
+ .catch(() => {
+ createAlert({
+ message: LOAD_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+ }
+};
+
export const scrollToFile = ({ state, commit, getters }, { path }) => {
if (!state.treeEntries[path]) return;
@@ -779,13 +856,11 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
});
export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) {
- /* eslint-disable @gitlab/require-i18n-strings */
if (!commitId) {
return Promise.reject(new Error('`commitId` is a required argument'));
} else if (!state.commit) {
- return Promise.reject(new Error('`state` must already contain a valid `commit`'));
+ return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings
}
- /* eslint-enable @gitlab/require-i18n-strings */
// this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421
const commitRE = new RegExp(state.commit.id, 'g');
@@ -821,6 +896,24 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
+export const rereadNoteHash = ({ state, dispatch }) => {
+ const urlHash = window?.location?.hash;
+
+ if (isUrlHashNoteLink(urlHash)) {
+ dispatch('setCurrentDiffFileIdFromNote', urlHash.split('_').pop())
+ .then(() => {
+ if (state.viewDiffsFileByFile) {
+ dispatch('fetchFileByFile');
+ }
+ })
+ .catch(() => {
+ createAlert({
+ message: DISCUSSION_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+};
+
export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, noteId) => {
const note = rootGetters.notesById[noteId];
@@ -833,11 +926,18 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n
}
};
-export const navigateToDiffFileIndex = ({ commit, getters }, index) => {
+export const navigateToDiffFileIndex = (
+ { state, getters, commit, dispatch },
+ { index, singleFile },
+) => {
const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+
+ if (state.viewDiffsFileByFile && singleFile) {
+ dispatch('fetchFileByFile');
+ }
};
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 3739ef0cd55..4ca353333b7 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -559,19 +559,19 @@ export const allDiscussionWrappersExpanded = (diff) => {
return discussionsExpanded;
};
-export function isUrlHashNoteLink(urlHash) {
+export function isUrlHashNoteLink(urlHash = '') {
const id = urlHash.replace(/^#/, '');
return id.startsWith('note');
}
-export function isUrlHashFileHeader(urlHash) {
+export function isUrlHashFileHeader(urlHash = '') {
const id = urlHash.replace(/^#/, '');
return id.startsWith('diff-content');
}
-export function parseUrlHashAsFileHash(urlHash, currentDiffFileId = '') {
+export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') {
const isNoteLink = isUrlHashNoteLink(urlHash);
let id = urlHash.replace(/^#/, '');
diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js
index 43e04a814c5..6847b8900d2 100644
--- a/app/assets/javascripts/diffs/utils/merge_request.js
+++ b/app/assets/javascripts/diffs/utils/merge_request.js
@@ -1,3 +1,5 @@
+import { ZERO_CHANGES_ALT_DISPLAY } from '../constants';
+
const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i;
function getVersionInfo({ endpoint } = {}) {
@@ -13,6 +15,17 @@ function getVersionInfo({ endpoint } = {}) {
};
}
+export function updateChangesTabCount({
+ count,
+ badge = document.querySelector('.js-diffs-tab .gl-badge'),
+} = {}) {
+ if (badge) {
+ // The purpose of this function is to assign to this parameter
+ /* eslint-disable-next-line no-param-reassign */
+ badge.textContent = count || ZERO_CHANGES_ALT_DISPLAY;
+ }
+}
+
export function getDerivedMergeRequestInformation({ endpoint } = {}) {
let mrPath;
let userOrGroup;
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
index 67b909d37c3..0afee7bebe0 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -52,7 +52,7 @@ export default {
<section
v-if="isVisible"
id="se-toolbar"
- class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-align-items-center"
+ class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center"
>
<gl-button-group v-if="hasGroupItems($options.groups.file)">
<source-editor-toolbar-button
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
index f8ff533f53f..9ec1a97ba1a 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -37,8 +37,6 @@ const setupDomElement = ({ injectToEl = null } = {}) => {
return previewEl;
};
-let dimResize = false;
-
export class EditorMarkdownPreviewExtension {
static get extensionName() {
return 'EditorMarkdownPreview';
@@ -53,7 +51,6 @@ export class EditorMarkdownPreviewExtension {
},
shown: false,
modelChangeListener: undefined,
- layoutChangeListener: undefined,
path: setupOptions.previewMarkdownPath,
actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true),
eventEmitter: new Emitter(),
@@ -65,13 +62,17 @@ export class EditorMarkdownPreviewExtension {
this.setupToolbar(instance);
}
- this.preview.layoutChangeListener = instance.onDidLayoutChange(() => {
- if (instance.markdownPreview?.shown && !dimResize) {
- const { width } = instance.getLayoutInfo();
- const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ const debouncedResizeHandler = debounce((entries) => {
+ for (const entry of entries) {
+ const { width: newInstanceWidth } = entry.contentRect;
+ if (instance.markdownPreview?.shown) {
+ const newWidth = newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ }
}
- });
+ }, 50);
+
+ this.resizeObserver = new ResizeObserver(debouncedResizeHandler);
this.preview.eventEmitter.event(this.togglePreview.bind(this, instance));
}
@@ -85,9 +86,7 @@ export class EditorMarkdownPreviewExtension {
}
cleanup(instance) {
- if (this.preview.layoutChangeListener) {
- this.preview.layoutChangeListener.dispose();
- }
+ this.resizeObserver.disconnect();
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
@@ -102,11 +101,7 @@ export class EditorMarkdownPreviewExtension {
static resizePreviewLayout(instance, width) {
const { height } = instance.getLayoutInfo();
- dimResize = true;
instance.layout({ width, height });
- window.requestAnimationFrame(() => {
- dimResize = false;
- });
}
setupToolbar(instance) {
@@ -130,9 +125,16 @@ export class EditorMarkdownPreviewExtension {
togglePreviewLayout(instance) {
const { width } = instance.getLayoutInfo();
- const newWidth = this.preview.shown
- ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
- : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ let newWidth;
+ if (this.preview.shown) {
+ // This means the preview is to be closed at the next step
+ newWidth = width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.disconnect();
+ } else {
+ // The preview is hidden, but is in the process to be opened
+ newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.observe(instance.getContainerDomNode());
+ }
EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index a5080332b78..44944a4a205 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -1894,6 +1894,10 @@
}
},
"additionalProperties": false
+ },
+ "publish": {
+ "description": "A path to a directory that contains the files to be published with Pages",
+ "type": "string"
}
},
"oneOf": [
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index b9392fabcbd..4484bc03737 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -232,8 +232,8 @@ export function emojiImageTag(name, src) {
title: `:${name}:`,
alt: `:${name}:`,
src,
- width: '20',
- height: '20',
+ width: '16',
+ height: '16',
align: 'absmiddle',
});
diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js
index 308077f98b1..6e88a998096 100644
--- a/app/assets/javascripts/entrypoints/super_sidebar.js
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -1,5 +1,6 @@
import '~/webpack';
import '~/commons';
-import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle';
+import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle';
initSuperSidebar();
+initSuperSidebarToggle();
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 53a93bbce30..9db3011ba5d 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -75,7 +75,12 @@ export default {
if (this.hasMultipleCommits) {
if (this.graphql) {
const { lastDeployment } = this.environment;
- return this.commitData(lastDeployment, 'commitPath');
+ return (
+ // data shape comming from REST and GraphQL is unfortunately different
+ // once we fully migrate to GraphQL it could be streamlined
+ this.commitData(lastDeployment, 'commitPath') ||
+ this.commitData(lastDeployment, 'webUrl')
+ );
}
const { last_deployment } = this.environment;
@@ -135,7 +140,6 @@ export default {
csrf,
cancelProps: {
text: __('Cancel'),
- attributes: { variant: 'danger' },
},
docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
@@ -157,7 +161,7 @@ export default {
}}</gl-link>
</template>
<template #docs="{ content }">
- <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="$options.docsPath" 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 31bc462f0b9..b2843b79ba6 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -158,7 +158,7 @@ export default {
>{{ instanceTitle }} ({{ instanceCount }})</span
>
<span ref="legend-icon" data-testid="legend-tooltip-target">
- <gl-icon class="gl-text-blue-500 gl-ml-2" name="question" />
+ <gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" />
</span>
<gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body">
<div class="deploy-board-legend gl-display-flex gl-flex-direction-column">
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 74eef50ebaf..d49598d2f21 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { formatTime } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
@@ -7,12 +7,9 @@ import eventHub from '../event_hub';
import actionMutation from '../graphql/mutations/action.mutation.graphql';
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
GlIcon,
},
props: {
@@ -36,6 +33,16 @@ export default {
title() {
return __('Deploy to...');
},
+ actionItems() {
+ return this.actions.map((actionItem) => ({
+ text: actionItem.name,
+ action: () => this.onClickAction(actionItem),
+ extraAttrs: {
+ disabled: this.isActionDisabled(actionItem),
+ },
+ ...actionItem,
+ }));
+ },
},
methods: {
async onClickAction(action) {
@@ -48,7 +55,6 @@ export default {
);
const confirmed = await confirmAction(confirmationMessage);
-
if (!confirmed) {
return;
}
@@ -80,30 +86,31 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
+ <gl-disclosure-dropdown
:text="title"
:title="title"
:loading="isLoading"
:aria-label="title"
+ :items="actionItems"
icon="play"
text-sr-only
right
data-container="body"
data-testid="environment-actions-button"
>
- <gl-dropdown-item
- v-for="(action, i) in actions"
- :key="i"
- :disabled="isActionDisabled(action)"
+ <gl-disclosure-dropdown-item
+ v-for="item in actionItems"
+ :key="item.name"
+ :item="item"
data-testid="manual-action-link"
- @click="onClickAction(action)"
>
- <span class="gl-flex-grow-1">{{ action.name }}</span>
- <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right">
- <gl-icon name="clock" />
- {{ remainingTime(action) }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item>
+ <span class="gl-flex-grow-1">{{ item.text }}</span>
+ <span v-if="item.scheduledAt" class="gl-text-gray-500 float-right">
+ <gl-icon name="clock" />
+ {{ remainingTime(item) }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
index cfb18cc4f82..736eaa7062d 100644
--- a/app/assets/javascripts/environments/components/kubernetes_overview.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -1,14 +1,20 @@
<script>
-import { GlCollapse, GlButton } from '@gitlab/ui';
+import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import KubernetesAgentInfo from './kubernetes_agent_info.vue';
+import KubernetesPods from './kubernetes_pods.vue';
export default {
components: {
GlCollapse,
GlButton,
+ GlAlert,
KubernetesAgentInfo,
+ KubernetesPods,
},
+ inject: ['kasTunnelUrl'],
props: {
agentName: {
required: true,
@@ -22,10 +28,16 @@ export default {
required: true,
type: String,
},
+ namespace: {
+ required: false,
+ type: String,
+ default: '',
+ },
},
data() {
return {
isVisible: false,
+ error: '',
};
},
computed: {
@@ -35,11 +47,26 @@ export default {
label() {
return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand;
},
+ gitlabAgentId() {
+ const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId;
+ return id.toString();
+ },
+ k8sAccessConfiguration() {
+ return {
+ basePath: this.kasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers },
+ },
+ };
+ },
},
methods: {
toggleCollapse() {
this.isVisible = !this.isVisible;
},
+ onClusterError(message) {
+ this.error = message;
+ },
},
i18n: {
collapse: __('Collapse'),
@@ -66,7 +93,17 @@ export default {
:agent-name="agentName"
:agent-id="agentId"
:agent-project-path="agentProjectPath"
+ class="gl-mb-5" />
+
+ <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
+ {{ error }}
+ </gl-alert>
+
+ <kubernetes-pods
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
class="gl-mb-5"
+ @cluster-error="onClusterError"
/></template>
</gl-collapse>
</div>
diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue
new file mode 100644
index 00000000000..a153331ee58
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { s__ } from '~/locale';
+import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlSingleStat,
+ },
+ apollo: {
+ k8sPods: {
+ query: k8sPodsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ update(data) {
+ return data?.k8sPods || [];
+ },
+ error(error) {
+ this.error = error;
+ this.$emit('cluster-error', this.error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ error: '',
+ };
+ },
+
+ computed: {
+ podStats() {
+ if (!this.k8sPods) return null;
+
+ return [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Running'),
+ title: this.$options.i18n.runningPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Pending'),
+ title: this.$options.i18n.pendingPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Succeeded'),
+ title: this.$options.i18n.succeededPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Failed'),
+ title: this.$options.i18n.failedPods,
+ },
+ ];
+ },
+ loading() {
+ return this.$apollo.queries.k8sPods.loading;
+ },
+ },
+ methods: {
+ getPodsByPhase(phase) {
+ const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase);
+ return filteredPods.length;
+ },
+ },
+ i18n: {
+ podsTitle: s__('Environment|Pods'),
+ runningPods: s__('Environment|Running'),
+ pendingPods: s__('Environment|Pending'),
+ succeededPods: s__('Environment|Succeeded'),
+ failedPods: s__('Environment|Failed'),
+ },
+};
+</script>
+<template>
+ <div>
+ <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p>
+
+ <gl-loading-icon v-if="loading" />
+
+ <div
+ v-else-if="podStats && !error"
+ class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3"
+ >
+ <gl-single-stat
+ v-for="(stat, index) in podStats"
+ :key="index"
+ class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3"
+ :value="stat.value"
+ :title="stat.title"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 2ec6e12b8b3..ee197bbcd45 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -148,6 +148,9 @@ export default {
return now < autoStopDate;
},
+ upcomingDeploymentIid() {
+ return this.environment.upcomingDeployment?.iid.toString() || '';
+ },
autoStopPath() {
return this.environment?.cancelAutoStopPath ?? '';
},
@@ -173,7 +176,8 @@ export default {
return this.glFeatures?.kasUserAccessProject;
},
hasRequiredAgentData() {
- return this.agent.project && this.agent.id && this.agent.name;
+ const { project, id, name } = this.agent || {};
+ return project && id && name;
},
showKubernetesOverview() {
return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
@@ -223,7 +227,7 @@ export default {
:icon="icon"
:aria-label="label"
size="small"
- category="tertiary"
+ category="secondary"
@click="toggleCollapse"
/>
<gl-link
@@ -270,7 +274,6 @@ export default {
<stop-component
v-if="canStop"
:environment="environment"
- class="gl-z-index-2"
data-track-action="click_button"
data-track-label="environment_stop"
graphql
@@ -351,7 +354,11 @@ export default {
class="gl-pl-4"
>
<template #approval>
- <environment-approval :environment="environment" @change="$emit('change')" />
+ <environment-approval
+ :deployment-iid="upcomingDeploymentIid"
+ :environment="environment"
+ @change="$emit('change')"
+ />
</template>
</deployment>
</div>
@@ -368,6 +375,7 @@ export default {
:agent-project-path="agent.project"
:agent-name="agent.name"
:agent-id="agent.id"
+ :namespace="agent.kubernetesNamespace"
/>
</div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
index 77d9311743c..92a0b0e550e 100644
--- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -1,9 +1,22 @@
<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { translations } from '~/environments/environment_details/constants';
import ActionsComponent from '~/environments/components/environment_actions.vue';
+import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql';
+
+const EnvironmentApprovalComponent = import(
+ 'ee_component/environments/components/environment_approval.vue'
+);
export default {
components: {
+ GlButton,
ActionsComponent,
+ EnvironmentApproval: () => EnvironmentApprovalComponent,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
props: {
actions: {
@@ -18,14 +31,92 @@ export default {
type: Array,
required: true,
},
+ rollback: {
+ // rollback shape:
+ /*
+ {
+ id: string,
+ name: string,
+ lastDeployment: {
+ commit: Commit,
+ isLast: boolean,
+ },
+ retryUrl: url,
+ };
+ */
+ type: Object,
+ required: false,
+ default: null,
+ },
+ // approvalEnvironment shape:
+ /* {
+ isApprovalActionAvailable: boolean,
+ deploymentIid: string,
+ environment: {
+ name: string,
+ tier: string,
+ requiredApprovalCount: number,
+ },
+ */
+ approvalEnvironment: {
+ type: Object,
+ required: false,
+ default: () => ({
+ isApprovalActionAvailable: false,
+ }),
+ },
},
computed: {
+ isRollbackAvailable() {
+ return Boolean(this.rollback?.lastDeployment);
+ },
+ rollbackIcon() {
+ return this.rollback.lastDeployment.isLast ? 'repeat' : 'redo';
+ },
isActionsShown() {
return this.actions.length > 0;
},
+ deploymentIid() {
+ return this.approvalEnvironment.deploymentIid;
+ },
+ environment() {
+ return this.approvalEnvironment.environment;
+ },
+ rollbackButtonTitle() {
+ return this.rollback.lastDeployment?.isLast
+ ? translations.redeployButtonTitle
+ : translations.rollbackButtonTitle;
+ },
+ },
+ methods: {
+ onRollbackClick() {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToRollback,
+ variables: {
+ environment: this.rollback,
+ },
+ });
+ },
},
};
</script>
<template>
- <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <div>
+ <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <gl-button
+ v-if="isRollbackAvailable"
+ v-gl-modal.confirm-rollback-modal
+ v-gl-tooltip
+ data-testid="rollback-button"
+ :title="rollbackButtonTitle"
+ :icon="rollbackIcon"
+ @click="onRollbackClick"
+ />
+ <environment-approval
+ v-if="approvalEnvironment.isApprovalActionAvailable"
+ :environment="environment"
+ :deployment-iid="deploymentIid"
+ :show-text="false"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js
index 3b33d6a676e..07579092e23 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -30,7 +30,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'job',
label: __('Job'),
- columnClass: 'gl-w-20p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -48,7 +48,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'actions',
label: __('Actions'),
- columnClass: 'gl-w-10p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
},
];
@@ -61,6 +61,8 @@ export const translations = {
),
nextPageButtonLabel: __('Next'),
previousPageButtonLabel: __('Prev'),
+ redeployButtonTitle: s__('Environments|Re-deploy to environment'),
+ rollbackButtonTitle: s__('Environments|Rollback environment'),
};
export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] };
diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue
index 10f8c06e581..128b1aae4d8 100644
--- a/app/assets/javascripts/environments/environment_details/deployments_table.vue
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -54,7 +54,11 @@ export default {
<time-ago-tooltip :time="item.deployed" />
</template>
<template #cell(actions)="{ item }">
- <deployment-actions :actions="item.actions" />
+ <deployment-actions
+ :actions="item.actions"
+ :rollback="item.rollback"
+ :approval-environment="item.deploymentApproval"
+ />
</template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index f4657c5100a..f91e68e793f 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,7 +1,9 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { logError } from '~/lib/logger';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql';
+import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper';
import EmptyState from './empty_state.vue';
import DeploymentsTable from './deployments_table.vue';
@@ -10,6 +12,7 @@ import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants';
export default {
components: {
+ ConfirmRollbackModal,
Pagination,
DeploymentsTable,
EmptyState,
@@ -49,10 +52,14 @@ export default {
};
},
},
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
+ },
},
data() {
return {
project: {},
+ environmentToRollback: {},
isInitialPageDataReceived: false,
isPrefetchingPages: false,
};
@@ -143,5 +150,6 @@ export default {
<pagination :page-info="pageInfo" :disabled="isPaginationDisabled" />
</div>
<empty-state v-if="!isDeploymentTableShown && !isLoading" />
+ <confirm-rollback-modal :environment="environmentToRollback" graphql />
</div>
</template>
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 26514b59995..0482741979b 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
+import k8sPodsQuery from './queries/k8s_pods.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: k8sPodsQuery,
+ data: {
+ status: {
+ phase: '',
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
new file mode 100644
index 00000000000..e799623f9bb
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
@@ -0,0 +1,6 @@
+fragment DeploymentJob on CiJob {
+ name
+ id
+ webPath
+ playable
+}
diff --git a/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
new file mode 100644
index 00000000000..1ff68c56362
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
@@ -0,0 +1,3 @@
+fragment ProtectedEnvironment on Environment {
+ id
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
index 0182b3a7234..65d36242afe 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
@@ -1,3 +1,7 @@
+#import "ee_else_ce/environments/graphql/fragments/environment_protected_data.fragment.graphql"
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/environments/graphql/fragments/deployment_job.fragment.graphql"
+
query getEnvironmentDetails(
$projectFullPath: ID!
$environmentName: String
@@ -11,8 +15,9 @@ query getEnvironmentDetails(
name
fullPath
environment(name: $environmentName) {
- id
+ ...ProtectedEnvironment
name
+ tier
lastDeployment(status: SUCCESS) {
id
job {
@@ -40,19 +45,13 @@ query getEnvironmentDetails(
ref
tag
job {
- name
- id
- webPath
- playable
+ ...DeploymentJob
deploymentPipeline: pipeline {
id
jobs(whenExecuted: ["manual"], retried: false) {
nodes {
- id
- name
- playable
+ ...DeploymentJob
scheduledAt
- webPath
}
}
}
@@ -66,17 +65,11 @@ query getEnvironmentDetails(
authorName
authorEmail
author {
- id
- name
- avatarUrl
- webUrl
+ ...User
}
}
triggerer {
- id
- webUrl
- name
- avatarUrl
+ ...User
}
createdAt
finishedAt
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
new file mode 100644
index 00000000000..818bca24d51
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
@@ -0,0 +1,7 @@
+query getK8sPods($configuration: Object, $namespace: String) {
+ k8sPods(configuration: $configuration, namespace: $namespace) @client {
+ status {
+ phase
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index e21670870b8..39e05825cf0 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,3 +1,4 @@
+import { CoreV1Api, Configuration } from '@gitlab/cluster-client';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import {
@@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({
isLastDeployment(_, { environment }) {
return environment?.lastDeployment?.isLast;
},
+ k8sPods(_, { configuration, namespace }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const podsApi = namespace
+ ? coreV1Api.listCoreV1NamespacedPod(namespace)
+ : coreV1Api.listCoreV1PodForAllNamespaces();
+
+ return podsApi
+ .then((res) => res?.data?.items || [])
+ .catch((err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+ });
+ },
},
Mutation: {
stopEnvironment(_, { environment }, { client }) {
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index b4d1f7326f6..7c102fd04d8 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -62,6 +62,19 @@ type LocalPageInfo {
previousPage: Int!
}
+type k8sPodStatus {
+ phase: String
+}
+
+type LocalK8sPods {
+ status: k8sPodStatus
+}
+
+input LocalConfiguration {
+ basePath: String
+ baseOptions: JSON
+}
+
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
@@ -71,6 +84,7 @@ extend type Query {
environmentToStop: LocalEnvironment
isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean
isLastDeployment(environment: LocalEnvironmentInput): Boolean
+ k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods]
}
extend type Mutation {
diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
index 9802dcbcf78..92efd46df64 100644
--- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
+++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
@@ -62,6 +62,60 @@ export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName)
);
};
+export const getRollbackActionFromDeploymentNode = (deploymentNode, environment) => {
+ const { job, id } = deploymentNode;
+
+ if (!job) {
+ return null;
+ }
+ const isLastDeployment = id === environment.lastDeployment?.id;
+ const { webPath } = job;
+ return {
+ id,
+ name: environment.name,
+ lastDeployment: {
+ commit: deploymentNode.commit,
+ isLast: isLastDeployment,
+ },
+ retryUrl: `${webPath}/retry`,
+ };
+};
+
+const getDeploymentApprovalFromDeploymentNode = (deploymentNode, environment) => {
+ if (!environment.protectedEnvironments || environment.protectedEnvironments.nodes.length === 0) {
+ return {
+ isApprovalActionAvailable: false,
+ };
+ }
+
+ const protectedEnvironmentInfo = environment.protectedEnvironments.nodes[0];
+
+ const hasApprovalRules = protectedEnvironmentInfo.approvalRules.nodes?.length > 0;
+ const hasRequiredApprovals = protectedEnvironmentInfo.requiredApprovalCount > 0;
+
+ const isApprovalActionAvailable = hasRequiredApprovals || hasApprovalRules;
+ const requiredMultipleApprovalRulesApprovals = protectedEnvironmentInfo.approvalRules.nodes.reduce(
+ (requiredApprovals, rule) => {
+ return requiredApprovals + rule.requiredApprovals;
+ },
+ 0,
+ );
+
+ const requiredApprovalCount = hasRequiredApprovals
+ ? protectedEnvironmentInfo.requiredApprovalCount
+ : requiredMultipleApprovalRulesApprovals;
+
+ return {
+ isApprovalActionAvailable,
+ deploymentIid: deploymentNode.iid,
+ environment: {
+ name: environment.name,
+ tier: environment.tier,
+ requiredApprovalCount,
+ },
+ };
+};
+
/**
* This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table
* @param {Object} deploymentNode
@@ -82,5 +136,7 @@ export const convertToDeploymentTableRow = (deploymentNode, environment) => {
created: deploymentNode.createdAt || '',
deployed: deploymentNode.finishedAt || '',
actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name),
+ rollback: getRollbackActionFromDeploymentNode(deploymentNode, environment),
+ deploymentApproval: getDeploymentApprovalFromDeploymentNode(deploymentNode, environment),
};
};
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index d9a523fd806..3f746bc5383 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { parseBoolean } from '../lib/utils/common_utils';
import { apolloProvider } from './graphql/client';
import EnvironmentsApp from './components/environments_app.vue';
@@ -16,6 +17,7 @@ export default (el) => {
projectPath,
defaultBranchName,
projectId,
+ kasTunnelUrl,
} = el.dataset;
return new Vue({
@@ -28,6 +30,7 @@ export default (el) => {
newEnvironmentPath,
helpPagePath,
projectId,
+ kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl),
canCreateEnvironment: parseBoolean(canCreateEnvironment),
},
render(h) {
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index afce2b7f237..5e812c85c96 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -92,7 +92,9 @@ export const initPage = async () => {
el,
apolloProvider: apolloProvider(),
router,
- provide: {},
+ provide: {
+ projectPath: dataSet.projectFullPath,
+ },
render(createElement) {
return createElement('router-view');
},
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
index aeed5450022..afb91d3db51 100644
--- a/app/assets/javascripts/error_tracking/utils.js
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -1,13 +1,13 @@
-/* eslint-disable @gitlab/require-i18n-strings */
+const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings
/**
* Tracks snowplow event when User clicks on error link to Sentry
* @param {String} externalUrl that will be send as a property for the event
*/
export const trackClickErrorLinkToSentryOptions = (url) => ({
- category: 'Error Tracking',
+ category,
action: 'click_error_link_to_sentry',
- label: 'Error Link',
+ label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings
property: url,
});
@@ -15,7 +15,7 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({
* Tracks snowplow event when user views error list
*/
export const trackErrorListViewsOptions = {
- category: 'Error Tracking',
+ category,
action: 'view_errors_list',
};
@@ -23,7 +23,7 @@ export const trackErrorListViewsOptions = {
* Tracks snowplow event when user views error details
*/
export const trackErrorDetailsViewsOptions = {
- category: 'Error Tracking',
+ category,
action: 'view_error_details',
};
@@ -31,6 +31,6 @@ export const trackErrorDetailsViewsOptions = {
* Tracks snowplow event when error status is updated
*/
export const trackErrorStatusUpdateOptions = (status) => ({
- category: 'Error Tracking',
+ category,
action: `update_${status}_status`,
});
diff --git a/app/assets/javascripts/featurable/constants.js b/app/assets/javascripts/featurable/constants.js
new file mode 100644
index 00000000000..23f1c5e415d
--- /dev/null
+++ b/app/assets/javascripts/featurable/constants.js
@@ -0,0 +1,6 @@
+// Matches `app/models/concerns/featurable.rb`
+
+export const FEATURABLE_DISABLED = 'disabled';
+export const FEATURABLE_PRIVATE = 'private';
+export const FEATURABLE_ENABLED = 'enabled';
+export const FEATURABLE_PUBLIC = 'public';
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 93510870915..34e0b94af3b 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -187,7 +187,7 @@ export default {
data-testid="feature-flags-tab-title"
class="page-title gl-font-size-h-display gl-my-0"
>
- {{ s__('FeatureFlags|Feature Flags') }}
+ {{ s__('FeatureFlags|Feature flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 286b214b511..dee1d239c9f 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -108,7 +108,7 @@ export default {
{{ s__('FeatureFlags|Status') }}
</div>
<div class="table-section section-20" role="columnheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-section section-40" role="columnheader">
{{ s__('FeatureFlags|Environment Specs') }}
@@ -148,7 +148,7 @@ export default {
<div class="table-section section-20" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-mobile-content d-flex flex-column js-feature-flag-title">
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 1d7a79f926a..6c8a2d90209 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -138,7 +138,7 @@ export default {
<template #description>
{{ $options.i18n.strategyTypeDescription }}
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</template>
<gl-form-select
@@ -202,7 +202,7 @@ export default {
{{ $options.i18n.environmentsSelectDescription }}
</span>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</div>
</div>
diff --git a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
index 148d9a35b81..c2c46e4265a 100644
--- a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
const InputSetter = {
init(hook) {
this.hook = hook;
@@ -33,11 +31,15 @@ const InputSetter = {
setInput(config, selectedItem) {
const input = config.input || this.hook.trigger;
const newValue = selectedItem.getAttribute(config.valueAttribute);
- const inputAttribute = config.inputAttribute;
-
- if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue);
- if (input.tagName === 'INPUT') return (input.value = newValue);
- return (input.textContent = newValue);
+ const { inputAttribute } = config;
+
+ if (input.hasAttribute(inputAttribute)) {
+ input.setAttribute(inputAttribute, newValue);
+ } else if (input.tagName === 'INPUT') {
+ input.value = newValue;
+ } else {
+ input.textContent = newValue;
+ }
},
destroy() {
diff --git a/app/assets/javascripts/filtered_search/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js
index d7f49bf19d8..3d3470a16d0 100644
--- a/app/assets/javascripts/filtered_search/droplab/utils.js
+++ b/app/assets/javascripts/filtered_search/droplab/utils.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
import { template as _template } from 'lodash';
import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants';
@@ -26,7 +24,7 @@ const utils = {
closest(thisTag, stopTag) {
while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') {
- thisTag = thisTag.parentNode;
+ thisTag = thisTag.parentNode; // eslint-disable-line no-param-reassign
}
return thisTag;
},
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index d865354881a..684375177bb 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -2,7 +2,13 @@ import { last } from 'lodash';
import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { createAlert } from '~/alert';
-import { WORKSPACE_PROJECT } from '~/issues/constants';
+import {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_MERGED,
+ STATUS_OPEN,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -43,7 +49,7 @@ export default class FilteredSearchManager {
this.isGroupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.useDefaultState = useDefaultState;
- this.states = ['opened', 'closed', 'merged', 'all'];
+ this.states = [STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED, STATUS_ALL];
this.page = page;
this.container = FilteredSearchContainer.container;
@@ -743,7 +749,7 @@ export default class FilteredSearchManager {
const { tokens, searchToken } = this.getSearchTokens();
let currentState = state || getParameterByName('state');
if (!currentState && this.useDefaultState) {
- currentState = 'opened';
+ currentState = STATUS_OPEN;
}
if (this.states.includes(currentState)) {
paths.push(`state=${currentState}`);
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 81da8409873..b778e05c7b1 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -343,7 +343,9 @@ class GfmAutoComplete {
icon,
availabilityStatus:
availability && isUserBusy(availability)
- ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
+ ? `<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2"> ${s__(
+ 'UserProfile|Busy',
+ )}</span>`
: '',
});
}
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 60f1b7f5aa4..09ee7de3b6e 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -54,6 +54,7 @@ import { __ } from '~/locale';
const errorMessageClass = 'gl-field-error';
const inputErrorClass = 'gl-field-error-outline';
+const validInputHintClass = '.gl-field-hint-valid';
const errorAnchorSelector = '.gl-field-error-anchor';
const ignoreInputSelector = '.gl-field-error-ignore';
@@ -151,6 +152,7 @@ export default class GlFieldError {
renderInvalid() {
this.inputElement.addClass(inputErrorClass);
this.scopedSiblings.addClass('hidden');
+ this.inputElement.parents('.form-group').find(validInputHintClass).addClass('hidden');
return this.fieldErrorElement.removeClass('hidden');
}
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 77fca45c949..65aa38cfb99 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -22,6 +22,7 @@ export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package';
export const TYPENAME_PROJECT = 'Project';
export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPENAME_SITE_PROFILE = 'DastSiteProfile';
+export const TYPENAME_TODO = 'Todo';
export const TYPENAME_USER = 'User';
export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner';
export const TYPENAME_VULNERABILITY = 'Vulnerability';
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 316bc746051..740eb722629 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -8,6 +8,7 @@ import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
import { findHierarchyWidgetChildren } from '~/work_items/utils';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export const config = {
typeDefs,
@@ -81,6 +82,14 @@ export const config = {
});
},
},
+ userPermissions: {
+ read(permission = {}) {
+ return {
+ ...permission,
+ setWorkItemMetadata: false,
+ };
+ },
+ },
},
},
MemberInterfaceConnection: {
@@ -126,6 +135,33 @@ export const config = {
};
},
},
+ Group: {
+ fields: {
+ projects: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ descendantGroups: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ },
+ },
+ ProjectConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ GroupConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ Board: {
+ fields: {
+ epics: {
+ keyArgs: ['boardId'],
+ },
+ },
+ },
BoardEpicConnection: {
merge(existing = { nodes: [] }, incoming, { args }) {
if (!args.after) {
@@ -174,6 +210,13 @@ export const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ setActiveBoardItem(_, { boardItem }, { cache }) {
+ cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: { activeBoardItem: boardItem },
+ });
+ return boardItem;
+ },
},
};
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 22629dfb7d8..f7d1efc4d1f 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -21,7 +21,8 @@
"Epic",
"EpicIssue",
"Issue",
- "MergeRequest"
+ "MergeRequest",
+ "WorkItemWidgetCurrentUserTodos"
],
"DependencyLinkMetadata": [
"NugetDependencyLinkMetadata"
@@ -145,6 +146,8 @@
],
"WorkItemWidget": [
"WorkItemWidgetAssignees",
+ "WorkItemWidgetAwardEmoji",
+ "WorkItemWidgetCurrentUserTodos",
"WorkItemWidgetDescription",
"WorkItemWidgetHealthStatus",
"WorkItemWidgetHierarchy",
diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
index d8760f147e1..28405d9dc9b 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
+++ b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
@@ -10,5 +10,9 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) {
}
}
}
+ ... on Issue {
+ id
+ dueDate
+ }
}
}
diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
index 535758750f9..cba13c11c5d 100644
--- a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No archived projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
index 7223321bf3e..7c691b56a43 100644
--- a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No shared projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
index 955cb1ca63e..0bd95d59022 100644
--- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
@@ -43,6 +43,7 @@ export default {
'newProjectPath',
'newSubgroupIllustration',
'newProjectIllustration',
+ 'emptyProjectsIllustration',
'emptySubgroupIllustration',
'canCreateSubgroups',
'canCreateProjects',
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d9781ef9c84..8d202194de7 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+import {
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import { ITEM_TYPE } from '../constants';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index a4c163b0a81..5674e28f5da 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -2,12 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import {
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../constants';
+import { ITEM_TYPE } from '../constants';
import ItemStatsValue from './item_stats_value.vue';
export default {
@@ -24,15 +19,6 @@ export default {
},
},
computed: {
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.item.visibility];
- },
- visibilityTooltip() {
- if (this.item.type === ITEM_TYPE.GROUP) {
- return GROUP_VISIBILITY_TYPE[this.item.visibility];
- }
- return PROJECT_VISIBILITY_TYPE[this.item.visibility];
- },
isProject() {
return this.item.type === ITEM_TYPE.PROJECT;
},
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6f5b03788a8..a5854632040 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,9 +1,4 @@
import { __, s__ } from '~/locale';
-import {
- VISIBILITY_LEVEL_PRIVATE_STRING,
- VISIBILITY_LEVEL_INTERNAL_STRING,
- VISIBILITY_LEVEL_PUBLIC_STRING,
-} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -30,36 +25,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The group and any public projects can be viewed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - The group and its projects can only be viewed by members.',
- ),
-};
-
-export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The project can be accessed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The project can be accessed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- '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 = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
-};
-
export const OVERVIEW_TABS_SORTING_ITEMS = [
{
label: __('Name'),
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index c3bf3f28509..f6711bde7d0 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -51,6 +51,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -63,6 +64,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
diff --git a/app/assets/javascripts/groups/init_group_readme.js b/app/assets/javascripts/groups/init_group_readme.js
new file mode 100644
index 00000000000..7cde64fed4d
--- /dev/null
+++ b/app/assets/javascripts/groups/init_group_readme.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import apolloProvider from '~/repository/graphql';
+import FilePreview from '~/repository/components/preview/index.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupReadme = () => {
+ const el = document.getElementById('js-group-readme');
+
+ if (!el) return false;
+
+ const { webPath, name } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(FilePreview, {
+ props: {
+ blob: { webPath, name },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index 664d07ca13d..4064520d1ca 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -44,6 +44,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -62,6 +63,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
diff --git a/app/assets/javascripts/groups/settings/components/group_settings_readme.vue b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
new file mode 100644
index 00000000000..123c7fc58f5
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
@@ -0,0 +1,147 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { createProject } from '~/rest_api';
+import { createAlert } from '~/alert';
+import { openWebIDE } from '~/lib/utils/web_ide_navigator';
+import { README_MODAL_ID, GITLAB_README_PROJECT, README_FILE } from '../constants';
+
+export default {
+ name: 'GroupSettingsReadme',
+ i18n: {
+ readme: __('README'),
+ addReadme: __('Add README'),
+ cancel: __('Cancel'),
+ createProjectAndReadme: s__('Groups|Create and add README'),
+ creatingReadme: s__('Groups|Creating README'),
+ existingProjectNewReadme: s__('Groups|This will create a README.md for project %{path}.'),
+ newProjectAndReadme: s__('Groups|This will create a project %{path} and add a README.md.'),
+ errorCreatingProject: s__('Groups|There was an error creating the Group README.'),
+ },
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ groupReadmePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ readmeProjectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ groupPath: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ creatingReadme: false,
+ };
+ },
+ computed: {
+ hasReadme() {
+ return this.groupReadmePath.length > 0;
+ },
+ hasReadmeProject() {
+ return this.readmeProjectPath.length > 0;
+ },
+ pathToReadmeProject() {
+ return this.hasReadmeProject
+ ? this.readmeProjectPath
+ : `${this.groupPath}/${GITLAB_README_PROJECT}`;
+ },
+ modalBody() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.existingProjectNewReadme
+ : this.$options.i18n.newProjectAndReadme;
+ },
+ modalSubmitButtonText() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.addReadme
+ : this.$options.i18n.createProjectAndReadme;
+ },
+ },
+ methods: {
+ hideModal() {
+ this.$refs.modal.hide();
+ },
+ createReadme() {
+ if (this.hasReadmeProject) {
+ openWebIDE(this.readmeProjectPath, README_FILE);
+ } else {
+ this.createProjectWithReadme();
+ }
+ },
+ createProjectWithReadme() {
+ this.creatingReadme = true;
+
+ const projectData = {
+ name: GITLAB_README_PROJECT,
+ namespace_id: this.groupId,
+ };
+
+ createProject(projectData)
+ .then(({ path_with_namespace: pathWithNamespace }) => {
+ openWebIDE(pathWithNamespace, README_FILE);
+ })
+ .catch(() => {
+ this.hideModal();
+ this.creatingReadme = false;
+ createAlert({ message: this.$options.i18n.errorCreatingProject });
+ });
+ },
+ },
+ README_MODAL_ID,
+};
+</script>
+
+<template>
+ <div>
+ <gl-button v-if="hasReadme" icon="doc-text" :href="groupReadmePath">{{
+ $options.i18n.readme
+ }}</gl-button>
+ <gl-button
+ v-else
+ v-gl-modal="$options.README_MODAL_ID"
+ variant="dashed"
+ icon="file-addition"
+ data-testid="group-settings-add-readme-button"
+ >{{ $options.i18n.addReadme }}</gl-button
+ >
+ <gl-modal ref="modal" :modal-id="$options.README_MODAL_ID" :title="$options.i18n.addReadme">
+ <div data-testid="group-settings-modal-readme-body">
+ <gl-sprintf :message="modalBody">
+ <template #path>
+ <code>{{ pathToReadmeProject }}</code>
+ </template>
+ </gl-sprintf>
+ </div>
+ <template #modal-footer>
+ <gl-button variant="default" @click="hideModal">{{ $options.i18n.cancel }}</gl-button>
+ <gl-button v-if="creatingReadme" variant="default" loading disabled>{{
+ $options.i18n.creatingReadme
+ }}</gl-button>
+ <gl-button
+ v-else
+ variant="confirm"
+ data-testid="group-settings-modal-create-readme-button"
+ @click="createReadme"
+ >{{ modalSubmitButtonText }}</gl-button
+ >
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
index c91c2a20529..023ddf29b36 100644
--- a/app/assets/javascripts/groups/settings/constants.js
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -1,3 +1,7 @@
export const LEVEL_TYPES = {
GROUP: 'group',
};
+
+export const README_MODAL_ID = 'add_group_readme_modal';
+export const GITLAB_README_PROJECT = 'gitlab-profile';
+export const README_FILE = 'README.md';
diff --git a/app/assets/javascripts/groups/settings/init_group_settings_readme.js b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
new file mode 100644
index 00000000000..d126228d854
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import GroupSettingsReadme from './components/group_settings_readme.vue';
+
+export const initGroupSettingsReadme = () => {
+ const el = document.getElementById('js-group-settings-readme');
+
+ if (!el) return false;
+
+ const { groupReadmePath, readmeProjectPath, groupPath, groupId } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(GroupSettingsReadme, {
+ props: {
+ groupReadmePath,
+ readmeProjectPath,
+ groupPath,
+ groupId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 9cb96283689..25a84d17379 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -37,7 +37,7 @@ export function initStatusTriggers() {
const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl);
Tracking.event(undefined, 'click_button', {
label: 'user_edit_status',
- property: buttonWithinTopNav ? 'navigation_top' : undefined,
+ property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu',
});
import(
@@ -135,6 +135,8 @@ function initNewNavToggle() {
});
}
-requestIdleCallback(initStatusTriggers);
+if (!gon?.use_new_navigation) {
+ requestIdleCallback(initStatusTriggers);
+}
requestIdleCallback(initNavUserDropdownTracking);
requestIdleCallback(initNewNavToggle);
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index c0a06706fc6..aa349186014 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,7 +1,6 @@
<script>
import {
GlSearchBoxByType,
- GlOutsideDirective as Outside,
GlIcon,
GlToken,
GlTooltipDirective,
@@ -36,6 +35,7 @@ import {
IS_SEARCHING,
IS_FOCUSED,
IS_NOT_FOCUSED,
+ DROPDOWN_CLOSE_TIMEOUT,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -53,7 +53,7 @@ export default {
SEARCH_RESULTS_SCOPE,
KBD_HELP,
},
- directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
@@ -65,7 +65,6 @@ export default {
},
data() {
return {
- showDropdown: false,
isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
@@ -91,7 +90,7 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
- if (!this.showDropdown || !this.isLoggedIn) {
+ if (!this.isFocused || !this.isLoggedIn) {
return false;
}
return this.searchOptions?.length > 0;
@@ -108,7 +107,6 @@ export default {
}
return FIRST_DROPDOWN_INDEX;
},
-
searchInputDescribeBy() {
if (this.isLoggedIn) {
return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
@@ -160,29 +158,18 @@ export default {
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
- this.showDropdown = true;
-
- // check isFocused state to avoid firing duplicate events
- if (!this.isFocused) {
- this.isFocused = true;
- this.$emit('expandSearchBar', true);
+ this.isFocused = true;
+ this.$emit('expandSearchBar');
- Tracking.event(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }
- },
- closeDropdown() {
- this.showDropdown = false;
+ Tracking.event(undefined, 'focus_input', {
+ label: 'global_search',
+ property: 'navigation_top',
+ });
},
collapseAndCloseSearchBar() {
- // we need a delay on this method
- // for the search bar not to remove
- // the clear button from dom
- // and register clicks on dropdown items
+ // without timeout dropdown closes
+ // before click event is dispatched
setTimeout(() => {
- this.showDropdown = false;
this.isFocused = false;
this.$emit('collapseSearchBar');
@@ -190,7 +177,7 @@ export default {
label: 'global_search',
property: 'navigation_top',
});
- }, 200);
+ }, DROPDOWN_CLOSE_TIMEOUT);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
@@ -226,7 +213,6 @@ export default {
<template>
<form
- v-outside="closeDropdown"
role="search"
:aria-label="$options.i18n.SEARCH_GITLAB"
class="header-search gl-relative gl-rounded-base gl-w-full"
@@ -244,12 +230,11 @@ export default {
:placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
- @focus="openDropdown"
- @click="openDropdown"
- @blur="collapseAndCloseSearchBar"
+ @focusin="openDropdown"
+ @focusout="collapseAndCloseSearchBar"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
- @keydown.esc.stop.prevent="closeDropdown"
+ @keydown.esc.stop.prevent="collapseAndCloseSearchBar"
/>
<gl-token
v-if="showScopeHelp"
@@ -301,7 +286,7 @@ export default {
:max="searchOptions.length - 1"
:min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
- @tab="closeDropdown"
+ :enable-cycle="true"
/>
<header-search-default-items
v-if="showDefaultItems"
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index b9bb4e573fd..47aeb2f9caa 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -31,3 +31,5 @@ export const IS_NOT_FOCUSED = 'is-not-focused';
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
+
+export const DROPDOWN_CLOSE_TIMEOUT = 200;
diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js
index 4e9404007ec..64502d13ee2 100644
--- a/app/assets/javascripts/header_search/init.js
+++ b/app/assets/javascripts/header_search/init.js
@@ -2,29 +2,18 @@ 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'
+ const { initHeaderSearchApp } = await import(
+ /* webpackChunkName: 'globalSearch' */ '~/header_search'
).catch((error) => Sentry.captureException(error));
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
+ // 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();
}
@@ -40,10 +29,7 @@ function initHeaderSearch() {
HEADER_INIT_EVENTS.forEach((eventType) => {
searchInputBox?.addEventListener(
eventType,
- eventHandler.bind(
- { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch },
- cleanEventListeners,
- ),
+ eventHandler.bind({ searchInputBox }, cleanEventListeners),
{ once: true },
);
});
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 92dacf8c94a..d788104edc8 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -43,6 +43,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="edit_mode_tab"
+ data-testid="edit-mode-button"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)"
@@ -60,6 +61,7 @@ export default {
:aria-label="s__('IDE|Review')"
data-container="body"
data-placement="right"
+ data-testid="review-mode-button"
type="button"
class="ide-sidebar-link js-ide-review-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
@@ -78,6 +80,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="commit_mode_tab"
+ data-testid="commit-mode-button"
type="button"
class="ide-sidebar-link js-ide-commit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 2799ea1378e..d05aa960f01 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -82,7 +82,7 @@ export default {
{{ __('Commit Message') }}
<div id="ide-commit-message-popover-container">
<span id="ide-commit-message-question" class="form-text text-muted gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="ide-commit-message-question"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index ea1dbee4669..9f83de840b9 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -69,7 +69,7 @@ export default {
>
<gl-icon name="ellipsis_v" />
</button>
- <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right">
+ <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu">
<template v-if="type === 'tree'">
<li>
<item-button
diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
index 7fca7429ad7..428cf7f55ac 100644
--- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue
+++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
@@ -82,7 +82,7 @@ export default {
<div>{{ __('Commit Message') }}</div>
<div id="commit-message-popover-container">
<span id="commit-message-question" class="gl-gray-700 gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="commit-message-question"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 967c83b320f..29c44d2f596 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -72,7 +72,6 @@ export const initLegacyWebIDE = (el, options = {}) => {
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
- learnGitlabSource: parseBoolean(el.dataset.learnGitlabSource),
});
},
beforeDestroy() {
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index 4d3cefcb107..51af73decad 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -67,6 +67,7 @@ export const initGitlabWebIDE = async (el) => {
links: {
feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE,
userPreferences: el.dataset.userPreferencesPath,
+ signIn: el.dataset.signInPath,
},
editorFont: {
srcUrl: editorFontSrcUrl,
diff --git a/app/assets/javascripts/ide/lib/languages/codeowners.js b/app/assets/javascripts/ide/lib/languages/codeowners.js
new file mode 100644
index 00000000000..e2eed713801
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/codeowners.js
@@ -0,0 +1,39 @@
+const conf = {
+ comments: {
+ lineComment: '#',
+ },
+ autoClosingPairs: [{ open: '[', close: ']' }],
+ surroundingPairs: [{ open: '[', close: ']' }],
+};
+
+const language = {
+ tokenizer: {
+ root: [
+ // comment
+ [/^#.*$/, 'comment'],
+
+ // optional approval
+ [/^\^/, 'constant.numeric'],
+
+ // number of approvers
+ [/\[\d+\]$/, 'constant.numeric'],
+
+ // section
+ [/\[(?!\d+\])[^\]]+\]/, 'namespace'],
+
+ // pattern
+ [/^\s*(\S+)/, 'regexp'],
+
+ // owner
+ [/\S*@.*$/, 'variable.value'],
+ ],
+ },
+};
+
+export default {
+ id: 'codeowners',
+ extensions: ['codeowners'],
+ aliases: ['CODEOWNERS'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
index f758cb7dd86..c2ab954eb73 100644
--- a/app/assets/javascripts/ide/lib/languages/index.js
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -1,6 +1,7 @@
import hcl from './hcl';
import vue from './vue';
+import codeowners from './codeowners';
-const languages = [vue, hcl];
+const languages = [vue, hcl, codeowners];
export default languages;
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 9f1eae03685..06751b926b5 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -1,4 +1,5 @@
import { createAlert } from '~/alert';
+import { STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants';
import service from '../../services';
@@ -16,7 +17,7 @@ export const getMergeRequestsForBranch = (
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
source_project_id: state.projects[projectId].id,
- state: 'opened',
+ state: STATUS_OPEN,
order_by: 'created_at',
per_page: 1,
})
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 572465f7587..79a8ccf2285 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,7 +1,6 @@
import { createAlert } from '~/alert';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
-import Tracking from '~/tracking';
import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
@@ -163,10 +162,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
);
}
- if (rootState.learnGitlabSource) {
- Tracking.event(undefined, 'commit', { label: 'web_ide_learn_gitlab_source' });
- }
-
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
return dispatch('updateFilesAfterCommit', {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 013a0c3ce8f..356bbf28a48 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -32,5 +32,4 @@ export default () => ({
environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
userPreferencesPath: '',
- learnGitlabSource: false,
});
diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js
new file mode 100644
index 00000000000..b9814b5ca60
--- /dev/null
+++ b/app/assets/javascripts/import/constants.js
@@ -0,0 +1,28 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, s__ } from '~/locale';
+
+const STATISTIC_ITEMS = {
+ diff_note: __('Diff notes'),
+ issue: __('Issues'),
+ issue_attachment: s__('GithubImporter|Issue links'),
+ issue_event: __('Issue events'),
+ label: __('Labels'),
+ lfs_object: __('LFS objects'),
+ merge_request_attachment: s__('GithubImporter|Merge request links'),
+ milestone: __('Milestones'),
+ note: __('Notes'),
+ note_attachment: s__('GithubImporter|Note links'),
+ protected_branch: __('Protected branches'),
+ collaborator: s__('GithubImporter|Collaborators'),
+ pull_request: s__('GithubImporter|Pull requests'),
+ pull_request_merged_by: s__('GithubImporter|PR mergers'),
+ pull_request_review: s__('GithubImporter|PR reviews'),
+ pull_request_review_request: s__('GithubImporter|PR reviews'),
+ release: __('Releases'),
+ release_attachment: s__('GithubImporter|Release links'),
+};
+
+// support both camel case and snake case versions
+Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+
+export { STATISTIC_ITEMS };
diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue
new file mode 100644
index 00000000000..86820663025
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_app.vue
@@ -0,0 +1,25 @@
+<script>
+import { s__ } from '~/locale';
+import ImportDetailsTable from './import_details_table.vue';
+
+export default {
+ components: { ImportDetailsTable },
+ props: {
+ project: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ i18n: {
+ pageTitle: s__('Import|GitHub import details'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1>{{ $options.i18n.pageTitle }}</h1>
+ <import-details-table />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue
new file mode 100644
index 00000000000..9ce58e8a9bc
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_table.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlEmptyState, GlIcon, GlLink, GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import { STATISTIC_ITEMS } from '../../constants';
+
+const DEFAULT_PAGE_SIZE = 20;
+
+export default {
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlTable,
+ PaginationBar,
+ },
+ STATISTIC_ITEMS,
+ LOCAL_STORAGE_KEY: 'gl-import-details-page-size',
+ fields: [
+ {
+ key: 'type',
+ label: __('Type'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'title',
+ label: __('Title'),
+ tdClass: 'gl-md-w-30 gl-word-break-word',
+ },
+ {
+ key: 'url',
+ label: __('URL'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'details',
+ label: __('Details'),
+ },
+ ],
+ data() {
+ return {
+ page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
+ };
+ },
+ computed: {
+ items() {
+ return [];
+ },
+
+ hasItems() {
+ return this.items.length > 0;
+ },
+
+ pageInfo() {
+ const mockPageInfo = {
+ page: this.page,
+ perPage: this.perPage,
+ totalPages: this.page,
+ total: this.items.length,
+ };
+ return mockPageInfo;
+ },
+ },
+
+ methods: {
+ setPage(page) {
+ this.page = page;
+ },
+
+ setPageSize(size) {
+ this.perPage = size;
+ this.page = 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" show-empty>
+ <template #empty>
+ <gl-empty-state :title="s__('Import|No import details')" />
+ </template>
+
+ <template #cell(type)="{ item: { type } }">
+ {{ $options.STATISTIC_ITEMS[type] }}
+ </template>
+ <template #cell(url)="{ item: { url } }">
+ <gl-link v-if="url" :href="url" target="_blank">
+ {{ url }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-table>
+ <pagination-bar
+ v-if="hasItems"
+ :page-info="pageInfo"
+ class="gl-mt-5"
+ :storage-key="$options.LOCAL_STORAGE_KEY"
+ @set-page="setPage"
+ @set-page-size="setPageSize"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/index.js b/app/assets/javascripts/import/details/index.js
new file mode 100644
index 00000000000..70850d947e2
--- /dev/null
+++ b/app/assets/javascripts/import/details/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import ImportDetailsApp from './components/import_details_app.vue';
+
+export default () => {
+ const el = document.querySelector('.js-import-details');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'ImportDetailsRoot',
+ render(createElement) {
+ return createElement(ImportDetailsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index ec2ab9d0c3d..96d07803545 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,32 +1,10 @@
<script>
-import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { STATUSES } from '../constants';
-
-const STATISTIC_ITEMS = {
- diff_note: __('Diff notes'),
- issue: __('Issues'),
- issue_attachment: s__('GithubImporter|Issue links'),
- issue_event: __('Issue events'),
- label: __('Labels'),
- lfs_object: __('LFS objects'),
- merge_request_attachment: s__('GithubImporter|Merge request links'),
- milestone: __('Milestones'),
- note: __('Notes'),
- note_attachment: s__('GithubImporter|Note links'),
- protected_branch: __('Protected branches'),
- collaborator: s__('GithubImporter|Collaborators'),
- pull_request: s__('GithubImporter|Pull requests'),
- pull_request_merged_by: s__('GithubImporter|PR mergers'),
- pull_request_review: s__('GithubImporter|PR reviews'),
- pull_request_review_request: s__('GithubImporter|PR reviews'),
- release: __('Releases'),
- release_attachment: s__('GithubImporter|Release links'),
-};
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-// support both camel case and snake case versions
-Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+import { STATISTIC_ITEMS } from '~/import/constants';
+import { STATUSES } from '../constants';
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
@@ -78,6 +56,13 @@ export default {
GlAccordionItem,
GlBadge,
GlIcon,
+ GlLink,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ detailsPath: {
+ default: undefined,
+ },
},
props: {
status: {
@@ -103,13 +88,16 @@ export default {
return this.stats && this.knownStats.length > 0;
},
+ isIncomplete() {
+ return this.status === STATUSES.FINISHED && this.stats && isIncompleteImport(this.stats);
+ },
+
mappedStatus() {
if (this.status === STATUSES.FINISHED) {
- const isIncomplete = this.stats && isIncompleteImport(this.stats);
- return isIncomplete
+ return this.isIncomplete
? {
icon: 'status-alert',
- text: __('Partial import'),
+ text: s__('Import|Partially completed'),
variant: 'warning',
}
: {
@@ -121,6 +109,10 @@ export default {
return STATUS_MAP[this.status];
},
+
+ showDetails() {
+ return Boolean(this.detailsPath) && this.glFeatures.importDetailsPage && this.isIncomplete;
+ },
},
methods: {
@@ -141,25 +133,22 @@ export default {
},
STATISTIC_ITEMS,
+ i18n: {
+ detailsLink: s__('Import|See failures'),
+ },
};
</script>
<template>
<div>
- <div class="gl-display-inline-block gl-w-13">
- <gl-badge
- :icon="mappedStatus.icon"
- :variant="mappedStatus.variant"
- size="md"
- icon-size="sm"
- class="gl-mr-2"
- >
+ <div class="gl-display-inline-block">
+ <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm">
{{ mappedStatus.text }}
</gl-badge>
</div>
<gl-accordion v-if="hasStats" :header-level="3">
<gl-accordion-item :title="__('Details')">
- <ul class="gl-p-0 gl-list-style-none gl-font-sm">
+ <ul class="gl-p-0 gl-mb-3 gl-list-style-none gl-font-sm">
<li v-for="key in knownStats" :key="key">
<div class="gl-display-flex gl-w-20 gl-align-items-center">
<gl-icon
@@ -174,6 +163,7 @@ export default {
</div>
</li>
</ul>
+ <gl-link v-if="showDetails" :href="detailsPath">{{ $options.i18n.detailsLink }}</gl-link>
</gl-accordion-item>
</gl-accordion>
</div>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index aaa37f145aa..55a8bad27b9 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -180,18 +180,20 @@ export default {
class="gl-mb-5"
/>
<div v-if="repositories.length" class="gl-w-full">
- <table>
- <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100">
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ fromHeaderText }}
- </th>
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('To GitLab') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('Status') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th>
+ <table class="table gl-table">
+ <thead>
+ <tr>
+ <th class="gl-w-half">
+ {{ fromHeaderText }}
+ </th>
+ <th class="gl-w-half">
+ {{ __('To GitLab') }}
+ </th>
+ <th>
+ {{ __('Status') }}
+ </th>
+ <th></th>
+ </tr>
</thead>
<tbody>
<template v-for="repo in repositories">
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 265cca9070e..b20309baac7 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -155,16 +155,16 @@ export default {
<template>
<tr
- class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
+ class="gl-h-11"
data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName"
>
- <td class="gl-p-4 gl-vertical-align-top">
+ <td>
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
</gl-link>
- <div v-if="isFinished" class="gl-font-sm">
+ <div v-if="isFinished" class="gl-font-sm gl-mt-2">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link
@@ -179,52 +179,50 @@ export default {
</gl-sprintf>
</div>
</td>
- <td
- class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
- data-testid="fullPath"
- data-qa-selector="project_path_content"
- >
- <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
- <template v-else-if="isImportNotStarted || isSelectedForReimport">
- <div class="gl-display-flex gl-align-items-stretch gl-w-full">
- <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
- <template v-if="namespaces.length">
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="ns in namespaces"
- :key="ns.fullPath"
- data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns.fullPath"
- @click="updateImportTarget({ targetNamespace: ns.fullPath })"
- >
- {{ ns.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
- userNamespace
- }}</gl-dropdown-item>
- </import-group-dropdown>
- <div
- class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
- >
- /
+ <td data-testid="fullPath" data-qa-selector="project_path_content">
+ <div class="gl-display-flex gl-sm-flex-wrap">
+ <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
+ <template v-else-if="isImportNotStarted || isSelectedForReimport">
+ <div class="gl-display-flex gl-align-items-stretch gl-w-full">
+ <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
+ <template v-if="namespaces.length">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in namespaces"
+ :key="ns.fullPath"
+ data-qa-selector="target_group_dropdown_item"
+ :data-qa-group-name="ns.fullPath"
+ @click="updateImportTarget({ targetNamespace: ns.fullPath })"
+ >
+ {{ ns.fullPath }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
+ userNamespace
+ }}</gl-dropdown-item>
+ </import-group-dropdown>
+ <div
+ class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ >
+ /
+ </div>
+ <gl-form-input
+ ref="newNameInput"
+ v-model="newNameInput"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ data-qa-selector="project_path_field"
+ />
</div>
- <gl-form-input
- ref="newNameInput"
- v-model="newNameInput"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
- data-qa-selector="project_path_field"
- />
- </div>
- </template>
- <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </template>
+ <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </div>
</td>
- <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
+ <td data-qa-selector="import_status_indicator">
<import-status :status="importStatus" :stats="stats" />
</td>
- <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap">
+ <td data-testid="actions" class="gl-white-space-nowrap">
<gl-tooltip :target="() => $refs.cancelButton.$el">
<div class="gl-text-left">
<p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p>
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 66ffd378426..f898e23b47a 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -66,12 +66,16 @@ export default function mountImportProjectsTable({
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
+ const { detailsPath } = mountElement.dataset;
return new Vue({
el: mountElement,
name: 'ImportProjectsRoot',
store,
apolloProvider,
+ provide: {
+ detailsPath,
+ },
render(createElement) {
// We are using attrs instead of props so root-level component with inheritAttrs
// will be able to pass them down
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index f8e70fea7aa..e15cb2224f4 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -12,6 +12,7 @@ import {
GlEmptyState,
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
+import { STATUS_CLOSED } from '~/issues/constants';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__, n__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/constants';
@@ -301,6 +302,9 @@ export default {
getEscalationStatus(escalationStatus) {
return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
},
+ isClosed(item) {
+ return item.state === STATUS_CLOSED;
+ },
showIncidentLink({ iid }) {
return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
},
@@ -397,7 +401,7 @@ export default {
<template #cell(title)="{ item }">
<div
:class="{
- 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed',
+ 'gl-display-flex gl-align-items-center gl-max-w-full': isClosed(item),
}"
>
<gl-link
@@ -411,7 +415,7 @@ export default {
</tooltip-on-truncate>
</gl-link>
<gl-icon
- v-if="item.state === 'closed'"
+ v-if="isClosed(item)"
name="issue-close"
class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index dde40ec2983..6f8d5cf5f89 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { s__ } from '~/locale';
export const I18N = {
@@ -51,11 +50,13 @@ export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
+const category = 'Incident Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
/**
* Tracks snowplow event when user clicks create new incident
*/
export const trackIncidentCreateNewOptions = {
- category: 'Incident Management',
+ category,
action: 'create_incident_button_clicks',
};
@@ -63,7 +64,7 @@ export const trackIncidentCreateNewOptions = {
* Tracks snowplow event when user views incidents list
*/
export const trackIncidentListViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incidents_list',
};
@@ -71,6 +72,6 @@ export const trackIncidentListViewsOptions = {
* Tracks snowplow event when user views incident details
*/
export const trackIncidentDetailsViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incident_details',
};
diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
index 8413fe92f89..82350a3987e 100644
--- a/app/assets/javascripts/init_diff_stats_dropdown.js
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -4,7 +4,15 @@ import { stickyMonitor } from './lib/utils/sticky';
export const initDiffStatsDropdown = (stickyTop) => {
if (stickyTop) {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false);
+ // We spend quite a bit of effort in our CSS to set the correct padding-top on the
+ // layout page, so we re-use the padding set there to determine at what height our
+ // element should be sticky
+ const pageLayout = document.querySelector('.layout-page');
+ const pageLayoutTopOffset = pageLayout
+ ? parseFloat(window.getComputedStyle(pageLayout).getPropertyValue('padding-top') || 0)
+ : 0;
+
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), pageLayoutTopOffset, false);
}
const el = document.querySelector('.js-diff-stats-dropdown');
diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
index 767675cc64c..aaa04dc4b43 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_notification.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
@@ -1,12 +1,7 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { GROUP_MODAL_ALERT_BODY } from '../constants';
-
-const SHARE_GROUP_LINK =
- 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group';
export default {
- SHARE_GROUP_LINK,
name: 'InviteGroupNotification',
components: { GlAlert, GlSprintf, GlLink },
inject: ['freeUsersLimit'],
@@ -15,18 +10,23 @@ export default {
type: String,
required: true,
},
- },
- i18n: {
- body: GROUP_MODAL_ALERT_BODY,
+ notificationText: {
+ type: String,
+ required: true,
+ },
+ notificationLink: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
<gl-alert variant="warning" :dismissible="false">
- <gl-sprintf :message="$options.i18n.body">
+ <gl-sprintf :message="notificationText">
<template #link="{ content }">
- <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{
+ <gl-link :href="notificationLink" target="_blank" class="gl-label-link">{{
content
}}</gl-link>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 3be3b9df747..51355baef99 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -190,7 +190,13 @@ export default {
@submit="sendInvite"
>
<template #alert>
- <invite-group-notification v-if="freeUserCapEnabled" :name="name" />
+ <invite-group-notification
+ v-if="freeUserCapEnabled"
+ :name="name"
+ :notification-text="$options.labels[inviteTo].notificationText"
+ :notification-link="$options.labels[inviteTo].notificationLink"
+ class="gl-mb-5"
+ />
</template>
<template #select>
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 812e39e6392..e99a61caf3f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -13,20 +13,21 @@ 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 Tracking from '~/tracking';
-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__, sprintf } from '~/locale';
import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from 'ee_else_ce/invite_members/utils/member_utils';
+import {
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
- LEARN_GITLAB,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
} from '../constants';
import eventHub from '../event_hub';
import { responseFromSuccess } from '../utils/response_message_parser';
-import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import {
displaySuccessfulInvitationAlert,
@@ -170,11 +171,7 @@ export default {
);
},
tasksToBeDoneEnabled() {
- return (
- (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
- this.isOnLearnGitlab) &&
- this.tasksToBeDoneOptions.length
- );
+ return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length;
},
showTasksToBeDone() {
return (
@@ -193,9 +190,6 @@ export default {
? this.selectedTaskProject.id
: '';
},
- isOnLearnGitlab() {
- return this.source === LEARN_GITLAB;
- },
showUserLimitNotification() {
return !isEmpty(this.usersLimitDataset.alertVariant);
},
@@ -248,14 +242,10 @@ export default {
eventHub.$on('openModal', (options) => {
this.openModal(options);
- if (this.isOnLearnGitlab) {
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source);
- }
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ source: 'in_product_marketing_email' });
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
methods: {
@@ -283,16 +273,29 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- trackEvent(experimentName, eventName) {
- const tracking = new ExperimentTracking(experimentName);
- tracking.event(eventName);
- },
showEmptyInvitesAlert() {
this.invalidFeedbackMessage = this.$options.labels.placeHolder;
this.shouldShowEmptyInvitesAlert = true;
this.$refs.alerts.focus();
},
- sendInvite({ accessLevel, expiresAt }) {
+ getInvitePayload({ accessLevel, expiresAt }) {
+ const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ return {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ ...email,
+ ...userId,
+ };
+ },
+ async sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
@@ -301,40 +304,28 @@ export default {
return;
}
- const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+ this.trackInviteMembersForTask();
const apiAddByInvite = this.isProject
? Api.inviteProjectMembers.bind(Api)
: Api.inviteGroupMembers.bind(Api);
- const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
- const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+ try {
+ const payload = this.getInvitePayload({ accessLevel, expiresAt });
+ const response = await apiAddByInvite(this.id, payload);
- this.trackinviteMembersForTask();
-
- apiAddByInvite(this.id, {
- format: 'json',
- expires_at: expiresAt,
- access_level: accessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- ...email,
- ...userId,
- })
- .then((response) => {
- const { error, message } = responseFromSuccess(response);
+ const { error, message } = responseFromSuccess(response);
- if (error) {
- this.showMemberErrors(message);
- } else {
- this.onInviteSuccess();
- }
- })
- .catch((e) => this.showInvalidFeedbackMessage(e))
- .finally(() => {
- this.isLoading = false;
- });
+ if (error) {
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch (e) {
+ this.showInvalidFeedbackMessage(e);
+ } finally {
+ this.isLoading = false;
+ }
},
showMemberErrors(message) {
this.invalidMembers = message;
@@ -344,11 +335,10 @@ export default {
// initial token creation hits this and nothing is found... so safe navigation
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
- trackinviteMembersForTask() {
+ trackInviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
- const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
- tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
+ this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property });
},
onCancel() {
this.track('click_cancel', { label: this.source });
@@ -377,9 +367,7 @@ export default {
}
},
showSuccessMessage() {
- if (this.isOnLearnGitlab) {
- eventHub.$emit('showSuccessfulInvitationsAlert');
- } else {
+ if (!triggerExternalAlert(this.source)) {
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
@@ -431,7 +419,9 @@ export default {
@access-level="onAccessLevelUpdate"
>
<template #intro-text-before>
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1">
+ <gl-emoji data-name="tada" />
+ </div>
</template>
<template #intro-text-after>
<br />
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 86badd16d6c..d5e9e498c6b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,12 +1,11 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const SEARCH_DELAY = 200;
export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
- name: 'invite_members_for_task',
- view: 'modal_opened_from_email',
submit: 'submit',
};
export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully';
@@ -61,9 +60,18 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__(
"InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
);
-export const GROUP_MODAL_ALERT_BODY = s__(
- 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+export const GROUP_MODAL_TO_GROUP_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
);
+export const GROUP_MODAL_TO_GROUP_ALERT_LINK = helpPagePath('user/group/manage', {
+ anchor: 'share-a-group-with-another-group',
+});
+export const GROUP_MODAL_TO_PROJECT_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your project%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+);
+export const GROUP_MODAL_TO_PROJECT_ALERT_LINK = helpPagePath('user/project/members/index', {
+ anchor: 'add-groups-to-a-project',
+});
export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite');
export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite');
@@ -129,16 +137,19 @@ export const GROUP_MODAL_LABELS = {
title: GROUP_MODAL_DEFAULT_TITLE,
toGroup: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK,
},
toProject: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK,
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
-export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index d85162626f1..240a3a89686 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -1,4 +1,14 @@
+import { getParameterValues } from '~/lib/utils/url_utility';
+
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;
}
+
+export function triggerExternalAlert() {
+ return false;
+}
+
+export function qualifiesForTasksToBeDone() {
+ return getParameterValues('open_modal')[0] === 'invite_members_for_task';
+}
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index c4b9bdb150b..d32336395dc 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -111,13 +111,14 @@ export default {
<div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
<gl-icon
v-if="hasState"
+ :id="`iconElementXL-${itemId}`"
ref="iconElementXL"
:class="iconClasses"
:name="iconName"
:title="stateTitle"
:aria-label="state"
/>
- <gl-tooltip :target="() => $refs.iconElementXL">
+ <gl-tooltip :target="`iconElementXL-${itemId}`">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
@@ -141,7 +142,7 @@ export default {
<!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). -->
<!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 -->
<div
- class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-wrap-reverse"
+ class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-reverse"
>
<!-- Path area: status icon (<XL), path, issue # -->
<div
@@ -221,7 +222,7 @@ export default {
category="tertiary"
size="small"
:disabled="removeDisabled"
- class="js-issue-item-remove-button"
+ class="js-issue-item-remove-button gl-mr-2"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 9ffcf14c943..799c0a18444 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -125,8 +125,13 @@ export default {
</script>
<template>
- <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant">
- <gl-icon :name="badgeIcon" />
+ <gl-badge
+ class="issuable-status-badge gl-mr-3"
+ :class="badgeClass"
+ :variant="badgeVariant"
+ :aria-label="badgeText"
+ >
+ <gl-icon :name="badgeIcon" class="gl-badge-icon" />
<span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span>
</gl-badge>
</template>
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 8a094d5d688..a1525ad2bec 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -6,12 +6,13 @@ import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'
import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import ZenMode from '~/zen_mode';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
const DATA_ISSUES_NEW_PATH = 'data-new-issue-path';
-function organizeQuery(obj, isFallbackKey = false) {
+export function organizeQuery(obj, isFallbackKey = false) {
if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
return obj;
}
@@ -83,11 +84,10 @@ export default class IssuableForm {
this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH));
this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
- this.descriptionField = this.form.find('textarea[name*="[description]"]');
+ this.descriptionField = () => this.form.find('textarea[name*="[description]"]');
+ this.submitButton = this.form.find('.js-issuable-submit-button');
this.draftCheck = document.querySelector('input.js-toggle-draft');
- if (!(this.titleField.length && this.descriptionField.length)) {
- return;
- }
+ if (!this.titleField.length) return;
this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
@@ -99,7 +99,7 @@ export default class IssuableForm {
if ($issuableDueDate.length) {
const calendar = new Pikaday({
field: $issuableDueDate.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $issuableDueDate.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
@@ -125,13 +125,6 @@ export default class IssuableForm {
);
IssuableForm.addAutosave(
autosaveMap,
- 'description',
- this.form.find('textarea[name*="[description]"]').get(0),
- this.searchTerm,
- this.fallbackKey,
- );
- IssuableForm.addAutosave(
- autosaveMap,
'confidential',
this.form.find('input:checkbox[name*="[confidential]"]').get(0),
this.searchTerm,
@@ -148,7 +141,21 @@ export default class IssuableForm {
return autosaveMap;
}
- handleSubmit() {
+ async handleSubmit(event) {
+ event.preventDefault();
+
+ const form = event.target;
+ const descriptionText = this.descriptionField().val();
+
+ if (containsSensitiveToken(descriptionText)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.submitButton.removeAttr('disabled');
+ this.submitButton.removeClass('disabled');
+ return false;
+ }
+ }
+ form.submit();
return this.resetAutosave();
}
diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
index 4a6edae0c06..cbad6a2537d 100644
--- a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
@@ -1,4 +1,5 @@
import { isEmpty } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -107,13 +108,13 @@ const mixins = {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
- return this.state === 'opened' || this.state === 'reopened';
+ return this.state === STATUS_OPEN || this.state === STATUS_REOPENED;
},
isClosed() {
- return this.state === 'closed';
+ return this.state === STATUS_CLOSED;
},
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
hasTitle() {
return this.title.length > 0;
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index b7d885ed8a7..d35355a8f26 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -5,6 +5,7 @@ export const STATUS_CLOSED = 'closed';
export const STATUS_MERGED = 'merged';
export const STATUS_OPEN = 'opened';
export const STATUS_REOPENED = 'reopened';
+export const STATUS_LOCKED = 'locked';
export const TITLE_LENGTH_MAX = 255;
@@ -22,4 +23,6 @@ export const IssuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
+ [STATUS_MERGED]: __('Merged'),
+ [STATUS_LOCKED]: __('Open'),
};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index c821c18bcb9..de0334b4ffe 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -432,7 +432,7 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
- if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
+ if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) {
this.droplab.hooks.forEach((hook) => hook.list.toggle());
return;
@@ -442,9 +442,9 @@ export default class CreateMergeRequestDropdown {
return;
}
- if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (event.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.currentTarget.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 83387d3ac29..61531880842 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -3,12 +3,10 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GLForm from '~/gl_form';
import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
-import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { TYPE_INCIDENT } from '~/issues/constants';
import Issue from '~/issues/issue';
-import { initTitleSuggestions, initTypePopover } from '~/issues/new';
+import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import { initRelatedIssues } from '~/related_issues';
import {
@@ -38,15 +36,14 @@ export function initFilteredSearchServiceDesk() {
}
export function initForm() {
- new GLForm($('.issue-form')); // eslint-disable-line no-new
new IssuableForm($('.issue-form')); // eslint-disable-line no-new
IssuableLabelSelector();
- new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new
new LabelsSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initTitleSuggestions();
initTypePopover();
+ initTypeSelect();
mountMilestoneDropdown();
}
diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
index d11540ad3dd..5199c36db5a 100644
--- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
@@ -77,7 +77,7 @@ export default {
class="issuable-milestone gl-mr-3"
data-testid="issuable-milestone"
>
- <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
+ <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate" class="gl-font-sm">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 99064a50e3f..2c6f11b682c 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -76,8 +76,8 @@ export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const i18n = {
calendarLabel: __('Subscribe to calendar'),
- closed: __('CLOSED'),
- closedMoved: __('CLOSED (MOVED)'),
+ closed: __('Closed'),
+ closedMoved: __('Closed (moved)'),
confidentialNo: __('No'),
confidentialYes: __('Yes'),
downvotes: __('Downvotes'),
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index b590006929a..e64870152bd 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -1,7 +1,9 @@
import produce from 'immer';
-import createDefaultClient from '~/lib/graphql';
+import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+let client;
+
const resolvers = {
Mutation: {
reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
@@ -22,6 +24,10 @@ const resolvers = {
},
};
-export const gqlClient = gon.features?.frontendCaching
- ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' })
- : createDefaultClient(resolvers);
+export async function gqlClient() {
+ if (client) return client;
+ client = gon.features?.frontendCaching
+ ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' })
+ : createDefaultClient(resolvers);
+ return client;
+}
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index aca894549e4..a97b59c1e4f 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue';
import { gqlClient } from './graphql';
-export function mountJiraIssuesListApp() {
+export async function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status-root');
if (!el) {
@@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() {
el,
name: 'JiraIssuesImportStatusRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render(createComponent) {
return createComponent(JiraIssuesImportStatusApp, {
@@ -42,7 +42,7 @@ export function mountJiraIssuesListApp() {
});
}
-export function mountIssuesListApp() {
+export async function mountIssuesListApp() {
const el = document.querySelector('.js-issues-list-root');
if (!el) {
@@ -100,7 +100,7 @@ export function mountIssuesListApp() {
el,
name: 'IssuesListRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
router: new VueRouter({
base: window.location.pathname,
diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
index a01f4f747b9..be2237ae2a2 100644
--- a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+import { STATUS_CLOSED } from '~/issues/constants';
import { __ } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -42,7 +43,7 @@ export default {
].filter(({ count }) => count);
},
isClosed() {
- return this.suggestion.state === 'closed';
+ return this.suggestion.state === STATUS_CLOSED;
},
stateIconClass() {
return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
diff --git a/app/assets/javascripts/issues/new/components/type_select.vue b/app/assets/javascripts/issues/new/components/type_select.vue
new file mode 100644
index 00000000000..81c3a769d26
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/type_select.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants';
+import { visitUrl } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ selectType: __('Select type'),
+ issuableType: {
+ [TYPE_ISSUE]: __('Issue'),
+ [TYPE_INCIDENT]: __('Incident'),
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlIcon,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ selectedType: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ isIssueAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ isIncidentAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ issuePath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ incidentPath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ },
+ data() {
+ return {
+ selected: this.selectedType,
+ };
+ },
+ computed: {
+ toggleText() {
+ return this.selectedType
+ ? this.$options.i18n.issuableType[this.selectedType]
+ : this.$options.i18n.selectType;
+ },
+ dropdownItems() {
+ const issueItem = this.isIssueAllowed
+ ? {
+ value: TYPE_ISSUE,
+ text: __('Issue'),
+ icon: 'issue-type-issue',
+ href: this.issuePath,
+ }
+ : null;
+ const incidentItem = this.isIncidentAllowed
+ ? {
+ value: TYPE_INCIDENT,
+ text: __('Incident'),
+ icon: 'issue-type-incident',
+ href: this.incidentPath,
+ tracking: {
+ action: 'select_issue_type_incident',
+ label: 'select_issue_type_incident_dropdown_option',
+ },
+ }
+ : null;
+
+ return [issueItem, incidentItem].filter(Boolean);
+ },
+ },
+ methods: {
+ selectType(type) {
+ const selectedItem = this.dropdownItems.find((item) => item.value === type);
+ if (selectedItem.tracking) {
+ const { action, label } = selectedItem.tracking;
+ this.track(action, { label });
+ }
+
+ visitUrl(selectedItem.href);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-collapsible-listbox
+ v-model="selected"
+ :header-text="$options.i18n.selectType"
+ :toggle-text="toggleText"
+ :items="dropdownItems"
+ block
+ class="js-issuable-type-filter-dropdown-wrap"
+ @select="selectType"
+ >
+ <template #list-item="{ item }">
+ <gl-icon :name="item.icon" :size="16" />
+ {{ item.text }}
+ </template>
+ </gl-collapsible-listbox>
+</template>
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
index 91599502996..84a170e6564 100644
--- a/app/assets/javascripts/issues/new/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -1,8 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import TitleSuggestions from './components/title_suggestions.vue';
import TypePopover from './components/type_popover.vue';
+import TypeSelect from './components/type_select.vue';
export function initTitleSuggestions() {
const el = document.getElementById('js-suggestions');
@@ -56,3 +58,28 @@ export function initTypePopover() {
render: (createElement) => createElement(TypePopover),
});
}
+
+export function initTypeSelect() {
+ const el = document.getElementById('js-type-select');
+
+ if (!el) {
+ return undefined;
+ }
+
+ const { selectedType, isIssueAllowed, isIncidentAllowed, issuePath, incidentPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'TypeSelectRoot',
+ render: (createElement) =>
+ createElement(TypeSelect, {
+ props: {
+ selectedType,
+ isIssueAllowed: parseBoolean(isIssueAllowed),
+ isIncidentAllowed: parseBoolean(isIncidentAllowed),
+ issuePath,
+ incidentPath,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index 15f97222971..bc32a15a420 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -14,6 +14,7 @@ import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
@@ -95,10 +96,10 @@ export default {
required: false,
default: '',
},
- initialTaskStatus: {
- type: String,
+ initialTaskCompletionStatus: {
+ type: Object,
required: false,
- default: '',
+ default: () => ({}),
},
updatedAt: {
type: String,
@@ -197,7 +198,7 @@ export default {
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
- taskStatus: this.initialTaskStatus,
+ taskCompletionStatus: this.initialTaskCompletionStatus,
lock_version: this.lockVersion,
});
@@ -222,9 +223,6 @@ export default {
formState() {
return this.store.formState;
},
- hasUpdated() {
- return Boolean(this.state.updatedAt);
- },
issueChanged() {
const {
store: {
@@ -379,7 +377,7 @@ export default {
this.showForm = false;
},
- updateIssuable() {
+ async updateIssuable() {
this.setFormState({ updateLoading: true });
const {
@@ -392,6 +390,14 @@ export default {
this.alert?.dismiss();
+ if (containsSensitiveToken(issuablePayload.description)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.setFormState({ updateLoading: false });
+ return false;
+ }
+ }
+
return this.service
.updateIssuable(issuablePayload)
.then((res) => res.data)
@@ -557,7 +563,6 @@ export default {
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
- :task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
:lock-version="state.lock_version"
@@ -570,7 +575,7 @@ export default {
/>
<edited-component
- v-if="hasUpdated"
+ :task-completion-status="state.taskCompletionStatus"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index bdee6c5fe9a..3721f224d5e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,6 +1,5 @@
<script>
import { GlToast } from '@gitlab/ui';
-import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
@@ -59,11 +58,6 @@ export default {
required: false,
default: '',
},
- taskStatus: {
- type: String,
- required: false,
- default: '',
- },
issuableType: {
type: String,
required: false,
@@ -138,7 +132,10 @@ export default {
},
watch: {
descriptionHtml(newDescription, oldDescription) {
- if (!this.initialUpdate && newDescription !== oldDescription) {
+ if (
+ !this.initialUpdate &&
+ this.stripClientState(newDescription) !== this.stripClientState(oldDescription)
+ ) {
this.animateChange();
} else {
this.initialUpdate = false;
@@ -148,16 +145,12 @@ export default {
this.renderGFM();
});
},
- taskStatus() {
- this.updateTaskStatusText();
- },
},
mounted() {
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
this.renderGFM();
- this.updateTaskStatusText();
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
@@ -282,24 +275,6 @@ export default {
this.$emit('taskListUpdateFailed');
},
- updateTaskStatusText() {
- const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
- const $issuableHeader = $('.issuable-meta');
- const $tasks = $('#task_status', $issuableHeader);
- const $tasksShort = $('#task_status_short', $issuableHeader);
-
- if (taskRegexMatches) {
- $tasks.text(this.taskStatus);
- $tasksShort.text(
- `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
- taskRegexMatches[2] > 1 ? 's' : ''
- }`,
- );
- } else {
- $tasks.text('');
- $tasksShort.text('');
- }
- },
createTaskListItemActions(provide) {
const app = new Vue({
el: document.createElement('div'),
@@ -349,6 +324,9 @@ export default {
listItem.append(element);
}
},
+ stripClientState(description) {
+ return description.replaceAll('<details open="true">', '<details>');
+ },
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 5138a4530e9..6a0edb59b65 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,13 +1,20 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeAgoTooltip,
+ GlLink,
GlSprintf,
},
props: {
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
updatedAt: {
type: String,
required: false,
@@ -25,36 +32,61 @@ export default {
},
},
computed: {
+ completedCount() {
+ return this.taskCompletionStatus.completed_count;
+ },
+ count() {
+ return this.taskCompletionStatus.count;
+ },
hasUpdatedBy() {
return this.updatedByName && this.updatedByPath;
},
+ showCheck() {
+ return this.completedCount === this.count;
+ },
+ taskStatus() {
+ const { completedCount, count } = this;
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
},
};
</script>
<template>
<small class="edited-text js-issue-widgets">
- <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- </gl-sprintf>
- <gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')">
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
- <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
+ <template v-if="taskStatus">
+ <template v-if="showCheck">&check;</template>
+ {{ taskStatus }}
+ <template v-if="updatedAt">&middot;</template>
+ </template>
+
+ <template v-if="updatedAt">
+ <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ <template #author>
+ <gl-link :href="updatedByPath" class="gl-font-sm gl-hover-text-gray-900 gl-text-gray-700">
+ {{ updatedByName }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
</small>
</template>
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
index 243666b2323..8267c0130a3 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -199,7 +199,7 @@ export default {
<p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
</div>
- <gl-form-group v-if="glFeatures.incidentEventTags">
+ <gl-form-group>
<label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags">
{{ $options.i18n.tagsLabel }}
<timeline-events-tags-popover />
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
index 7c6ff002014..373c5970e64 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
@@ -42,8 +42,8 @@ export default {
},
mounted() {
this.gitlabBasePath = retrieveBaseUrl();
- setApiBaseURL(this.gitlabBasePath);
if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) {
+ setApiBaseURL(this.gitlabBasePath);
this.showSetupInstructions = true;
}
},
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 2018942a7e8..1c7ba1d331b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -45,7 +45,9 @@ export default {
data-testid="artifacts-remove-timeline"
>
<span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
- <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span>
+ <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{
+ s__('Job|The artifacts will be removed')
+ }}</span>
<timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
<gl-link
:href="helpUrl"
@@ -53,11 +55,11 @@ export default {
rel="noopener noreferrer nofollow"
data-testid="artifact-expired-help-link"
>
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</p>
<p v-else-if="isLocked" class="build-detail-row">
- <span data-testid="job-locked-message">{{
+ <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">{{
s__(
'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
)
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 3d87cea6445..ff7982319e7 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -19,7 +19,7 @@ export default {
loadingAriaLabel: __('Loading'),
},
filterSearchBoxStyles:
- 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b',
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100',
components: {
GlAlert,
GlSkeletonLoader,
@@ -140,6 +140,19 @@ export default {
this.infiniteScrollingTriggered = false;
this.filterSearchTriggered = true;
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ updateHistory({
+ url: setUrlParams({ statuses: null }, window.location.href, true),
+ });
+
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+ this.$apollo.queries.jobsCount.refetch({ statuses: null });
+
+ return;
+ }
+
// Eventually there will be more tokens available
// this code is written to scale for those tokens
filters.forEach((filter) => {
@@ -223,7 +236,7 @@ export default {
<jobs-table-empty-state v-else-if="showEmptyState" />
- <jobs-table v-else :jobs="jobs.list" />
+ <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" />
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon
diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue
index 2be404de1e1..904e1ba9a47 100644
--- a/app/assets/javascripts/labels/components/delete_label_modal.vue
+++ b/app/assets/javascripts/labels/components/delete_label_modal.vue
@@ -81,14 +81,9 @@ export default {
</gl-sprintf>
<template #modal-footer>
<gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- category="primary"
- variant="danger"
- :href="destroyPath"
- data-method="delete"
- data-testid="delete-button"
- >{{ __('Delete label') }}</gl-button
- >
+ <gl-button category="primary" variant="danger" :href="destroyPath" data-method="delete">{{
+ __('Delete label')
+ }}</gl-button>
</template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js
index 60ab0c92256..fa0104fcf12 100644
--- a/app/assets/javascripts/labels/create_label_dropdown.js
+++ b/app/assets/javascripts/labels/create_label_dropdown.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import Api from '~/api';
import { humanize } from '~/lib/utils/text_utility';
@@ -49,6 +47,7 @@ export default class CreateLabelDropdown {
addBinding() {
const self = this;
+ // eslint-disable-next-line func-names
this.$colorSuggestions.on('click', function (e) {
const $this = $(this);
self.addColorValue(e, $this);
diff --git a/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
new file mode 100644
index 00000000000..5d2a002bf85
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
@@ -0,0 +1,97 @@
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable class-methods-use-this */
+import { db } from './local_db';
+
+/**
+ * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1]
+ *
+ * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15
+ */
+export class IndexedDBPersistentStorage {
+ static async create() {
+ await db.open();
+
+ return new IndexedDBPersistentStorage();
+ }
+
+ async getItem(queryId) {
+ const resultObj = {};
+ const selectedQuery = await db.table('queries').get(queryId);
+ const tableNames = new Set(db.tables.map((table) => table.name));
+
+ if (selectedQuery) {
+ resultObj.ROOT_QUERY = selectedQuery;
+
+ const lookupTable = [];
+
+ const parseObjectsForRef = async (selObject) => {
+ const ops = Object.values(selObject).map(async (child) => {
+ if (!child) {
+ return;
+ }
+
+ if (child.__ref) {
+ const pathId = child.__ref;
+ const [refType, ...refKeyParts] = pathId.split(':');
+ const refKey = refKeyParts.join(':');
+
+ if (
+ !resultObj[pathId] &&
+ !lookupTable.includes(pathId) &&
+ tableNames.has(refType.toLowerCase())
+ ) {
+ lookupTable.push(pathId);
+ const selectedEntity = await db.table(refType.toLowerCase()).get(refKey);
+ if (selectedEntity) {
+ await parseObjectsForRef(selectedEntity);
+ resultObj[pathId] = selectedEntity;
+ }
+ }
+ } else if (typeof child === 'object') {
+ await parseObjectsForRef(child);
+ }
+ });
+
+ return Promise.all(ops);
+ };
+
+ await parseObjectsForRef(resultObj.ROOT_QUERY);
+ }
+
+ return resultObj;
+ }
+
+ async setItem(key, value) {
+ await this.#setQueryResults(key, JSON.parse(value));
+ }
+
+ async removeItem() {
+ // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993
+
+ await Promise.all(
+ db.tables.map((table) => {
+ return table.clear();
+ }),
+ );
+ }
+
+ async #setQueryResults(queryId, results) {
+ await Promise.all(
+ Object.keys(results).map((id) => {
+ const objectType = id.split(':')[0];
+ if (objectType === 'ROOT_QUERY') {
+ return db.table('queries').put(results[id], queryId);
+ }
+ const key = objectType.toLowerCase();
+ const tableExists = db.tables.some((table) => table.name === key);
+ if (tableExists) {
+ return db.table(key).put(results[id], id);
+ }
+ return new Promise((resolve) => {
+ resolve();
+ });
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js
new file mode 100644
index 00000000000..cda30ff9d42
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/local_db.js
@@ -0,0 +1,14 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import Dexie from 'dexie';
+
+export const db = new Dexie('GLLocalCache');
+db.version(1).stores({
+ pages: 'url, timestamp',
+ queries: '',
+ project: 'id',
+ group: 'id',
+ usercore: 'id',
+ issue: 'id, state, title',
+ label: 'id, title',
+ milestone: 'id',
+});
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index c0e923b2670..2e6fcbea80d 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,7 +1,7 @@
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
-import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist';
+import { persistCache } from 'apollo3-cache-persist';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import possibleTypes from '~/graphql_shared/possible_types.json';
@@ -53,6 +53,15 @@ export const typePolicies = {
TreeEntry: {
keyFields: ['webPath'],
},
+ Subscription: {
+ fields: {
+ aiCompletionResponse: {
+ read(value) {
+ return value ?? null;
+ },
+ },
+ },
+ },
};
export const stripWhitespaceFromQuery = (url, path) => {
@@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', {
},
});
-export default (resolvers = {}, config = {}) => {
+function createApolloClient(resolvers = {}, config = {}) {
const {
baseUrl,
batchMax = 10,
@@ -113,7 +122,6 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
path = '/api/graphql',
useGet = false,
- localCacheKey = null,
} = config;
let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
@@ -171,6 +179,7 @@ export default (resolvers = {}, config = {}) => {
config: {
url: httpResponse.url,
operationName: operation.operationName,
+ method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request
},
headers: {
'x-request-id': httpResponse.headers.get('x-request-id'),
@@ -237,16 +246,6 @@ export default (resolvers = {}, config = {}) => {
},
});
- if (localCacheKey) {
- persistCacheSync({
- cache: newCache,
- // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
- debug: process.env.NODE_ENV === 'development',
- storage: new LocalStorageWrapper(window.localStorage),
- persistenceMapper,
- });
- }
-
ac = new ApolloClient({
typeDefs,
link: appLink,
@@ -262,5 +261,42 @@ export default (resolvers = {}, config = {}) => {
acs.push(ac);
- return ac;
+ return { client: ac, cache: newCache };
+}
+
+export async function createApolloClientWithCaching(resolvers = {}, config = {}) {
+ const { localCacheKey = null } = config;
+ const { client, cache } = createApolloClient(resolvers, config);
+
+ if (localCacheKey) {
+ let storage;
+
+ // Test that we can use IndexedDB. If not, no persisting for you!
+ try {
+ const { IndexedDBPersistentStorage } = await import(
+ /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage'
+ );
+
+ storage = await IndexedDBPersistentStorage.create();
+ } catch (error) {
+ return client;
+ }
+
+ await persistCache({
+ cache,
+ // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
+ debug: process.env.NODE_ENV === 'development',
+ storage,
+ key: localCacheKey,
+ persistenceMapper,
+ });
+ }
+
+ return client;
+}
+
+export default (resolvers = {}, config = {}) => {
+ const { client } = createApolloClient(resolvers, config);
+
+ return client;
};
diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js
index c72561ce69d..60b46989375 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -12,8 +12,9 @@ const drawDiagram = (source) => {
// eslint-disable-next-line no-unsanitized/property
element.innerHTML = svgCode;
- const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
- const width = parseInt(element.firstElementChild.style.maxWidth, 10);
+ element.firstElementChild.removeAttribute('height');
+ const { height, width } = element.firstElementChild.getBoundingClientRect();
+
setIframeRenderedSize(height, width);
};
mermaid.mermaidAPI.render('mermaid', source, insertSvg);
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js
index 7da3bab0a4b..520d7f627f6 100644
--- a/app/assets/javascripts/lib/utils/chart_utils.js
+++ b/app/assets/javascripts/lib/utils/chart_utils.js
@@ -1,3 +1,6 @@
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { __ } from '~/locale';
+
const commonTooltips = () => ({
mode: 'x',
intersect: false,
@@ -98,3 +101,38 @@ export const firstAndLastY = (data) => {
return [firstY, lastY];
};
+
+const toolboxIconSvgPath = async (name) => {
+ return `path://${await getSvgIconPathContent(name)}`;
+};
+
+export const getToolboxOptions = async () => {
+ const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath);
+
+ try {
+ const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises);
+
+ return {
+ toolbox: {
+ feature: {
+ dataZoom: {
+ icon: { zoom: marqueeSelectionPath, back: redoPath },
+ },
+ restore: {
+ icon: repeatPath,
+ },
+ saveAsImage: {
+ icon: downloadPath,
+ },
+ },
+ },
+ };
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line no-console
+ console.warn(__('SVG could not be rendered correctly: '), e);
+ }
+
+ return {};
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
new file mode 100644
index 00000000000..64c77bf1080
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
@@ -0,0 +1,13 @@
+import { stringifyTime, parseSeconds } from './date_format_utility';
+
+/**
+ * Formats seconds into a human readable value of elapsed time,
+ * optionally limiting it to hours.
+ * @param {Number} seconds Seconds to format
+ * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours
+ * @return {String} Provided seconds in human readable elapsed time format
+ */
+export const formatTimeSpent = (seconds, limitToHours) => {
+ const negative = seconds < 0;
+ return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours }));
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index c1081239544..f9a70371680 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -2,3 +2,4 @@ export * from './datetime/timeago_utility';
export * from './datetime/date_format_utility';
export * from './datetime/date_calculation_utility';
export * from './datetime/pikaday_utility';
+export * from './datetime/time_spent_utility';
diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js
index 4cea4257e7b..febf83a4d38 100644
--- a/app/assets/javascripts/lib/utils/error_message.js
+++ b/app/assets/javascripts/lib/utils/error_message.js
@@ -1,20 +1,15 @@
-export const USER_FACING_ERROR_MESSAGE_PREFIX = 'UF:';
-
-const getMessageFromError = (error = '') => {
- return error.message || error;
-};
-
-export const parseErrorMessage = (error = '') => {
- const messageString = getMessageFromError(error);
-
- if (messageString.startsWith(USER_FACING_ERROR_MESSAGE_PREFIX)) {
- return {
- message: messageString.replace(USER_FACING_ERROR_MESSAGE_PREFIX, '').trim(),
- userFacing: true,
- };
- }
- return {
- message: messageString,
- userFacing: false,
- };
+/**
+ * Utility to parse an error object returned from API.
+ *
+ *
+ * @param { Object } error - An error object directly from API response
+ * @param { string } error.message - The error message, returned from API.
+ * @param { string } defaultMessage - Default user-facing error message
+ * @returns { string } - A transformed user-facing error message, or defaultMessage
+ */
+export const parseErrorMessage = (error = {}, defaultMessage = '') => {
+ const messageString = error.message || '';
+ return messageString.startsWith(window.gon.uf_error_prefix)
+ ? messageString.replace(window.gon.uf_error_prefix, '').trim()
+ : defaultMessage;
};
diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js
index bd47f10b3ac..7cfcd11ece9 100644
--- a/app/assets/javascripts/lib/utils/keys.js
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -1,3 +1,7 @@
export const ESC_KEY = 'Escape';
export const ENTER_KEY = 'Enter';
export const BACKSPACE_KEY = 'Backspace';
+export const ARROW_DOWN_KEY = 'ArrowDown';
+export const ARROW_UP_KEY = 'ArrowUp';
+export const END_KEY = 'End';
+export const HOME_KEY = 'Home';
diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js
new file mode 100644
index 00000000000..2807911c9bb
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/secret_detection.js
@@ -0,0 +1,45 @@
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { s__, __ } from '~/locale';
+
+export const i18n = {
+ defaultPrompt: s__(
+ 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?',
+ ),
+ descriptionPrompt: s__(
+ 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?',
+ ),
+ primaryBtnText: __('Proceed'),
+};
+
+const sensitiveDataPatterns = [
+ {
+ name: 'GitLab Personal Access Token',
+ regex: 'glpat-[0-9a-zA-Z_-]{20}',
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Feed Token',
+ regex: 'feed_token=[0-9a-zA-Z_-]{20}',
+ },
+];
+
+export const containsSensitiveToken = (message) => {
+ for (const rule of sensitiveDataPatterns) {
+ const regex = new RegExp(rule.regex, 'gi');
+ if (regex.test(message)) {
+ return true;
+ }
+ }
+ return false;
+};
+
+export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) {
+ const confirmed = await confirmAction(prompt, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: i18n.primaryBtnText,
+ });
+ if (!confirmed) {
+ return false;
+ }
+ return true;
+}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 2d5e9bc91f2..a2873622682 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -371,7 +371,7 @@ export function insertMarkdownText({
});
}
-function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
+export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
const $textArea = $(textArea);
textArea = $textArea.get(0);
const text = $textArea.val();
@@ -627,10 +627,9 @@ export function addMarkdownListeners(form) {
});
const $allToolbarBtns = $(form)
- .off('click', '.js-md, .saved-replies-dropdown li')
- .on('click', '.js-md, .saved-replies-dropdown li', function () {
- const $savedReplyContent = $('.js-saved-reply-content', this);
- const $toolbarBtn = $savedReplyContent.length ? $savedReplyContent : $(this);
+ .off('click', '.js-md')
+ .on('click', '.js-md', function () {
+ const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
});
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
new file mode 100644
index 00000000000..fd08d34a80e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
@@ -0,0 +1,78 @@
+import Vue from 'vue';
+import { createApolloProvider } from '@vue/apollo-option';
+import { ApolloMutation } from '@vue/apollo-components';
+
+export { ApolloMutation };
+
+const installed = new WeakMap();
+
+function callLifecycle(hookName, ...extraArgs) {
+ const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$;
+ if (!addedMixins) {
+ return [];
+ }
+
+ return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs));
+}
+
+function createMixinForLateInit({ install, shouldInstall }) {
+ return {
+ created() {
+ callLifecycle.call(this, 'created');
+ },
+ // @vue/compat normalizez lifecycle hook names so there is no error here
+ destroyed() {
+ callLifecycle.call(this, 'unmounted');
+ },
+
+ data(...args) {
+ const extraData = callLifecycle.call(this, 'data', ...args);
+ if (!extraData.length) {
+ return {};
+ }
+
+ return Object.assign({}, ...extraData);
+ },
+
+ beforeCreate() {
+ if (shouldInstall(this)) {
+ const { mixins } = this.$.appContext;
+ const globalMixinsBeforeInit = new Set(mixins);
+ install(this);
+
+ this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m));
+
+ callLifecycle.call(this, 'beforeCreate');
+ }
+ },
+ };
+}
+
+export default class VueCompatApollo {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createApolloProvider(...args);
+ }
+
+ static install() {
+ Vue.mixin(
+ createMixinForLateInit({
+ shouldInstall: (vm) =>
+ vm.$options.apolloProvider &&
+ !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider),
+ install: (vm) => {
+ const { app } = vm.$.appContext;
+ const { apolloProvider } = vm.$options;
+
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+
+ installed.get(app).add(apolloProvider);
+
+ vm.$.appContext.app.use(vm.$options.apolloProvider);
+ },
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
new file mode 100644
index 00000000000..006ed920ef0
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -0,0 +1,105 @@
+import Vue from 'vue';
+import {
+ createRouter,
+ createMemoryHistory,
+ createWebHistory,
+ createWebHashHistory,
+} from 'vue-router-vue3';
+
+const mode = (value, options) => {
+ if (!value) return null;
+ let history;
+ // eslint-disable-next-line default-case
+ switch (value) {
+ case 'history':
+ history = createWebHistory(options.base);
+ break;
+ case 'hash':
+ history = createWebHashHistory();
+ break;
+ case 'abstract':
+ history = createMemoryHistory();
+ break;
+ }
+ return { history };
+};
+
+const base = () => null;
+
+const toNewCatchAllPath = (path) => {
+ if (path === '*') return '/:pathMatch(.*)*';
+ return path;
+};
+
+const routes = (value) => {
+ if (!value) return null;
+ const newRoutes = value.reduce(function handleRoutes(acc, route) {
+ const newRoute = {
+ ...route,
+ path: toNewCatchAllPath(route.path),
+ };
+ if (route.children) {
+ newRoute.children = route.children.reduce(handleRoutes, []);
+ }
+ acc.push(newRoute);
+ return acc;
+ }, []);
+ return { routes: newRoutes };
+};
+
+const scrollBehavior = (value) => {
+ return {
+ scrollBehavior(...args) {
+ const { x, y, left, top } = value(...args);
+ return { left: x || left, top: y || top };
+ },
+ };
+};
+
+const transformers = {
+ mode,
+ base,
+ routes,
+ scrollBehavior,
+};
+
+const transformOptions = (options) => {
+ const defaultConfig = {
+ routes: null,
+ history: createWebHashHistory(),
+ };
+ return Object.keys(options).reduce((acc, key) => {
+ const value = options[key];
+ if (key in transformers) {
+ Object.assign(acc, transformers[key](value, options));
+ } else {
+ acc[key] = value;
+ }
+ return acc;
+ }, defaultConfig);
+};
+
+const installed = new WeakMap();
+
+export default class VueRouterCompat {
+ constructor(options) {
+ // eslint-disable-next-line no-constructor-return
+ return createRouter(transformOptions(options));
+ }
+
+ static install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { router } = this.$options;
+ if (router && !installed.get(app)?.has(router)) {
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+ installed.get(app).add(router);
+ this.$.appContext.app.use(this.$options.router);
+ }
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
new file mode 100644
index 00000000000..ff94ff3d04a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import {
+ createStore,
+ mapState,
+ mapGetters,
+ mapActions,
+ mapMutations,
+ createNamespacedHelpers,
+} from 'vuex-vue3';
+
+export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers };
+
+const installedStores = new WeakMap();
+
+export default {
+ Store: class VuexCompatStore {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createStore(...args);
+ }
+ },
+
+ install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { store } = this.$options;
+ if (store && !installedStores.get(app)?.has(store)) {
+ if (!installedStores.has(app)) {
+ installedStores.set(app, new WeakSet());
+ }
+ installedStores.get(app).add(store);
+ this.$.appContext.app.use(this.$options.store);
+ }
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js
new file mode 100644
index 00000000000..f0579b5886d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js
@@ -0,0 +1,24 @@
+import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility';
+
+/**
+ * Takes a project path and optional file path and branch
+ * and then redirects the user to the web IDE.
+ *
+ * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight)
+ * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md)
+ * @param {string} branch - optional branch to open the IDE, defaults to 'main'
+ */
+
+export const openWebIDE = (projectPath, filePath, branch = 'main') => {
+ if (!projectPath) {
+ throw new TypeError('projectPath parameter is required');
+ }
+
+ const pathnameSegments = [projectPath, 'edit', branch, '-'];
+
+ if (filePath) {
+ pathnameSegments.push(filePath);
+ }
+
+ visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`));
+};
diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs
index c2c63777001..f7790cadc48 100644
--- a/app/assets/javascripts/locale/ensure_single_line.cjs
+++ b/app/assets/javascripts/locale/ensure_single_line.cjs
@@ -1,5 +1,3 @@
-/* eslint-disable import/no-commonjs */
-
const SPLIT_REGEX = /\s*[\r\n]+\s*/;
/**
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index a1539aba786..fd002e29afc 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -89,9 +89,11 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
- if (!gon.use_new_navigation) initTopNav();
+ if (!gon.use_new_navigation) {
+ initTopNav();
+ initTodoToggle();
+ }
initBreadcrumbs();
- initTodoToggle();
initPrefetchLinks('.js-prefetch-document');
initLogoAnimation();
initServicePingConsent();
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 76b286f94ad..685482a76de 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,5 +1,6 @@
<script>
import { mapState } from 'vuex';
+import { __ } from '~/locale';
import {
getParameterByName,
setUrlParams,
@@ -46,6 +47,10 @@ export default {
return false;
}
+ if (token.type === 'user_type' && !gon.features?.serviceAccountsCrud) {
+ return false;
+ }
+
return this.filteredSearchBar.tokens?.includes(token.type);
});
},
@@ -94,6 +99,14 @@ export default {
};
}
} else {
+ // Remove this block after this issue is closed: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159
+ if (value.data === __('Service account')) {
+ return {
+ ...accumulator,
+ [type]: 'service_account',
+ };
+ }
+
return {
...accumulator,
[type]: value.data,
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index e066b023fbb..a85bb09e17b 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -81,19 +81,18 @@ export default {
return;
}
- this.updateMemberRole({
- memberId: this.member.id,
- accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
- })
- .then(() => {
- this.$toast.show(s__('Members|Role updated successfully.'));
- })
- .catch((error) => {
- Sentry.captureException(error);
- })
- .finally(() => {
- this.busy = false;
+ try {
+ await this.updateMemberRole({
+ memberId: this.member.id,
+ accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
});
+
+ this.$toast.show(s__('Members|Role updated successfully.'));
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.busy = false;
+ }
},
},
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index d55e942dafa..124b14a9845 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -13,7 +13,7 @@ import axios from './lib/utils/axios_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
-import { __ } from './locale';
+import { __, s__ } from './locale';
import syntaxHighlight from './syntax_highlight';
// MergeRequestTabs
@@ -316,7 +316,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
toggleLoader(false);
- createAlert({ message: __('MergeRequest|Failed to load the page') });
+ createAlert({ message: s__('MergeRequest|Failed to load the page') });
});
}
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index f90fdb04923..9d210f7a6ec 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -64,8 +64,6 @@ export function initDeleteMilestoneModal() {
if (!successful) {
button.removeAttribute('disabled');
}
-
- button.querySelector('.js-loading-icon').classList.add('hidden');
};
const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
@@ -75,7 +73,6 @@ export function initDeleteMilestoneModal() {
`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`,
);
button.setAttribute('disabled', '');
- button.querySelector('.js-loading-icon').classList.remove('hidden');
eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
};
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
new file mode 100644
index 00000000000..4c0f99cf62c
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
@@ -0,0 +1,98 @@
+<script>
+import {
+ GlModal,
+ GlDropdown,
+ GlTooltipDirective,
+ GlDropdownItem,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ props: {
+ deletePath: {
+ type: String,
+ required: true,
+ },
+ deleteConfirmationText: {
+ type: String,
+ required: true,
+ },
+ actionPrimaryText: {
+ type: String,
+ required: true,
+ },
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleteModalVisible: false,
+ modal: {
+ id: 'ml-experiments-delete-modal',
+ deleteConfirmation: this.deleteConfirmationText,
+ actionPrimary: {
+ text: this.actionPrimaryText,
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
+ };
+ },
+ methods: {
+ confirmDelete() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ right
+ category="tertiary"
+ :aria-label="__('More actions')"
+ icon="ellipsis_v"
+ no-caret
+ >
+ <gl-dropdown-item
+ v-gl-modal-directive="modal.id"
+ :aria-label="actionPrimaryText"
+ variant="danger"
+ >
+ {{ actionPrimaryText }}
+
+ <form ref="deleteForm" method="post" :action="deletePath">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+
+ <gl-modal
+ :modal-id="modal.id"
+ :title="modalTitle"
+ :action-primary="modal.actionPrimary"
+ :action-cancel="modal.actionCancel"
+ @primary="confirmDelete"
+ >
+ <p>
+ {{ deleteConfirmationText }}
+ </p>
+ </gl-modal>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
index 3c765de92a2..23b58543f11 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
@@ -2,6 +2,7 @@
import { GlLink } from '@gitlab/ui';
import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
import {
TITLE_LABEL,
INFO_LABEL,
@@ -12,12 +13,16 @@ import {
PARAMETERS_LABEL,
METRICS_LABEL,
METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
} from './translations';
export default {
name: 'MlCandidatesShow',
components: {
IncubationAlert,
+ DeleteButton,
GlLink,
},
props: {
@@ -36,6 +41,9 @@ export default {
PARAMETERS_LABEL,
METRICS_LABEL,
METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
},
computed: {
sections() {
@@ -67,11 +75,22 @@ export default {
:link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
/>
- <h3>
- {{ $options.i18n.TITLE_LABEL }}
- </h3>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <h1 class="page-title gl-font-size-h-display flex-fill">
+ {{ $options.i18n.TITLE_LABEL }}
+ </h1>
- <table class="candidate-details">
+ <delete-button
+ :delete-path="candidate.info.path"
+ :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE"
+ :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL"
+ :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE"
+ />
+ </div>
+ </div>
+
+ <table class="candidate-details gl-w-full">
<tbody>
<tr class="divider"></tr>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
index caad145873e..5f7714aa0c0 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
@@ -9,3 +9,8 @@ export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts');
export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters');
export const METRICS_LABEL = s__('MlExperimentTracking|Metrics');
export const METADATA_LABEL = s__('MlExperimentTracking|Metadata');
+export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.',
+);
+export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate');
+export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue
new file mode 100644
index 00000000000..92c662fedf1
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import { __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+export default {
+ name: 'ExperimentHeader',
+ components: {
+ DeleteButton,
+ GlButton,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ deleteInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ downloadCsv() {
+ const currentPath = window.location.pathname;
+ const currentSearch = window.location.search;
+
+ visitUrl(`${currentPath}.csv${currentSearch}`);
+ },
+ },
+ i18n: {
+ downloadAsCsvLabel: __('Download as CSV'),
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <h1 class="page-title gl-font-size-h-display flex-fill">{{ title }}</h1>
+
+ <gl-button @click="downloadCsv">{{ $options.i18n.downloadAsCsvLabel }}</gl-button>
+
+ <delete-button v-bind="deleteInfo" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
index ca0a42fda10..acb5fc7cad2 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
@@ -12,6 +12,7 @@ import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import ExperimentHeader from './components/experiment_header.vue';
import {
LIST_KEY_CREATED_AT,
BASE_SORT_FIELDS,
@@ -30,8 +31,13 @@ export default {
IncubationAlert,
RegistrySearch,
KeysetPagination,
+ ExperimentHeader,
},
props: {
+ experiment: {
+ type: Object,
+ required: true,
+ },
candidates: {
type: Array,
required: true,
@@ -124,6 +130,14 @@ export default {
hasItems() {
return this.candidates.length > 0;
},
+ deleteButtonInfo() {
+ return {
+ deletePath: this.experiment.path,
+ deleteConfirmationText: translations.DELETE_EXPERIMENT_CONFIRMATION_MESSAGE,
+ actionPrimaryText: translations.DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL,
+ modalTitle: translations.DELETE_EXPERIMENT_MODAL_TITLE,
+ };
+ },
},
methods: {
submitFilters() {
@@ -157,6 +171,8 @@ export default {
:link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
/>
+ <experiment-header :title="experiment.name" :delete-info="deleteButtonInfo" />
+
<registry-search
:filters="filters"
:sorting="sorting"
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
index 63b0d902b72..5c34a66921d 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
@@ -14,3 +14,8 @@ export const EMPTY_STATE_DESCRIPTION_LABEL = s__(
'MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client.',
);
export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No candidates');
+export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this experiment will also delete its candidates and their associated metadata.',
+);
+export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment');
+export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?');
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 4fdc08487f2..32e85262882 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -40,7 +40,7 @@ function prometheusMetricQueryParams(timeRange) {
* Extract error messages from API or HTTP request errors.
*
* - API errors are in `error.response.data.message`
- * - HTTP (axios) errors are in `error.messsage`
+ * - HTTP (axios) errors are in `error.message`
*
* @param {Object} error
* @returns {String} User friendly error message
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 0d849e1a2d8..5f4d2703d21 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -97,7 +97,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -107,13 +106,13 @@ export const generateLinkToChartOptions = (chartLink) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'generate_link_to_cluster_metric_chart'
: 'generate_link_to_metrics_chart';
- return { category, action, label: 'Chart link', property: chartLink };
+ return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings
};
/**
@@ -125,13 +124,13 @@ export const downloadCSVOptions = (title) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'download_csv_of_cluster_metric_chart'
: 'download_csv_of_metrics_dashboard_chart';
- return { category, action, label: 'Chart title', property: title };
+ return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings
};
/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 2488c8aee9c..d537117d962 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -41,7 +41,7 @@ export default () => {
apolloProvider,
provide: {
reportAbusePath: notesDataset.reportAbusePath,
- newSavedRepliesPath: notesDataset.savedRepliesNewPath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/actions.js b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
new file mode 100644
index 00000000000..4f8c3bb7f20
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
@@ -0,0 +1,5 @@
+import * as types from './mutation_types';
+
+export const setDrawer = ({ commit }, data) => {
+ return commit(types.default.SET_DRAWER, data);
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/getters.js b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
new file mode 100644
index 00000000000..dd61bc900fa
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
@@ -0,0 +1 @@
+export const activeDrawer = (state) => state.activeDrawer;
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/index.js b/app/assets/javascripts/mr_notes/stores/drawer/index.js
new file mode 100644
index 00000000000..c4a7eacb78a
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/index.js
@@ -0,0 +1,13 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+export default () => ({
+ namespaced: true,
+ state: {
+ activeDrawer: {},
+ },
+ mutations,
+ actions,
+ getters,
+});
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
new file mode 100644
index 00000000000..5fe8a9ba20d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
@@ -0,0 +1,3 @@
+export default {
+ SET_DRAWER: 'SET_DRAWER',
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutations.js b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
new file mode 100644
index 00000000000..eee43f2b316
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
@@ -0,0 +1,7 @@
+import types from './mutation_types';
+
+export default {
+ [types.SET_DRAWER](state, drawer) {
+ Object.assign(state, { activeDrawer: drawer });
+ },
+};
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 7527c685c71..1f8e61beff0 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -4,6 +4,7 @@ import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'
import diffsModule from '~/diffs/store/modules';
import notesModule from '~/notes/stores/modules';
import mrPageModule from './modules';
+import findingsDrawer from './drawer';
Vue.use(Vuex);
@@ -12,6 +13,7 @@ export const createModules = () => ({
notes: notesModule(),
diffs: diffsModule(),
batchComments: batchCommentsModule(),
+ findingsDrawer: findingsDrawer(),
});
export const createStore = () =>
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index ca6232fa4c4..5eb5e5b9b90 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -7,7 +7,7 @@ import Tracking from '~/tracking';
export default {
i18n: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
+ badgeLabel: s__('NorthstarNavigation|Beta'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'),
toggleLabel: s__('NorthstarNavigation|Toggle new navigation'),
@@ -51,7 +51,7 @@ export default {
Tracking.event(undefined, 'click_toggle', {
label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta',
- property: this.enabled ? 'navigation' : 'navigation_top',
+ property: this.enabled ? 'nav_user_menu' : 'navigation_top',
});
window.location.reload();
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index a7c2e572037..9d07f435620 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -10,12 +10,12 @@ export default class NewBranchForm {
}
addBinding() {
- this.name.addEventListener('blur', this.validate);
+ this.name.addEventListener('change', this.validate);
}
init() {
if (this.name != null && this.name.value.length > 0) {
- const event = new CustomEvent('blur');
+ const event = new CustomEvent('change');
this.name.dispatchEvent(event);
}
}
@@ -77,6 +77,7 @@ export default class NewBranchForm {
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
this.branchNameError.textContent = errors.join(', ');
+ this.name.focus();
}
}
}
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index 037be8467cb..c76ffce9168 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-return-assign */
export default class NewCommitForm {
constructor(form) {
this.form = form;
@@ -21,6 +20,6 @@ export default class NewCommitForm {
this.createMergeRequestContainer.hide();
this.createMergeRequest.prop('checked', false);
}
- return (this.wasDifferent = different);
+ this.wasDifferent = different;
}
}
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 64e801a7516..211a12208c1 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -51,7 +51,7 @@ export default {
language="python"
:code="code"
:max-height="maxHeight"
- class="gl-border"
+ class="gl-border gl-p-4!"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue
new file mode 100644
index 00000000000..4fe02ee6edf
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue
@@ -0,0 +1,46 @@
+<script>
+import JSONTable from '~/behaviors/components/json_table.vue';
+import Prompt from '../prompt.vue';
+import { convertHtmlTableToJson } from './dataframe_util';
+
+export default {
+ name: 'DataframeOutput',
+ components: {
+ Prompt,
+ JSONTable,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ showOutput() {
+ return this.index === 0;
+ },
+ dataframeAsJSONTable() {
+ return {
+ ...convertHtmlTableToJson(this.rawCode),
+ caption: '',
+ hasFilter: true,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" :show-output="showOutput" />
+ <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
new file mode 100644
index 00000000000..2fdaaced0b9
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
@@ -0,0 +1,44 @@
+import { sanitize } from '~/lib/dompurify';
+
+/**
+ * Converts a dataframe in the output of a Jupyter Notebook cell to a json object
+ *
+ * @param {string} input - the dataframe
+ * @param {DOMParser} parser - the html parser
+ * @returns {Object} The converted JSON object with an `items` property containing the rows.
+ */
+export function convertHtmlTableToJson(input, domParser) {
+ const parser = domParser || new DOMParser();
+ const htmlDoc = parser.parseFromString(sanitize(input), 'text/html');
+
+ if (!htmlDoc) return { fields: [], items: [] };
+
+ const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map(
+ (head) => head.innerText,
+ );
+
+ if (!columnNames) return { fields: [], items: [] };
+
+ const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) =>
+ [...row.querySelectorAll('td')].map((item) => item.innerText),
+ );
+
+ return {
+ fields: columnNames.map((column) => ({
+ key: column === '' ? 'index' : column,
+ label: column,
+ sortable: true,
+ })),
+ items: itemValues.map((values, itemIndex) => ({
+ index: itemIndex,
+ ...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])),
+ })),
+ };
+}
+
+export function isDataframe(output) {
+ const htmlData = output.data['text/html'];
+ if (!htmlData) return false;
+
+ return htmlData.slice(0, 20).some((line) => line.includes('dataframe'));
+}
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 22bcb5dd66a..0437b85913b 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -5,6 +5,8 @@ import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
import MarkdownOutput from './markdown.vue';
import ErrorOutput from './error.vue';
+import DataframeOutput from './dataframe.vue';
+import { isDataframe } from './dataframe_util';
const TEXT_MARKDOWN = 'text/markdown';
const ERROR_OUTPUT_TYPE = 'error';
@@ -66,6 +68,8 @@ export default {
return ImageOutput;
} else if (output.data['image/jpeg']) {
return ImageOutput;
+ } else if (isDataframe(output)) {
+ return DataframeOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['text/latex']) {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4bcddb260e1..6794f838c84 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -7,6 +7,7 @@ import { createAlert } from '~/alert';
import { badgeState } from '~/issuable/components/status_box.vue';
import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
capitalizeFirstCharacter,
convertToCamelCase,
@@ -81,6 +82,9 @@ export default {
'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noteableDisplayName() {
const displayNameMap = {
[constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue,
@@ -224,7 +228,7 @@ export default {
handleSaveDraft() {
this.handleSave({ isDraft: true });
},
- handleSave({ withIssueAction = false, isDraft = false } = {}) {
+ async handleSave({ withIssueAction = false, isDraft = false } = {}) {
this.errors = [];
if (this.note.length) {
@@ -246,6 +250,13 @@ export default {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
+ if (containsSensitiveToken(this.note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return;
+ }
+ }
+
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.stopPolling();
@@ -363,6 +374,7 @@ export default {
:form-field-props="formFieldProps"
:autosave-key="autosaveKey"
:disabled="isSubmitting"
+ :autocomplete-data-sources="autocompleteDataSources"
supports-quick-actions
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleEnter()"
@@ -399,10 +411,10 @@ export default {
{{ $options.i18n.internal }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
- name="question"
+ name="question-o"
:size="16"
:title="$options.i18n.internalVisibility"
- class="gl-text-gray-500"
+ class="gl-text-blue-500"
/>
</gl-form-checkbox>
<comment-type-dropdown
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 37935e9c3c6..ba5ffc60917 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -157,7 +157,7 @@ export default {
v-if="resolveAllDiscussionsIssuePath && !allResolved"
:href="resolveAllDiscussionsIssuePath"
>
- {{ __('Create issue to resolve all threads') }}
+ {{ __('Resolve all with new issue') }}
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 39b3df899a5..d02327a37a7 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -28,7 +28,9 @@ export default {
class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"
data-qa-selector="discussion_filter_container"
>
- <div class="timeline-icon d-none d-lg-flex">
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
<gl-icon name="comment" />
</div>
<div class="timeline-content gl-pl-8">
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 89cd252b94b..bce17aebd64 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -21,7 +21,7 @@ export default {
editCommentLabel: __('Edit comment'),
deleteCommentLabel: __('Delete comment'),
moreActionsLabel: __('More actions'),
- reportAbuse: __('Report abuse to administrator'),
+ reportAbuse: __('Report abuse'),
},
name: 'NoteActions',
components: {
@@ -367,7 +367,7 @@ export default {
@click="closeTooltip"
/>
<!-- eslint-enable @gitlab/vue-no-data-toggle -->
- <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
+ <ul class="dropdown-menu more-actions-dropdown dropdown-menu-right">
<gl-dropdown-item
v-if="canEdit"
class="js-note-edit gl-sm-display-none!"
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 21841680cab..9c04a72375b 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -35,9 +35,6 @@ export default {
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
- addButtonClass() {
- return this.isAuthoredByMe ? 'js-user-authored' : '';
- },
},
methods: {
...mapActions(['toggleAwardRequest']),
@@ -64,7 +61,6 @@ export default {
:awards="awards"
:can-award-emoji="canAwardEmoji"
:current-user-id="getUserData.id"
- :add-button-class="addButtonClass"
@award="handleAward($event)"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index eef011db7d2..b4e5129ca0e 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -5,7 +5,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import autosave from '../mixins/autosave';
import NoteAttachment from './note_attachment.vue';
import NoteAwardsList from './note_awards_list.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -22,7 +21,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [autosave],
props: {
note: {
type: Object,
@@ -96,21 +94,9 @@ export default {
},
mounted() {
this.renderGFM();
-
- if (this.isEditing) {
- this.initAutoSave(this.note);
- }
},
updated() {
this.renderGFM();
-
- if (this.isEditing) {
- if (!this.autosave) {
- this.initAutoSave(this.note);
- } else {
- this.setAutoSave();
- }
- }
},
methods: {
...mapActions([
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b6ede10d02b..34ae0c7ffc1 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,10 +1,10 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -15,13 +15,14 @@ export default {
i18n: COMMENT_FORM,
name: 'NoteForm',
components: {
- MarkdownField,
+ MarkdownEditor,
CommentFieldLayout,
GlButton,
GlSprintf,
GlLink,
+ GlFormCheckbox,
},
- mixins: [issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()],
props: {
noteBody: {
type: String,
@@ -95,20 +96,22 @@ export default {
},
},
data() {
- let updatedNoteBody = this.noteBody;
-
- if (!updatedNoteBody && this.autosaveKey) {
- updatedNoteBody = getDraft(this.autosaveKey) || '';
- }
-
return {
- updatedNoteBody,
+ updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: this.resolveDiscussion,
isUnresolving: !this.resolveDiscussion,
resolveAsThread: true,
isSubmittingWithKeydown: false,
+ formFieldProps: {
+ id: 'note_note',
+ name: 'note[note]',
+ 'aria-label': __('Reply to comment'),
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form',
+ 'data-qa-selector': 'reply_field',
+ },
};
},
computed: {
@@ -123,6 +126,9 @@ export default {
withBatchComments: (state) => state.batchComments?.withBatchComments,
}),
...mapGetters('batchComments', ['hasDrafts']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
showBatchCommentsActions() {
return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
},
@@ -135,11 +141,6 @@ export default {
.some((n) => n.current_user?.can_resolve_discussion) || this.isDraft
);
},
- textareaPlaceholder() {
- return this.discussionNote?.internal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
noteHash() {
if (this.noteId) {
return `#note_${this.noteId}`;
@@ -214,6 +215,9 @@ export default {
placeholder: { link: ['startTag', 'endTag'] },
};
},
+ enableContentEditor() {
+ return Boolean(this.glFeatures.contentEditorOnIssues);
+ },
},
watch: {
noteBody() {
@@ -225,7 +229,7 @@ export default {
},
},
mounted() {
- this.$refs.textarea.focus();
+ this.updatePlaceholder();
},
methods: {
...mapActions(['toggleResolveNote']),
@@ -252,19 +256,21 @@ export default {
},
cancelHandler(shouldConfirm = false) {
// check if any dropdowns are active before sending the cancelation event
- if (!this.$refs.textarea.classList.contains('at-who-active')) {
+ if (
+ !this.$refs.markdownEditor.$el
+ .querySelector('textarea')
+ ?.classList.contains('at-who-active')
+ ) {
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
}
},
- onInput() {
- if (this.isSubmittingWithKeydown) {
- return;
- }
-
- if (this.autosaveKey) {
- const { autosaveKey, updatedNoteBody: text } = this;
- updateDraft(autosaveKey, text);
- }
+ updatePlaceholder() {
+ this.formFieldProps.placeholder = this.discussionNote?.internal
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
+ onInput(value) {
+ this.updatedNoteBody = value;
},
handleKeySubmit() {
if (this.showBatchCommentsActions) {
@@ -273,6 +279,7 @@ export default {
this.isSubmittingWithKeydown = true;
this.handleUpdate();
}
+ this.updatedNoteBody = '';
},
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
@@ -333,53 +340,47 @@ export default {
:noteable-data="getNoteableData"
:is-internal-note="discussion.internal"
>
- <markdown-field
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="enableContentEditor"
+ :value="updatedNoteBody"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:lines="lines"
- :note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
+ :note="discussionNote"
+ :form-field-props="formFieldProps"
:show-suggest-popover="showSuggestPopover"
- :textarea-value="updatedNoteBody"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :autosave-key="autosaveKey"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :disabled="isSubmitting"
+ supports-quick-actions
+ autofocus
+ @keydown.meta.enter="handleKeySubmit()"
+ @keydown.ctrl.enter="handleKeySubmit()"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
+ @input="onInput"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
- >
- <template #textarea>
- <textarea
- id="note_note"
- ref="textarea"
- v-model="updatedNoteBody"
- :disabled="isSubmitting"
- data-supports-quick-actions="true"
- name="note[note]"
- class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
- data-qa-selector="reply_field"
- dir="auto"
- :aria-label="__('Reply to comment')"
- :placeholder="textareaPlaceholder"
- @keydown.meta.enter="handleKeySubmit()"
- @keydown.ctrl.enter="handleKeySubmit()"
- @keydown.exact.up="editMyLastNote()"
- @keydown.exact.esc="cancelHandler(true)"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
<label>
<template v-if="discussionResolved">
- <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" />
- {{ __('Unresolve thread') }}
+ <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox">
+ {{ __('Unresolve thread') }}
+ </gl-form-checkbox>
</template>
<template v-else>
- <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" />
- {{ __('Resolve thread') }}
+ <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox">
+ {{ __('Resolve thread') }}
+ </gl-form-checkbox>
</template>
</label>
</p>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 60ae573bae7..3375e366ecf 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -11,6 +11,7 @@ import { s__, __, sprintf } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -207,12 +208,21 @@ export default {
this.isReplying = false;
clearDraft(this.autosaveKey);
}),
- saveReply(noteText, form, callback) {
+ async saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
callback();
return;
}
+
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 80025d6f98a..ae2f94a5a80 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -13,6 +13,7 @@ import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -294,10 +295,9 @@ export default {
this.isRequesting = false;
this.oldContent = null;
renderGFM(this.$refs.noteBody.$el);
- this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
- formUpdateHandler({ noteText, callback, resolveDiscussion }) {
+ async formUpdateHandler({ noteText, callback, resolveDiscussion }) {
const position = {
...this.note.position,
};
@@ -320,6 +320,14 @@ export default {
if (this.isDraft) return;
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const data = {
endpoint: this.note.path,
note: {
@@ -383,7 +391,6 @@ export default {
});
if (!confirmed) return;
}
- this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
// eslint-disable-next-line vue/no-mutating-props
this.note.note_html = this.oldContent;
diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue
index 9c3b2139a5d..95c02884ace 100644
--- a/app/assets/javascripts/notes/components/notes_activity_header.vue
+++ b/app/assets/javascripts/notes/components/notes_activity_header.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div
- class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5"
+ class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-pb-3"
>
<h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2>
<div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0">
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index b884c6b6d19..cf7207d260d 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import NotesApp from './components/notes_app.vue';
@@ -58,7 +59,8 @@ export default () => {
provide: {
showTimelineViewToggle,
reportAbusePath: notesDataset.reportAbusePath,
- newSavedRepliesPath: notesDataset.savedRepliesNewPath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id),
},
data() {
return {
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
deleted file mode 100644
index 17272d5abef..00000000000
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { s__ } from '~/locale';
-import Autosave from '~/autosave';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- methods: {
- initAutoSave(noteable, extraKeys = []) {
- let keys = [
- s__('Autosave|Note'),
- capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
- noteable.id,
- ];
-
- if (extraKeys) {
- keys = keys.concat(extraKeys);
- }
-
- this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys);
- },
- resetAutoSave() {
- this.autosave.reset();
- },
- setAutoSave() {
- this.autosave.save();
- },
- disposeAutoSave() {
- this.autosave.dispose();
- },
- },
-};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 3dbcf28d11c..90de7db8c1b 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -63,6 +63,7 @@ function getPreviousDiscussion() {
function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
const discussion = getDiscussion();
+
if (!isOverviewPage() && !discussion) {
window.mrTabs?.eventHub.$once('NotesAppReady', () => {
handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions);
@@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
window.mrTabs?.tabShown('show', undefined, false);
return;
}
- const id = discussion.dataset.discussionId;
- ctx.expandDiscussion({ discussionId: id });
- scrollToElement(discussion, scrollOptions);
+
+ if (discussion) {
+ const id = discussion.dataset.discussionId;
+ ctx.expandDiscussion({ discussionId: id });
+ scrollToElement(discussion, scrollOptions);
+ }
}
export default {
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index 14e97fcef46..9a1323cdaf2 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { marked } from 'marked';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
@@ -8,9 +7,9 @@ import { markdownConfig } from '~/lib/utils/text_utility';
* @param {Boolean} enabled that will be send as a property for the event
*/
export const trackToggleTimelineView = (enabled) => ({
- category: 'Incident Management',
+ category: 'Incident Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'toggle_incident_comments_into_timeline_view',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
property: enabled,
});
diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
new file mode 100644
index 00000000000..c4a928c5e07
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlButton, GlModal } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+import {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ RENEW_SECRET_FAILURE,
+ RENEW_SECRET_SUCCESS,
+ WARNING_NO_SECRET,
+} from '../constants';
+
+export default {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ name: 'OAuthSecret',
+ components: {
+ GlButton,
+ GlModal,
+ InputCopyToggleVisibility,
+ },
+ inject: ['initialSecret', 'renewPath'],
+ data() {
+ return {
+ secret: this.initialSecret,
+ alert: null,
+ isModalVisible: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ text: this.$options.RENEW_SECRET,
+ attributes: {
+ variant: 'confirm',
+ loading: this.isLoading,
+ },
+ };
+ },
+ },
+ created() {
+ if (!this.secret) {
+ this.alert = createAlert({ message: WARNING_NO_SECRET, variant: VARIANT_WARNING });
+ }
+ },
+ methods: {
+ displayModal() {
+ this.isModalVisible = true;
+ },
+ async renewSecret(event) {
+ event.preventDefault();
+ this.isLoading = true;
+ this.alert?.dismiss();
+
+ try {
+ const { data } = await axios.put(this.renewPath);
+ this.alert = createAlert({ message: RENEW_SECRET_SUCCESS, variant: VARIANT_SUCCESS });
+ this.secret = data.secret;
+ } catch {
+ this.alert = createAlert({ message: RENEW_SECRET_FAILURE });
+ } finally {
+ this.isLoading = false;
+ this.isModalVisible = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <input-copy-toggle-visibility
+ v-if="secret"
+ :copy-button-title="$options.COPY_SECRET"
+ :value="secret"
+ class="gl-mt-n3 gl-mb-0"
+ >
+ <template #description>
+ {{ $options.DESCRIPTION_SECRET }}
+ </template>
+ </input-copy-toggle-visibility>
+
+ <gl-button category="secondary" class="gl-align-self-start" @click="displayModal">{{
+ $options.RENEW_SECRET
+ }}</gl-button>
+
+ <gl-modal
+ v-model="isModalVisible"
+ :title="$options.CONFIRM_MODAL_TITLE"
+ size="sm"
+ modal-id="modal-renew-secret"
+ :action-primary="actionPrimary"
+ @primary="renewSecret"
+ >
+ {{ $options.CONFIRM_MODAL }}
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/oauth_application/constants.js b/app/assets/javascripts/oauth_application/constants.js
new file mode 100644
index 00000000000..5eaacadda78
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/constants.js
@@ -0,0 +1,20 @@
+import { __, s__ } from '~/locale';
+
+export const CONFIRM_MODAL = s__(
+ 'AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab.',
+);
+export const CONFIRM_MODAL_TITLE = s__('AuthorizedApplication|Renew secret?');
+export const COPY_SECRET = __('Copy secret');
+export const DESCRIPTION_SECRET = __(
+ 'This is the only time the secret is accessible. Copy the secret and store it securely.',
+);
+export const RENEW_SECRET = s__('AuthorizedApplication|Renew secret');
+export const RENEW_SECRET_FAILURE = s__(
+ 'AuthorizedApplication|There was an error trying to renew the application secret. Please try again.',
+);
+export const RENEW_SECRET_SUCCESS = s__(
+ 'AuthorizedApplication|Application secret was successfully renewed.',
+);
+export const WARNING_NO_SECRET = __(
+ 'The secret is only available when you create the application or renew the secret.',
+);
diff --git a/app/assets/javascripts/oauth_application/index.js b/app/assets/javascripts/oauth_application/index.js
new file mode 100644
index 00000000000..f8f1f647a15
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import OAuthSecret from './components/oauth_secret.vue';
+
+export const initOAuthApplicationSecret = () => {
+ const el = document.querySelector('#js-oauth-application-secret');
+
+ if (!el) {
+ return null;
+ }
+
+ const { initialSecret, renewPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'OAuthSecretRoot',
+ provide: { initialSecret, renewPath },
+ render(h) {
+ return h(OAuthSecret);
+ },
+ });
+};
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 5d77ff9dc0d..4e154870f55 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
@@ -4,9 +4,10 @@ import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
- UPDATED_AT,
+ CREATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
CLEANUP_ONGOING_TEXT,
@@ -65,11 +66,11 @@ export default {
visibilityIcon() {
return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
},
- timeAgo() {
- return this.timeFormatted(this.imageDetails.updatedAt);
+ formattedCreatedAtDate() {
+ return formatDate(this.imageDetails.createdAt, 'mmm d, yyyy HH:MM', true);
},
- updatedText() {
- return sprintf(UPDATED_AT, { time: this.timeAgo });
+ createdText() {
+ return sprintf(CREATED_AT, { time: this.formattedCreatedAtDate });
},
tagCountText() {
if (this.$apollo.queries.containerRepository.loading) {
@@ -145,9 +146,9 @@ export default {
<template #metadata-updated>
<metadata-item
:icon="visibilityIcon"
- :text="updatedText"
+ :text="createdText"
size="xl"
- data-testid="updated-and-visibility"
+ data-testid="created-and-visibility"
/>
</template>
<template #right-actions>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 863d1c2629b..a1c837a4add 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -3,12 +3,16 @@ import { GlEmptyState } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
-
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
+ ALERT_SUCCESS_TAG,
+ ALERT_DANGER_TAG,
+ ALERT_SUCCESS_TAGS,
+ ALERT_DANGER_TAGS,
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE,
@@ -20,19 +24,22 @@ import {
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import TagsListRow from './tags_list_row.vue';
+import DeleteModal from './delete_modal.vue';
export default {
name: 'TagsList',
components: {
+ DeleteModal,
GlEmptyState,
TagsListRow,
TagsLoader,
RegistryList,
PersistedSearch,
},
+ mixins: [Tracking.mixin()],
inject: ['config'],
-
props: {
id: {
type: [Number, String],
@@ -77,6 +84,8 @@ export default {
return {
containerRepository: {},
filters: {},
+ itemsToBeDeleted: [],
+ mutationLoading: false,
sort: null,
};
},
@@ -88,7 +97,7 @@ export default {
return this.containerRepository?.tags?.nodes || [];
},
hideBulkDelete() {
- return !(this.containerRepository?.canDelete || false);
+ return !this.containerRepository?.canDelete;
},
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
@@ -105,7 +114,12 @@ export default {
return this.tags.length === 0;
},
isLoading() {
- return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort;
+ return (
+ this.isImageLoading ||
+ this.$apollo.queries.containerRepository.loading ||
+ this.mutationLoading ||
+ !this.sort
+ );
},
hasFilters() {
return this.filters?.name;
@@ -116,17 +130,61 @@ export default {
emptyStateDescription() {
return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE;
},
+ tracking() {
+ return {
+ label:
+ this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
},
methods: {
+ deleteTags(toBeDeleted) {
+ this.itemsToBeDeleted = toBeDeleted;
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ confirmDelete() {
+ this.handleDeleteTag();
+ },
+ async handleDeleteTag() {
+ this.track('confirm_delete');
+ const { itemsToBeDeleted } = this;
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteContainerRepositoryTagsMutation,
+ variables: {
+ id: this.queryVariables.id,
+ tagNames: itemsToBeDeleted.map((item) => item.name),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getContainerRepositoryTagsQuery,
+ variables: this.queryVariables,
+ },
+ ],
+ });
+ if (data?.destroyContainerRepositoryTags?.errors[0]) {
+ throw new Error();
+ }
+ this.$emit(
+ 'delete',
+ itemsToBeDeleted.length === 1 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS,
+ );
+ this.itemsToBeDeleted = [];
+ } catch (e) {
+ this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS);
+ } finally {
+ this.mutationLoading = false;
+ }
+ },
fetchNextPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
fetchPreviousPage() {
@@ -136,9 +194,6 @@ export default {
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
handleSearchUpdate({ sort, filters }) {
@@ -193,7 +248,7 @@ export default {
id-property="name"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
- @delete="$emit('delete', $event)"
+ @delete="deleteTags"
>
<template #default="{ selectItem, isSelected, item, first }">
<tags-list-row
@@ -203,10 +258,17 @@ export default {
:is-mobile="isMobile"
:disabled="disabled"
@select="selectItem(item)"
- @delete="$emit('delete', [item])"
+ @delete="deleteTags([item])"
/>
</template>
</registry-list>
+
+ <delete-modal
+ ref="deleteModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirmDelete="confirmDelete"
+ @cancel="track('cancel_delete')"
+ />
</template>
</template>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 7bb69363743..7ac803a8ece 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -65,7 +65,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
-export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
+export const CREATED_AT = s__('ContainerRegistry|Created %{time}');
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
index 9d0ecfd2dcb..71538ea5a07 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
@@ -1,14 +1,10 @@
import { s__ } from '~/locale';
-export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
- 'ContainerRegistry|Expiration policy will run in %{time}',
-);
-export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
- 'ContainerRegistry|Expiration policy is disabled.',
-);
+export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}');
+export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.');
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
- 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
+ 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}',
);
export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__(
'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}',
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
index 850dca07a3f..f9820df4a12 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
@@ -6,7 +6,6 @@ Vue.use(VueApollo);
export const mergeVariables = (existing, incoming) => {
if (!incoming) return existing;
- if (!existing) return incoming;
return incoming;
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index e2036d9e63d..eae663acb48 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -7,7 +7,6 @@ query getContainerRepositoryDetails($id: ContainerRepositoryID!) {
location
canDelete
createdAt
- updatedAt
expirationPolicyStartedAt
expirationPolicyCleanupStatus
project {
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 2b5fb1a70ed..c6ed4c06577 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
@@ -15,22 +15,14 @@ import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
import {
- ALERT_SUCCESS_TAG,
- ALERT_DANGER_TAG,
- ALERT_SUCCESS_TAGS,
- ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../constants/index';
-import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
-import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
-import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
export default {
name: 'RegistryDetailsPage',
@@ -76,7 +68,6 @@ export default {
mutationLoading: false,
deleteAlertType: null,
hidePartialCleanupWarning: false,
- deleteImageAlert: false,
};
},
computed: {
@@ -97,8 +88,7 @@ export default {
},
tracking() {
return {
- label:
- this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ label: 'registry_image_delete',
};
},
pageActionsAreDisabled() {
@@ -112,57 +102,8 @@ export default {
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
- deleteTags(toBeDeleted) {
- this.deleteImageAlert = false;
- this.itemsToBeDeleted = toBeDeleted;
- this.track('click_button');
- this.$refs.deleteModal.show();
- },
confirmDelete() {
- if (this.deleteImageAlert) {
- this.$refs.deleteImage.doDelete();
- } else {
- this.handleDeleteTag();
- }
- },
- async handleDeleteTag() {
- this.track('confirm_delete');
- const { itemsToBeDeleted } = this;
- this.itemsToBeDeleted = [];
- this.mutationLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: deleteContainerRepositoryTagsMutation,
- variables: {
- id: this.queryVariables.id,
- tagNames: itemsToBeDeleted.map((i) => i.name),
- },
- awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: getContainerRepositoryTagsQuery,
- variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
- },
- {
- query: getContainerRepositoriesDetails,
- variables: {
- fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
- isGroupPage: this.config.isGroupPage,
- },
- },
- ],
- });
-
- if (data?.destroyContainerRepositoryTags?.errors[0]) {
- throw new Error();
- }
- this.deleteAlertType =
- itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
- } catch (e) {
- this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
- }
-
- this.mutationLoading = false;
+ this.$refs.deleteImage.doDelete();
},
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
@@ -174,7 +115,6 @@ export default {
});
},
deleteImage() {
- this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ ...this.containerRepository }];
this.$refs.deleteModal.show();
},
@@ -185,6 +125,9 @@ export default {
this.itemsToBeDeleted = [];
this.mutationLoading = true;
},
+ showAlert(alertType) {
+ this.deleteAlertType = alertType;
+ },
},
};
</script>
@@ -222,7 +165,7 @@ export default {
:is-image-loading="isLoading"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
- @delete="deleteTags"
+ @delete="showAlert"
/>
<delete-image
@@ -237,7 +180,7 @@ export default {
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
- :delete-image="deleteImageAlert"
+ delete-image
@confirmDelete="confirmDelete"
@cancel="track('cancel_delete')"
/>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index b24ec65464f..d32e90f3adb 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -8,7 +8,6 @@ import {
GlFormInputGroup,
GlModal,
GlModalDirective,
- GlSkeletonLoader,
GlSprintf,
} from '@gitlab/ui';
import { __, s__, n__, sprintf } from '~/locale';
@@ -30,7 +29,6 @@ export default {
GlFormGroup,
GlFormInputGroup,
GlModal,
- GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
@@ -208,23 +206,20 @@ export default {
</template>
</gl-form-group>
- <gl-skeleton-loader v-if="$apollo.queries.group.loading" />
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :loading="$apollo.queries.group.loading"
+ :manifests="manifests"
+ :pagination="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
- <div v-else data-testid="main-area">
- <manifests-list
- v-if="manifests && manifests.length"
- :manifests="manifests"
- :pagination="pageInfo"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
- />
-
- <gl-empty-state
- v-else
- :svg-path="noManifestsIllustration"
- :title="$options.i18n.noManifestTitle"
- />
- </div>
+ <gl-empty-state
+ v-else
+ :svg-path="noManifestsIllustration"
+ :title="$options.i18n.noManifestTitle"
+ />
<gl-modal
:modal-id="$options.confirmClearCacheModal"
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
index 005c8feea3a..0d9b8330fe3 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlKeysetPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
@@ -8,6 +8,7 @@ export default {
components: {
ManifestRow,
GlKeysetPagination,
+ GlSkeletonLoader,
},
props: {
manifests: {
@@ -19,6 +20,11 @@ export default {
type: Object,
required: true,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: () => false,
+ },
},
i18n: {
listTitle: s__('DependencyProxy|Image list'),
@@ -34,19 +40,22 @@ export default {
<template>
<div class="gl-mt-6">
<h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
- <div
- class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
- >
- <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
- </div>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- v-bind="pagination"
- class="gl-mt-3"
- @prev="$emit('prev-page')"
- @next="$emit('next-page')"
- />
+ <gl-skeleton-loader v-if="loading" />
+ <div v-else data-testid="main-area">
+ <div
+ class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
+ >
+ <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
+ </div>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pagination"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 9bab08b8548..a9d076afb92 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
@@ -36,7 +36,7 @@ export default {
},
},
i18n: {
- LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+ LIST_TITLE_TEXT: s__('InfrastructureRegistry|Terraform Module Registry'),
LIST_INTRO_TEXT: s__(
'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
index b167fff26b0..f790c7b1430 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
@@ -1,39 +1,74 @@
<script>
-import { GlModal } from '@gitlab/ui';
-import { __, n__ } from '~/locale';
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
+ DELETE_MODAL_CONTENT,
+ DELETE_MODAL_TITLE,
+ DELETE_PACKAGES_MODAL_DESCRIPTION,
DELETE_PACKAGES_MODAL_TITLE,
DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'DeleteModal',
i18n: {
- DELETE_PACKAGES_MODAL_TITLE,
+ DELETE_MODAL_CONTENT,
+ DELETE_PACKAGES_MODAL_DESCRIPTION,
},
components: {
+ GlLink,
GlModal,
+ GlSprintf,
},
props: {
itemsToBeDeleted: {
type: Array,
required: true,
},
+ showRequestForwardingContent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- description() {
- return n__(
- 'PackageRegistry|You are about to delete 1 package. This operation is irreversible.',
- `PackageRegistry|You are about to delete %d packages. This operation is irreversible.`,
- this.itemsToBeDeleted.length,
- );
+ itemToBeDeleted() {
+ if (this.itemsToBeDeleted.length === 1) {
+ const [itemToBeDeleted] = this.itemsToBeDeleted;
+ return itemToBeDeleted;
+ }
+ return null;
+ },
+ title() {
+ return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE;
+ },
+ packagesDeletePrimaryActionProps() {
+ let text = DELETE_PACKAGE_MODAL_PRIMARY_ACTION;
+
+ if (this.showRequestForwardingContent) {
+ if (this.itemToBeDeleted) {
+ text = DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ } else {
+ text = DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ }
+ }
+ return {
+ text,
+ attributes: { variant: 'danger', category: 'primary' },
+ };
+ },
+ requestForwardingContentMessage() {
+ return this.itemToBeDeleted
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT;
},
},
modal: {
- packagesDeletePrimaryAction: {
- text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
- attributes: { variant: 'danger', category: 'primary' },
- },
cancelAction: {
text: __('Cancel'),
},
@@ -43,6 +78,9 @@ export default {
this.$refs.deleteModal.show();
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -51,12 +89,34 @@ export default {
ref="deleteModal"
size="sm"
modal-id="delete-packages-modal"
- :action-primary="$options.modal.packagesDeletePrimaryAction"
+ :action-primary="packagesDeletePrimaryActionProps"
:action-cancel="$options.modal.cancelAction"
- :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE"
+ :title="title"
@primary="$emit('confirm')"
@cancel="$emit('cancel')"
>
- <span>{{ description }}</span>
+ <p v-if="showRequestForwardingContent">
+ <gl-sprintf :message="requestForwardingContentMessage">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p v-else>
+ <gl-sprintf v-if="itemToBeDeleted" :message="$options.i18n.DELETE_MODAL_CONTENT">
+ <template #version>
+ <strong>{{ itemToBeDeleted.version }}</strong>
+ </template>
+
+ <template #name>
+ <strong>{{ itemToBeDeleted.name }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.i18n.DELETE_PACKAGES_MODAL_DESCRIPTION">
+ <template #count>
+ {{ itemsToBeDeleted.length }}
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index 3d5ac528920..7ea19df7a6c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -1,4 +1,6 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { n__ } from '~/locale';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
@@ -10,16 +12,20 @@ import {
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import Tracking from '~/tracking';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
export default {
components: {
DeleteModal,
DeletePackageModal,
+ GlAlert,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -31,33 +37,65 @@ export default {
required: false,
default: false,
},
- versions: {
- type: Array,
- required: true,
- default: () => [],
- },
- pageInfo: {
- type: Object,
- required: true,
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
},
- isLoading: {
+ isMutationLoading: {
type: Boolean,
required: false,
default: false,
},
+ packageId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
itemToBeDeleted: null,
itemsToBeDeleted: [],
+ packageVersions: {},
+ fetchPackageVersionsError: false,
};
},
+ apollo: {
+ packageVersions: {
+ query: getPackageVersionsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ skip() {
+ return this.isListEmpty;
+ },
+ update(data) {
+ return data.package?.versions ?? {};
+ },
+ error(error) {
+ this.fetchPackageVersionsError = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
+ isListEmpty() {
+ return this.count === 0;
+ },
+ isLoading() {
+ return this.$apollo.queries.packageVersions.loading || this.isMutationLoading;
+ },
+ pageInfo() {
+ return this.packageVersions?.pageInfo ?? {};
+ },
listTitle() {
return n__('%d version', '%d versions', this.versions.length);
},
- isListEmpty() {
- return this.versions.length === 0;
+ queryVariables() {
+ return {
+ id: this.packageId,
+ first: GRAPHQL_PAGE_SIZE,
+ };
},
tracking() {
const category = this.itemToBeDeleted
@@ -67,6 +105,9 @@ export default {
category,
};
},
+ versions() {
+ return this.packageVersions?.nodes ?? [];
+ },
},
methods: {
deleteItemConfirmation() {
@@ -101,6 +142,32 @@ export default {
this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
},
+ fetchPreviousVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ };
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ fetchNextVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo?.endCursor,
+ };
+
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ },
+ i18n: {
+ errorMessage: FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
},
};
</script>
@@ -109,6 +176,9 @@ export default {
<div v-if="isLoading">
<packages-list-loader />
</div>
+ <gl-alert v-else-if="fetchPackageVersionsError" variant="danger" :dismissible="false">{{
+ $options.i18n.errorMessage
+ }}</gl-alert>
<slot v-else-if="isListEmpty" name="empty-state"></slot>
<div v-else>
<registry-list
@@ -118,8 +188,8 @@ export default {
:pagination="pageInfo"
:title="listTitle"
@delete="setItemsToBeDeleted"
- @prev-page="$emit('prev-page')"
- @next-page="$emit('next-page')"
+ @prev-page="fetchPreviousVersionsPage"
+ @next-page="fetchNextVersionsPage"
>
<template #default="{ first, item, isSelected, selectItem }">
<version-row
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 193a222853f..37a6fe75f15 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -130,13 +130,14 @@ export default {
<template v-if="packageEntity.canDestroy" #right-action>
<gl-dropdown
+ data-testid="delete-dropdown"
icon="ellipsis_v"
:text="$options.i18n.moreActions"
:text-sr-only="true"
category="tertiary"
no-caret
>
- <gl-dropdown-item variant="danger" @click="$emit('delete')">{{
+ <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
$options.i18n.deletePackage
}}</gl-dropdown-item>
</gl-dropdown>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
index 440e11a99f2..05359128af4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -39,5 +39,8 @@ export default {
<template #metadata-amount>
<metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
</template>
+ <template #right-actions>
+ <slot name="settings-link"></slot>
+ </template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 486ab4fdc99..effed4891d8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,7 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
@@ -14,16 +13,24 @@ import {
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
PACKAGE_ERROR_STATUS,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
+const forwardingFieldToPackageTypeMapping = {
+ mavenPackageRequestsForwarding: PACKAGE_TYPE_MAVEN,
+ npmPackageRequestsForwarding: PACKAGE_TYPE_NPM,
+ pypiPackageRequestsForwarding: PACKAGE_TYPE_PYPI,
+};
+
export default {
name: 'PackagesList',
components: {
GlAlert,
DeleteModal,
- DeletePackageModal,
PackagesListLoader,
PackagesListRow,
RegistryList,
@@ -44,16 +51,27 @@ export default {
type: Object,
required: true,
},
+ groupSettings: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
- itemToBeDeleted: null,
itemsToBeDeleted: [],
errorPackages: [],
};
},
computed: {
+ itemToBeDeleted() {
+ if (this.itemsToBeDeleted.length === 1) {
+ const [itemToBeDeleted] = this.itemsToBeDeleted;
+ return itemToBeDeleted;
+ }
+ return null;
+ },
listTitle() {
return n__('%d package', '%d packages', this.list.length);
},
@@ -77,6 +95,15 @@ export default {
showErrorPackageAlert() {
return this.errorPackages.length > 0;
},
+ packageTypesWithForwardingEnabled() {
+ return Object.keys(this.groupSettings)
+ .filter((field) => this.groupSettings[field])
+ .map((field) => forwardingFieldToPackageTypeMapping[field]);
+ },
+ isRequestForwardingEnabled() {
+ const selectedPackageTypes = new Set(this.itemsToBeDeleted.map((item) => item.packageType));
+ return this.packageTypesWithForwardingEnabled.some((type) => selectedPackageTypes.has(type));
+ },
},
watch: {
list(newVal) {
@@ -88,40 +115,36 @@ export default {
this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : [];
},
methods: {
- setItemToBeDeleted(item) {
- this.itemToBeDeleted = { ...item };
- this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
- },
setItemsToBeDeleted(items) {
+ this.itemsToBeDeleted = items;
if (items.length === 1) {
- const [item] = items;
- this.setItemToBeDeleted(item);
- return;
+ this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
}
- this.itemsToBeDeleted = items;
- this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
},
deleteItemsConfirmation() {
this.$emit('delete', this.itemsToBeDeleted);
- this.track(DELETE_PACKAGES_TRACKING_ACTION);
+
+ if (this.itemToBeDeleted) {
+ this.track(DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGES_TRACKING_ACTION);
+ }
+
this.itemsToBeDeleted = [];
},
deleteItemsCanceled() {
- this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ }
this.itemsToBeDeleted = [];
},
- deleteItemConfirmation() {
- this.$emit('delete', [this.itemToBeDeleted]);
- this.track(DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
- deleteItemCanceled() {
- this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
showConfirmationModal() {
- this.setItemToBeDeleted(this.errorPackages[0]);
+ this.setItemsToBeDeleted([this.errorPackages[0]]);
},
},
i18n: {
@@ -165,21 +188,16 @@ export default {
:first="first"
:package-entity="item"
:selected="isSelected(item)"
- @delete="setItemToBeDeleted(item)"
+ @delete="setItemsToBeDeleted([item])"
@select="selectItem(item)"
/>
</template>
</registry-list>
- <delete-package-modal
- :item-to-be-deleted="itemToBeDeleted"
- @ok="deleteItemConfirmation"
- @cancel="deleteItemCanceled"
- />
-
<delete-modal
ref="deletePackagesModal"
:items-to-be-deleted="itemsToBeDeleted"
+ :show-request-forwarding-content="isRequestForwardingEnabled"
@confirm="deleteItemsConfirmation"
@cancel="deleteItemsCanceled"
/>
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 eda8d9e0066..ad5edcd7602 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -115,6 +115,10 @@ export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version';
export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version';
export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version';
+export const FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Failed to load version data',
+);
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -122,6 +126,21 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del
export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages');
export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
+export const DELETE_PACKAGES_MODAL_DESCRIPTION = s__(
+ 'PackageRegistry|You are about to delete %{count} packages. This operation is irreversible.',
+);
+export const DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete package',
+);
+export const DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete selected packages',
+);
+export const DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete the package anyway? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
+export const DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Some of the selected package formats allow request forwarding. Deleting a package while request forwarding is enabled for the project can pose a security risk. Do you want to proceed with deleting the selected packages? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
@@ -207,5 +226,9 @@ export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/inde
export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index');
export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index');
export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
new file mode 100644
index 00000000000..db05f497b7f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
@@ -0,0 +1,8 @@
+fragment GroupPackageSettings on Group {
+ id
+ packageSettings {
+ mavenPackageRequestsForwarding
+ npmPackageRequestsForwarding
+ pypiPackageRequestsForwarding
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index 56f95fa2c1f..39e5da54509 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -4,6 +4,27 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
+export const mergeVariables = (existing, incoming) => {
+ if (!incoming) return existing;
+ return incoming;
+};
+
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ PackageDetailsType: {
+ fields: {
+ versions: {
+ keyArgs: false,
+ merge: mergeVariables,
+ },
+ },
+ },
+ },
+ },
+ },
+ ),
});
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index b5313f929f8..99864f7ad0c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -1,10 +1,4 @@
-query getPackageDetails(
- $id: PackagesPackageID!
- $first: Int
- $last: Int
- $after: String
- $before: String
-) {
+query getPackageDetails($id: PackagesPackageID!) {
package(id: $id) {
id
name
@@ -62,31 +56,8 @@ query getPackageDetails(
downloadPath
}
}
- versions(after: $after, before: $before, first: $first, last: $last) {
+ versions {
count
- nodes {
- id
- name
- canDestroy
- createdAt
- version
- status
- _links {
- webPath
- }
- tags(first: 1) {
- nodes {
- id
- name
- }
- }
- }
- pageInfo {
- hasNextPage
- hasPreviousPage
- endCursor
- startCursor
- }
}
dependencyLinks {
nodes {
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
new file mode 100644
index 00000000000..a4119ac5821
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
@@ -0,0 +1,38 @@
+query getPackageVersions(
+ $id: PackagesPackageID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ package(id: $id) {
+ id
+ versions(after: $after, before: $before, first: $first, last: $last) {
+ count
+ nodes {
+ id
+ name
+ canDestroy
+ createdAt
+ packageType
+ version
+ status
+ _links {
+ webPath
+ }
+ tags(first: 1) {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
index 5bde5f08e56..f25f24cbc5f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -1,4 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getPackages(
@@ -32,6 +33,9 @@ query getPackages(
...PageInfo
}
}
+ group {
+ ...GroupPackageSettings
+ }
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
id
@@ -52,5 +56,6 @@ query getPackages(
...PageInfo
}
}
+ ...GroupPackageSettings
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
index 15ed98122a0..e2f8d239bae 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -19,6 +19,7 @@ export default () => {
npmInstanceUrl,
projectListUrl,
groupListUrl,
+ settingsPath,
} = el.dataset;
const isGroupPage = pageType === 'groups';
@@ -48,6 +49,7 @@ export default () => {
projectListUrl,
groupListUrl,
breadCrumbState,
+ settingsPath,
},
render(createElement) {
return createElement(PackageRegistry);
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 1ce2140894e..0f1c63a04ad 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
@@ -54,6 +54,7 @@ import {
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
import Tracking from '~/tracking';
export default {
@@ -135,7 +136,6 @@ export default {
queryVariables() {
return {
id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
- first: GRAPHQL_PAGE_SIZE,
};
},
packageFiles() {
@@ -147,9 +147,6 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
- isVersionsLoading() {
- return this.isLoading || this.versionsMutationLoading;
- },
packageFilesLoading() {
return this.isLoading || this.mutationLoading;
},
@@ -161,12 +158,12 @@ export default {
category: packageTypeToTrackCategory(this.packageType),
};
},
- versionPageInfo() {
- return this.packageEntity?.versions?.pageInfo ?? {};
- },
packageDependencies() {
return this.packageEntity.dependencyLinks?.nodes || [];
},
+ packageVersionsCount() {
+ return this.packageEntity.versions?.count ?? 0;
+ },
showDependencies() {
return this.packageType === PACKAGE_TYPE_NUGET;
},
@@ -190,6 +187,17 @@ export default {
},
];
},
+ refetchVersionsQueryData() {
+ return [
+ {
+ query: getPackageVersionsQuery,
+ variables: {
+ id: this.queryVariables.id,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ },
+ ];
+ },
},
methods: {
formatSize(size) {
@@ -274,34 +282,6 @@ export default {
resetDeleteModalContent() {
this.deletePackageModalContent = DELETE_MODAL_CONTENT;
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
- fetchPreviousVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: null,
- last: GRAPHQL_PAGE_SIZE,
- before: this.versionPageInfo?.startCursor,
- };
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
- fetchNextVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: GRAPHQL_PAGE_SIZE,
- last: null,
- after: this.versionPageInfo?.endCursor,
- };
-
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
},
i18n: {
DELETE_MODAL_TITLE,
@@ -403,12 +383,12 @@ export default {
<template #title>
<span>{{ $options.i18n.otherVersionsTabTitle }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{
- packageEntity.versions.count
+ packageVersionsCount
}}</gl-badge>
</template>
<delete-packages
- :refetch-queries="refetchQueriesData"
+ :refetch-queries="refetchVersionsQueryData"
show-success-alert
@start="versionsMutationLoading = true"
@end="versionsMutationLoading = false"
@@ -416,12 +396,10 @@ export default {
<template #default="{ deletePackages }">
<package-versions-list
:can-destroy="packageEntity.canDestroy"
- :is-loading="isVersionsLoading"
- :page-info="versionPageInfo"
- :versions="packageEntity.versions.nodes"
+ :count="packageVersionsCount"
+ :is-mutation-loading="versionsMutationLoading"
+ :package-id="packageEntity.id"
@delete="deletePackages"
- @prev-page="fetchPreviousVersionsPage"
- @next-page="fetchNextVersionsPage"
>
<template #empty-state>
<p class="gl-mt-3" data-testid="no-versions-message">
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 6e92a6420ac..044ce4e6413 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/alert';
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { historyReplaceState } from '~/lib/utils/common_utils';
@@ -19,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
GlSprintf,
@@ -27,23 +28,26 @@ export default {
PackageSearch,
DeletePackages,
},
- inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['emptyListIllustration', 'isGroupPage', 'fullPath', 'settingsPath'],
data() {
return {
- packages: {},
+ packagesResource: {},
sort: '',
filters: {},
mutationLoading: false,
};
},
apollo: {
- packages: {
+ packagesResource: {
query: getPackagesQuery,
variables() {
return this.queryVariables;
},
update(data) {
- return data[this.graphqlResource]?.packages ?? {};
+ return data[this.graphqlResource] ?? {};
},
skip() {
return !this.sort;
@@ -51,6 +55,14 @@ export default {
},
},
computed: {
+ packages() {
+ return this.packagesResource?.packages ?? {};
+ },
+ groupSettings() {
+ return this.isGroupPage
+ ? this.packagesResource?.packageSettings ?? {}
+ : this.packagesResource?.group?.packageSettings ?? {};
+ },
queryVariables() {
return {
isGroupPage: this.isGroupPage,
@@ -83,7 +95,7 @@ export default {
: this.$options.i18n.noResultsTitle;
},
isLoading() {
- return this.$apollo.queries.packages.loading || this.mutationLoading;
+ return this.$apollo.queries.packagesResource.loading || this.mutationLoading;
},
refetchQueriesData() {
return [
@@ -123,7 +135,7 @@ export default {
after: this.pageInfo?.endCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -136,7 +148,7 @@ export default {
before: this.pageInfo?.startCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -149,6 +161,7 @@ export default {
noResultsText: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
+ settingsText: s__('PackageRegistry|Configure in settings'),
},
links: {
EMPTY_LIST_HELP_URL,
@@ -159,7 +172,16 @@ export default {
<template>
<div>
- <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
+ <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount">
+ <template v-if="settingsPath" #settings-link>
+ <gl-button
+ v-gl-tooltip="$options.i18n.settingsText"
+ icon="settings"
+ :href="settingsPath"
+ :aria-label="$options.i18n.settingsText"
+ />
+ </template>
+ </package-title>
<package-search class="gl-mb-5" @update="handleSearchUpdate" />
<delete-packages
@@ -170,6 +192,7 @@ export default {
>
<template #default="{ deletePackages }">
<package-list
+ :group-settings="groupSettings"
:list="packages.nodes"
:is-loading="isLoading"
:page-info="pageInfo"
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
index b7d7f0aaca7..ab88d9e8936 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
@@ -1,12 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { isEqual } from 'lodash';
import {
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
PACKAGE_FORWARDING_FORM_BUTTON,
PACKAGE_FORWARDING_FIELDS,
MAVEN_FORWARDING_FIELDS,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/settings/group/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
@@ -20,12 +22,15 @@ export default {
name: 'PackageForwardingSettings',
i18n: {
PACKAGE_FORWARDING_FORM_BUTTON,
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
},
components: {
ForwardingSettings,
GlButton,
+ GlLink,
+ GlSprintf,
SettingsBlock,
},
mixins: [glFeatureFlagsMixin()],
@@ -150,6 +155,9 @@ export default {
this.$set(this.workingCopy, type, value);
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -157,9 +165,14 @@ export default {
<settings-block>
<template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template>
<template #description>
- <span data-testid="description">
+ <span class="gl-display-block gl-mb-2" data-testid="description">
{{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }}
</span>
+ <gl-sprintf :message="$options.i18n.PACKAGE_FORWARDING_SECURITY_DESCRIPTION">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
<template #default>
<form @submit.prevent="submit">
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 b47759df35f..fa73c01c5c4 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -17,6 +17,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
+export const PACKAGE_FORWARDING_SECURITY_DESCRIPTION = s__(
+ 'PackageRegistry|There are security risks if packages are deleted while request forwarding is enabled. %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding');
export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.',
@@ -79,3 +82,7 @@ export const MAVEN_FORWARDING_FIELDS = {
// Parameters
export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
index 7a9ea7c0bf7..35fc0910a16 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
@@ -8,7 +8,7 @@ import {
export default {
i18n: {
- toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ toggleLabel: s__('ContainerRegistry|Enable cleanup policy'),
},
components: {
GlFormGroup,
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 731fb3e4c45..5f59372e5ba 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -1,6 +1,6 @@
import { s__, __ } from '~/locale';
-export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`);
+export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies');
export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
`ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
index 76623377d90..adffab277cc 100644
--- a/app/assets/javascripts/packages_and_registries/shared/utils.js
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -55,15 +55,6 @@ export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) =>
RegistryBreadcrumb,
},
render(createElement) {
- // FIXME(@tnir): this is a workaround until the MR gets merged:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
- const parentEl = breadCrumbEl.parentElement.parentElement;
- if (parentEl) {
- parentEl.classList.remove('breadcrumbs-container');
- parentEl.classList.add('gl-display-flex');
- parentEl.classList.add('w-100');
- }
- // End of FIXME(@tnir)
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js
index feceeb0b10a..ea7c9042e6d 100644
--- a/app/assets/javascripts/pages/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/abuse_reports/index.js
@@ -1,3 +1,5 @@
import { initLinkToSpam } from '~/abuse_reports';
+import initFilePickers from '~/file_pickers';
initLinkToSpam();
+initFilePickers();
diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js
index 3397b02aeba..df9e38431b0 100644
--- a/app/assets/javascripts/pages/admin/applications/index.js
+++ b/app/assets/javascripts/pages/admin/applications/index.js
@@ -1,3 +1,5 @@
import initApplicationDeleteButtons from '~/admin/applications';
+import { initOAuthApplicationSecret } from '~/oauth_application';
initApplicationDeleteButtons();
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
index 72cfc005782..72cfc005782 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
index 3bc785ee1b6..3bc785ee1b6 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js
index cfde1fc0a2b..84be895e194 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
+++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js
@@ -1,4 +1,5 @@
import { s__, __ } from '~/locale';
+import { DEFAULT_FIELDS } from '~/jobs/components/table/constants';
export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
@@ -10,3 +11,11 @@ export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed');
export const CANCEL_JOBS_WARNING = s__(
"AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?",
);
+
+/* Admin Table constants */
+export const DEFAULT_FIELDS_ADMIN = [
+ ...DEFAULT_FIELDS.slice(0, 2),
+ { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' },
+ { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' },
+ ...DEFAULT_FIELDS.slice(2),
+];
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
new file mode 100644
index 00000000000..b89e311ff1d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
@@ -0,0 +1,118 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { queryToObject } from '~/lib/utils/url_utility';
+import { validateQueryString } from '~/jobs/components/filtered_search/utils';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
+import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue';
+import { DEFAULT_FIELDS_ADMIN } from '../constants';
+import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql';
+
+export default {
+ i18n: {
+ jobsFetchErrorMsg: __('There was an error fetching the jobs.'),
+ },
+ components: {
+ JobsTableEmptyState,
+ GlAlert,
+ JobsTable,
+ JobsTableTabs,
+ },
+ inject: {
+ jobStatuses: {
+ default: null,
+ },
+ url: {
+ default: '',
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetAllJobs,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {};
+ return {
+ list,
+ pageInfo,
+ count,
+ };
+ },
+ error() {
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ list: [],
+ },
+ error: '',
+ count: 0,
+ scope: null,
+ infiniteScrollingTriggered: false,
+ filterSearchTriggered: false,
+ DEFAULT_FIELDS_ADMIN,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ // Show when on All tab with no jobs
+ // Show only when not loading and filtered search has not been triggered
+ // So we don't show empty state when results are empty on a filtered search
+ showEmptyState() {
+ return (
+ this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered
+ );
+ },
+ variables() {
+ return { ...this.validatedQueryString };
+ },
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
+ jobsCount() {
+ return this.jobs.count;
+ },
+ },
+ watch: {
+ // this watcher ensures that the count on the all tab
+ // is not updated when switching to the finished tab
+ jobsCount(newCount) {
+ if (this.scope) return;
+
+ this.count = newCount;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''">
+ {{ error }}
+ </gl-alert>
+
+ <jobs-table-tabs :all-jobs-count="count" :loading="loading" />
+
+ <jobs-table-empty-state v-if="showEmptyState" />
+
+ <jobs-table
+ v-else
+ :jobs="jobs.list"
+ :table-fields="DEFAULT_FIELDS_ADMIN"
+ class="gl-table-no-top-border"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
new file mode 100644
index 00000000000..fd7ee2a6f8c
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
@@ -0,0 +1,62 @@
+import { isEqual } from 'lodash';
+
+export default {
+ typePolicies: {
+ Query: {
+ fields: {
+ jobs: {
+ keyArgs: ['statuses'],
+ },
+ },
+ },
+ CiJobConnection: {
+ merge(existing = {}, incoming, { args = {} }) {
+ if (incoming.nodes) {
+ let nodes;
+
+ const areNodesEqual = isEqual(existing.nodes, incoming.nodes);
+ const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses;
+ const { pageInfo } = incoming;
+
+ if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
+ if (areNodesEqual) {
+ if (incoming.pageInfo.hasNextPage) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
+ } else {
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
+ } else {
+ nodes = [...incoming.nodes];
+ }
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ return {
+ nodes: existing.nodes,
+ pageInfo: existing.pageInfo,
+ statuses: args.statuses,
+ };
+ },
+ },
+ },
+};
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
new file mode 100644
index 00000000000..374009efa15
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
@@ -0,0 +1,81 @@
+query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
+ count
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ artifacts {
+ nodes {
+ id
+ downloadPath
+ fileType
+ }
+ }
+ allowFailure
+ status
+ scheduledAt
+ manualJob
+ triggered
+ createdByTag
+ detailedStatus {
+ id
+ detailsPath
+ group
+ icon
+ label
+ text
+ tooltip
+ action {
+ id
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ pipeline {
+ id
+ project {
+ id
+ fullPath
+ webUrl
+ }
+ path
+ user {
+ id
+ webPath
+ avatarUrl
+ }
+ }
+ stage {
+ id
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ stuck
+ userPermissions {
+ readBuild
+ readJobArtifacts
+ updateBuild
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
deleted file mode 100644
index c5a0509b625..00000000000
--- a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-export default {
- inject: {
- jobStatuses: {
- default: null,
- },
- url: {
- default: '',
- },
- emptyStateSvgPath: {
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <div>{{ __('Jobs') }}</div>
-</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 9df52557212..9c2a255a1a3 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,11 +1,21 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import { CANCEL_JOBS_MODAL_ID } from './components/constants';
-import CancelJobsModal from './components/cancel_jobs_modal.vue';
-import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue';
+import createDefaultClient from '~/lib/graphql';
+import { CANCEL_JOBS_MODAL_ID } from '../components/constants';
+import CancelJobsModal from '../components/cancel_jobs_modal.vue';
+import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue';
+import cacheConfig from '../components/table/graphql/cache_config';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+const client = createDefaultClient({}, { cacheConfig });
+
+const apolloProvider = new VueApollo({
+ defaultClient: client,
+});
function initJobs() {
const buttonId = 'js-stop-jobs-button';
@@ -44,6 +54,7 @@ export function initAdminJobsApp() {
return new Vue({
el: containerEl,
+ apolloProvider,
provide: {
url,
emptyStateSvgPath,
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index dec06fe6f4d..721168f6140 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -9,6 +9,7 @@ import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import initConfirmDanger from '~/init_confirm_danger';
+import { initGroupSettingsReadme } from '~/groups/settings/init_group_settings_readme';
initFilePickers();
initConfirmDanger();
@@ -27,3 +28,5 @@ initProjectSelects();
initSearchSettings();
initCascadingSettingsLockPopovers();
+
+initGroupSettingsReadme();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 1b3c7ba5a52..2e71eced66f 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -23,7 +23,7 @@ const APP_OPTIONS = {
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'],
+ tokens: ['two_factor', 'with_inherited_permissions', 'enterprise', 'user_type'],
searchParam: 'search',
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 8b68cb5f3bf..513f4968dbd 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -11,6 +11,10 @@ export default {
NewNamespacePage,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
groupsUrl: {
type: String,
required: true,
@@ -44,6 +48,7 @@ export default {
{ text: s__('GroupsNew|New subgroup'), href: '#' },
]
: [
+ { text: s__('Navigation|Your work'), href: this.rootPath },
{ text: s__('GroupsNew|Groups'), href: this.groupsUrl },
{ text: s__('GroupsNew|New group'), href: '#' },
];
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index b16c5f3da9f..6227d5ff880 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -22,6 +22,7 @@ initFilePickers();
function initNewGroupCreation(el) {
const {
hasErrors,
+ rootPath,
groupsUrl,
parentGroupUrl,
parentGroupName,
@@ -33,6 +34,7 @@ function initNewGroupCreation(el) {
const props = {
groupsUrl,
+ rootPath,
parentGroupUrl,
parentGroupName,
importExistingGroupPath,
diff --git a/app/assets/javascripts/pages/groups/runners/new/index.js b/app/assets/javascripts/pages/groups/runners/new/index.js
new file mode 100644
index 00000000000..318643d95a4
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initGroupNewRunner } from '~/ci/runner/group_new_runner';
+
+initGroupNewRunner();
diff --git a/app/assets/javascripts/pages/groups/runners/register/index.js b/app/assets/javascripts/pages/groups/runners/register/index.js
new file mode 100644
index 00000000000..b02e33e21f2
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initGroupRegisterRunner } from '~/ci/runner/group_register_runner';
+
+initGroupRegisterRunner();
diff --git a/app/assets/javascripts/pages/groups/settings/applications/index.js b/app/assets/javascripts/pages/groups/settings/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 52124865bcc..dba65c7e791 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
@@ -7,11 +5,11 @@ import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
export default function initGroupDetails() {
- new ShortcutsNavigation();
+ new ShortcutsNavigation(); // eslint-disable-line no-new
initNotificationsDropdown();
- new ProjectsList();
+ new ProjectsList(); // eslint-disable-line no-new
initInviteMembersBanner();
initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 53bceb3a6f0..f6a4ca0f360 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,5 +1,6 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
+import { initGroupReadme } from '~/groups/init_group_readme';
import initReadMore from '~/read_more';
import initGroupDetails from '../shared/group_details';
@@ -7,3 +8,4 @@ leaveByUrl('group');
initGroupDetails();
initGroupOverviewTabs();
initReadMore();
+initGroupReadme();
diff --git a/app/assets/javascripts/pages/import/github/details/index.js b/app/assets/javascripts/pages/import/github/details/index.js
new file mode 100644
index 00000000000..44a85589c9d
--- /dev/null
+++ b/app/assets/javascripts/pages/import/github/details/index.js
@@ -0,0 +1,3 @@
+import initImportDetails from '~/import/details';
+
+initImportDetails();
diff --git a/app/assets/javascripts/pages/oauth/applications/index.js b/app/assets/javascripts/pages/oauth/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/oauth/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/profiles/comment_templates/index.js b/app/assets/javascripts/pages/profiles/comment_templates/index.js
new file mode 100644
index 00000000000..413816c29cc
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/comment_templates/index.js
@@ -0,0 +1,3 @@
+import { initCommentTemplates } from '~/comment_templates';
+
+initCommentTemplates();
diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js
deleted file mode 100644
index ef227b82172..00000000000
--- a/app/assets/javascripts/pages/profiles/saved_replies/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initSavedReplies } from '~/saved_replies';
-
-initSavedReplies();
diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js
index 4aa9b225790..df8f110a60d 100644
--- a/app/assets/javascripts/pages/projects/artifacts/index.js
+++ b/app/assets/javascripts/pages/projects/artifacts/index.js
@@ -1,3 +1,3 @@
-import { initArtifactsTable } from '~/artifacts/index';
+import { initArtifactsTable } from '~/ci/artifacts/index';
initArtifactsTable();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 02fcc6ea940..ec894586803 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -8,6 +8,7 @@ import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
+import ForkInfo from '~/repository/components/fork_info.vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
@@ -16,6 +17,7 @@ import createStore from '~/code_navigation/store';
import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
import RefSelector from '~/ref/components/ref_selector.vue';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -44,6 +46,7 @@ const initRefSwitcher = () => {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -58,7 +61,15 @@ const initRefSwitcher = () => {
initRefSwitcher();
if (viewBlobEl) {
- const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
+ const {
+ blobPath,
+ projectPath,
+ targetBranch,
+ originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -69,6 +80,9 @@ if (viewBlobEl) {
provide: {
targetBranch,
originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
},
render(createElement) {
return createElement(BlobContentViewer, {
@@ -87,6 +101,47 @@ if (viewBlobEl) {
initBlob();
}
+const initForkInfo = () => {
+ const forkEl = document.getElementById('js-fork-info');
+ if (!forkEl) {
+ return null;
+ }
+ const {
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ canSyncBranch,
+ aheadComparePath,
+ behindComparePath,
+ canUserCreateMrInFork,
+ createMrPath,
+ } = forkEl.dataset;
+ return new Vue({
+ el: forkEl,
+ apolloProvider,
+ render(h) {
+ return h(ForkInfo, {
+ props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ aheadComparePath,
+ behindComparePath,
+ canUserCreateMrInFork,
+ createMrPath,
+ },
+ });
+ },
+ });
+};
+
+initForkInfo();
+
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link');
if (statusLink) {
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index f871cd804e7..9a47a720709 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -18,11 +18,7 @@ import '~/sourcegraph/load';
import DiffStats from '~/diffs/components/diff_stats.vue';
import { initReportAbuse } from '~/projects/report_abuse';
-const hasPerfBar = document.querySelector('.with-performance-bar');
-const performanceHeight = hasPerfBar ? 35 : 0;
-initDiffStatsDropdown(
- (document.querySelector('.navbar-gitlab')?.offsetHeight ?? 0) + performanceHeight,
-);
+initDiffStatsDropdown(true);
new ZenMode();
new ShortcutsNavigation();
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index 760bf3f7131..5bcdd34e258 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -7,8 +7,7 @@ import syntaxHighlight from '~/syntax_highlight';
initCompareSelector();
new Diff(); // eslint-disable-line no-new
-const paddingTop = 16;
-initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+initDiffStatsDropdown(true);
GpgBadges.fetch();
syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
index 05a1bbc69ed..fe9f0c7e69f 100644
--- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -1,3 +1,3 @@
-import initCycleAnalytics from '~/analytics/cycle_analytics';
+import cycleAnalyticsAppBundle from 'ee_else_ce/analytics/cycle_analytics/bundle';
-initCycleAnalytics();
+cycleAnalyticsAppBundle();
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index 2718765ee23..3d81e77f879 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import MergeRequest from '~/merge_request';
import CompareApp from '~/merge_requests/components/compare_app.vue';
import { __ } from '~/locale';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
if (mrNewCompareNode) {
@@ -82,4 +84,6 @@ if (mrNewCompareNode) {
action: mrNewSubmitNode.dataset.mrSubmitAction,
});
initPipelines();
+ // eslint-disable-next-line no-new
+ new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index f8cb8b30250..6127adc3584 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,10 +1,11 @@
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import initCheckFormState from './check_form_state';
import initFormUpdate from './update_form';
@@ -72,3 +73,5 @@ initMergeRequest();
initFormUpdate();
initCheckFormState();
initTargetBranchSelector();
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index d4734b8842d..599fd225de9 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -4,19 +4,13 @@ import $ from 'jquery';
import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GLForm from '~/gl_form';
import LabelsSelect from '~/labels/labels_select';
-import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
export default () => {
new ShortcutsNavigation();
- new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
IssuableLabelSelector();
new LabelsSelect();
- new IssuableTemplateSelectors({
- warnTemplateOverride: true,
- });
mountMilestoneDropdown('[name="merge_request[milestone_id]"]');
};
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
index a90cabb3c68..f50763151ef 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -9,6 +9,7 @@ const initShowExperiment = () => {
}
const props = {
+ experiment: JSON.parse(element.dataset.experiment),
candidates: JSON.parse(element.dataset.candidates),
metricNames: JSON.parse(element.dataset.metrics),
paramNames: JSON.parse(element.dataset.params),
diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
index 414636f0a74..a669ea5baaf 100644
--- a/app/assets/javascripts/pages/projects/network/show/index.js
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -24,7 +24,7 @@ const initRefSwitcher = () => {
},
on: {
input(selectedRef) {
- visitUrl(joinPaths(networkRootPath, selectedRef));
+ visitUrl(joinPaths(networkRootPath, encodeURIComponent(selectedRef)));
},
},
});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 242c5a1a97b..eab4be4dcf1 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -176,7 +176,7 @@ export default {
<gl-icon
v-if="showDailyLimitMessage(option)"
v-gl-tooltip.hover
- name="question"
+ name="question-o"
:title="scheduleDailyLimitMsg"
/>
</gl-form-radio>
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 5f15a11e708..e6ee6b702bb 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -3,28 +3,10 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import initClonePanel from '~/clone_panel';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { serializeForm } from '~/lib/utils/forms';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-
-const BRANCH_REF_TYPE = 'heads';
-const TAG_REF_TYPE = 'tags';
-const BRANCH_GROUP_NAME = __('Branches');
-const TAG_GROUP_NAME = __('Tags');
export default class Project {
constructor() {
initClonePanel();
- // Ref switcher
- if (document.querySelector('.js-project-refs-dropdown')) {
- Project.initRefSwitcher();
- $('.project-refs-select').on('change', function () {
- return $(this).parents('form').trigger('submit');
- });
- }
$('.js-hide-no-ssh-message').on('click', function (e) {
setCookie('hide_no_ssh_message', 'false');
@@ -48,125 +30,4 @@ export default class Project {
static changeProject(url) {
return (window.location = url);
}
-
- static initRefSwitcher() {
- const refListItem = document.createElement('li');
- const refLink = document.createElement('a');
-
- refLink.href = '#';
-
- return $('.js-project-refs-dropdown').each(function () {
- const $dropdown = $(this);
- const selected = $dropdown.data('selected');
- const refType = $dropdown.data('refType');
- const fieldName = $dropdown.data('fieldName');
- const shouldVisit = Boolean($dropdown.data('visit'));
- const $form = $dropdown.closest('form');
- const path = $form.find('#path').val();
- const action = $form.attr('action');
- const linkTarget = mergeUrlParams(serializeForm($form[0]), action);
-
- return initDeprecatedJQueryDropdown($dropdown, {
- data(term, callback) {
- axios
- .get($dropdown.data('refsUrl'), {
- params: {
- ref: $dropdown.data('ref'),
- search: term,
- },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('An error occurred while getting projects'),
- }),
- );
- },
- selectable: true,
- filterable: true,
- filterRemote: true,
- filterByText: true,
- inputFieldName: $dropdown.data('inputFieldName'),
- fieldName,
- renderRow(ref, _, params) {
- const li = refListItem.cloneNode(false);
-
- const link = refLink.cloneNode(false);
-
- if (ref === selected) {
- // Check group and current ref type to avoid adding a class when tags and branches share the same name
- if (
- (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) ||
- (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) ||
- !refType
- ) {
- link.className = 'is-active';
- }
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
- if (ref.length > 0 && shouldVisit) {
- const urlParams = { [fieldName]: ref };
- if (params.group === BRANCH_GROUP_NAME) {
- urlParams.ref_type = BRANCH_REF_TYPE;
- } else {
- urlParams.ref_type = TAG_REF_TYPE;
- }
- link.href = mergeUrlParams(urlParams, linkTarget);
- }
-
- li.appendChild(link);
-
- return li;
- },
- id(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel(obj, $el) {
- return $el.text().trim();
- },
- clicked(options) {
- const { e } = options;
-
- if (!shouldVisit) {
- e.preventDefault();
- }
-
- // Some pages need to dynamically get the current path
- // so they can opt-in to JS getting the path from the
- // current URL by not setting a path in the dropdown form
- if (shouldVisit && path === undefined) {
- e.preventDefault();
-
- const selectedUrl = new URL(e.target.href);
- const loc = window.location.href;
-
- if (loc.includes('/-/')) {
- const currentRef = $dropdown.data('ref');
- // The split and startWith is to ensure an exact word match
- // and avoid partial match ie. currentRef is "dev" and loc is "development"
- const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1];
- const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/');
-
- if (doesPathContainRef) {
- // We are ignoring the url containing the ref portion
- // and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
- selectedUrl.searchParams.set('path', targetPath);
- selectedUrl.hash = window.location.hash;
- }
- }
-
- // Open in new window if "meta" key is pressed
- if (e.metaKey) {
- window.open(selectedUrl.href, '_blank');
- } else {
- window.location.href = selectedUrl.href;
- }
- }
- },
- });
- });
- }
}
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index d2263fa815d..087808c33da 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,3 +1,4 @@
+import 'bootstrap/js/dist/collapse';
import MirrorRepos from '~/mirrors/mirror_repos';
import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules';
import mountDefaultBranchSelector from '~/projects/settings/mount_default_branch_selector';
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
index 885b8ca8e12..d907b8a470d 100644
--- a/app/assets/javascripts/pages/projects/usage_quotas/index.js
+++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js
@@ -1,9 +1,21 @@
import initProjectStorage from '~/usage_quotas/storage/init_project_storage';
import initSearchSettings from '~/search_settings';
+import { GlTabsBehavior, HISTORY_TYPE_HASH } from '~/tabs';
+
+const initGlTabs = () => {
+ const tabsEl = document.getElementById('js-project-usage-quotas-tabs');
+ if (!tabsEl) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+};
const initVueApp = () => {
initProjectStorage('js-project-storage-count-app');
};
+initGlTabs();
initVueApp();
initSearchSettings();
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index b8de2757284..00f7c5d60d1 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -5,6 +5,7 @@ import LengthValidator from '~/validators/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
import EmailFormatValidator from '~/pages/sessions/new/email_format_validator';
import { initLanguageSwitcher } from '~/language_switcher';
+import { initTogglePasswordVisibility } from '~/authentication/password';
import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
@@ -19,3 +20,4 @@ Tracking.enableFormTracking({
});
initLanguageSwitcher();
+initTogglePasswordVisibility();
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index ee48543f0d2..bad8a7cedc6 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -14,7 +14,7 @@ export default class OAuthRememberMe {
}
bindEvents() {
- $('#remember_me', this.container).on('click', this.toggleRememberMe);
+ $('#remember_me_omniauth', this.container).on('click', this.toggleRememberMe);
}
toggleRememberMe(event) {
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 549c964cce4..3b38d715ea5 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -128,6 +128,9 @@ export default {
};
},
computed: {
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noContent() {
return !this.content.trim();
},
@@ -351,6 +354,8 @@ export default {
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
+ :enable-autocomplete="true"
+ :autocomplete-data-sources="autocompleteDataSources"
:drawio-enabled="true"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
diff --git a/app/assets/javascripts/pages/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
new file mode 100644
index 00000000000..41c78fbe3a6
--- /dev/null
+++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
@@ -0,0 +1,3 @@
+import initTimelogsApp from '~/time_tracking';
+
+initTimelogsApp();
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 dbca8bc9be7..fac070d6e47 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink, GlPopover } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { glEmojiTag } from '~/emoji';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -10,8 +11,10 @@ import RequestSelector from './request_selector.vue';
export default {
components: {
+ GlPopover,
AddRequest,
DetailedMetric,
+ GlLink,
RequestSelector,
},
directives: {
@@ -30,6 +33,10 @@ export default {
type: String,
required: true,
},
+ requestMethod: {
+ type: String,
+ required: true,
+ },
peekUrl: {
type: String,
required: true,
@@ -72,6 +79,11 @@ export default {
keys: ['request', 'body'],
},
{
+ metric: 'zkt',
+ header: s__('PerformanceBar|Zoekt calls'),
+ keys: ['request', 'body'],
+ },
+ {
metric: 'external-http',
title: 'external',
header: s__('PerformanceBar|External Http calls'),
@@ -103,9 +115,6 @@ export default {
this.currentRequestId = requestId;
},
},
- initialRequest() {
- return this.currentRequestId === this.requestId;
- },
hasHost() {
return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host;
},
@@ -124,24 +133,47 @@ export default {
const fileName = this.requests[0].displayName;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
+ showZoekt() {
+ return document.body.dataset.page === 'search:show';
+ },
+ showFlamegraphButtons() {
+ return this.isGetRequest(this.currentRequestId);
+ },
+ showMemoryReportButton() {
+ return this.isGetRequest(this.currentRequestId) && this.env === 'development';
+ },
memoryReportPath() {
- return mergeUrlParams({ performance_bar: 'memory' }, window.location.href);
+ return mergeUrlParams(
+ { performance_bar: 'memory' },
+ this.store.findRequest(this.currentRequestId).fullUrl,
+ );
},
},
+ created() {
+ if (!this.showZoekt) {
+ this.$options.detailedMetrics = this.$options.detailedMetrics.filter(
+ (item) => item.metric !== 'zkt',
+ );
+ }
+ },
mounted() {
this.currentRequest = this.requestId;
},
methods: {
+ glEmojiTag,
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
this.$emit('change-request', newRequestId);
},
- flamegraphPath(mode) {
+ flamegraphPath(mode, requestId) {
return mergeUrlParams(
{ performance_bar: 'flamegraph', stackprof_mode: mode },
- window.location.href,
+ this.store.findRequest(requestId).fullUrl,
);
},
+ isGetRequest(requestId) {
+ return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET';
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -159,8 +191,17 @@ export default {
class="current-host"
:class="{ canary: currentRequest.details.host.canary }"
>
- <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
- {{ currentRequest.details.host.hostname }}
+ <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
+ <gl-popover placement="bottom" target="canary-emoji" content="Canary" />
+ <span
+ id="host-emoji"
+ v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')"
+ ></span>
+ <gl-popover
+ placement="bottom"
+ target="host-emoji"
+ :content="currentRequest.details.host.hostname"
+ />
</span>
</div>
<detailed-metric
@@ -177,41 +218,45 @@ export default {
id="peek-view-trace"
class="view"
>
- <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
+ <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
s__('PerformanceBar|Trace')
- }}</a>
+ }}</gl-link>
</div>
<div v-if="currentRequest.details" id="peek-download" class="view">
- <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{
- s__('PerformanceBar|Download')
- }}</a>
+ <gl-link
+ class="gl-text-blue-200"
+ is-unsafe-link
+ :download="downloadName"
+ :href="downloadPath"
+ >{{ s__('PerformanceBar|Download') }}</gl-link
+ >
</div>
- <div
- v-if="currentRequest.details && env === 'development'"
- id="peek-memory-report"
- class="view"
- >
- <a class="gl-text-blue-200" :href="memoryReportPath">{{
+ <div v-if="showMemoryReportButton" id="peek-memory-report" class="view">
+ <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{
s__('PerformanceBar|Memory report')
- }}</a>
+ }}</gl-link>
</div>
- <div v-if="currentRequest.details" id="peek-flamegraph" class="view">
- <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
- <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
+ <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view">
+ <span id="flamegraph-emoji" class="gl-text-white-200">
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span>
+ </span>
+ <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" />
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{
s__('PerformanceBar|wall')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{
s__('PerformanceBar|cpu')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{
s__('PerformanceBar|object')
- }}</a>
+ }}</gl-link>
</div>
- <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
+ <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
s__('PerformanceBar|Stats')
- }}</a>
+ }}</gl-link>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 84fe14fe056..e3e48e61393 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -32,15 +32,21 @@ const initPerformanceBar = (el) => {
store,
env: performanceBarData.env,
requestId: performanceBarData.requestId,
+ requestMethod: performanceBarData.requestMethod,
peekUrl: performanceBarData.peekUrl,
- profileUrl: performanceBarData.profileUrl,
statsUrl: performanceBarData.statsUrl,
};
},
mounted() {
PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest);
- this.addRequest(this.requestId, window.location.href);
+ this.addRequest(
+ this.requestId,
+ window.location.href,
+ undefined,
+ undefined,
+ this.requestMethod,
+ );
this.loadRequestDetails(this.requestId);
},
beforeDestroy() {
@@ -56,12 +62,12 @@ const initPerformanceBar = (el) => {
this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- addRequest(requestId, requestUrl, operationName) {
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
- this.store.addRequest(requestId, requestUrl, operationName);
+ this.store.addRequest(requestId, requestUrl, operationName, requestParams, methodVerb);
},
loadRequestDetails(requestId) {
const request = this.store.findRequest(requestId);
@@ -145,8 +151,8 @@ const initPerformanceBar = (el) => {
store: this.store,
env: this.env,
requestId: this.requestId,
+ requestMethod: this.requestMethod,
peekUrl: this.peekUrl,
- profileUrl: this.profileUrl,
statsUrl: this.statsUrl,
},
on: {
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index e67143f3ede..3a9788d8ab6 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -14,11 +14,13 @@ export default class PerformanceBarService {
fireCallback,
requestId,
requestUrl,
+ requestParams,
operationName,
+ methodVerb,
] = PerformanceBarService.callbackParams(response, peekUrl);
if (fireCallback) {
- callback(requestId, requestUrl, operationName);
+ callback(requestId, requestUrl, operationName, requestParams, methodVerb);
}
return response;
@@ -35,11 +37,14 @@ export default class PerformanceBarService {
static callbackParams(response, peekUrl) {
const requestId = response.headers && response.headers['x-request-id'];
const requestUrl = response.config?.url;
+ const requestParams = response.config?.params;
+ const methodVerb = response.config?.method;
+
const cachedResponse =
response.headers && parseBoolean(response.headers['x-gitlab-from-cache']);
const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse;
const operationName = response.config?.operationName;
- return [fireCallback, requestId, requestUrl, operationName];
+ return [fireCallback, requestId, requestUrl, requestParams, operationName, methodVerb];
}
}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 2011604534c..34e2763a478 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -1,11 +1,22 @@
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
export default class PerformanceBarStore {
constructor() {
this.requests = [];
}
- addRequest(requestId, requestUrl, operationName) {
- if (!this.findRequest(requestId)) {
- let displayName = PerformanceBarStore.truncateUrl(requestUrl);
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
+ if (this.findRequest(requestId)) {
+ this.updateRequestBatchedQueriesCount(requestId);
+ } else {
+ let displayName = '';
+
+ if (methodVerb) {
+ displayName += `${methodVerb.toUpperCase()} `;
+ }
+
+ displayName += PerformanceBarStore.truncateUrl(requestUrl);
if (operationName) {
displayName += ` (${operationName})`;
@@ -14,13 +25,31 @@ export default class PerformanceBarStore {
this.requests.push({
id: requestId,
url: requestUrl,
+ fullUrl: mergeUrlParams(requestParams, requestUrl),
+ method: methodVerb,
details: {},
+ queriesInBatch: 1, // only for GraphQL
displayName,
});
}
return this.requests;
}
+ updateRequestBatchedQueriesCount(requestId) {
+ const existingRequest = this.findRequest(requestId);
+ existingRequest.queriesInBatch += 1;
+
+ const oldDisplayName = existingRequest.displayName;
+ const regex = /\d+ queries batched/;
+ if (regex.test(oldDisplayName)) {
+ existingRequest.displayName = oldDisplayName.replace(
+ regex,
+ `${existingRequest.queriesInBatch} queries batched`,
+ );
+ } else {
+ existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`);
+ }
+ }
findRequest(requestId) {
return this.requests.find((request) => request.id === requestId);
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 3130fe42c3c..c9f43e43b2d 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -24,6 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-geo-migrate-hashed-storage-callout',
'.js-unlimited-members-during-trial-alert',
'.js-branch-rules-info-callout',
+ '.js-license-check-deprecation-alert',
];
const initCallouts = () => {
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 8f76d7535f1..83cd64c17ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -255,7 +255,7 @@ export default {
this.canRefetchHeaderPipeline = true;
this.$apollo.queries.headerPipeline.refetch();
},
- /* eslint-disable @gitlab/require-i18n-strings */
+ // eslint-disable-next-line @gitlab/require-i18n-strings
reportFailure({ type, err = 'No error string passed.', skipSentry = false }) {
this.showAlert = true;
this.alertType = type;
@@ -263,7 +263,6 @@ export default {
reportToSentry(this.$options.name, `type: ${type}, info: ${err}`);
}
},
- /* eslint-enable @gitlab/require-i18n-strings */
updateShowLinksState(val) {
this.showLinks = val;
},
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 6d8c35f4482..73143c981ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -62,6 +62,7 @@ export default {
},
showTip() {
return (
+ this.showLinksToggle &&
this.showLinks &&
this.showLinksActive &&
!this.tipPreviouslyDismissed &&
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 3da792cb9df..54985a24593 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -35,7 +35,6 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => {
return layers;
};
-/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
return {
fetchOptions: {
@@ -52,6 +51,7 @@ const getQueryHeaders = (etagResource) => {
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
return `
${message}.
Locations: ${locations
@@ -74,14 +74,12 @@ const serializeLoadErrors = (errors) => {
}
if (!isEmpty(networkError)) {
- return `Network error: ${networkError.message}`;
+ return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings
}
return message;
};
-/* eslint-enable @gitlab/require-i18n-strings */
-
const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
const stopStartQuery = (query) => {
if (!Visibility.hidden()) {
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
index 16f6aa5aaa4..21b585933b8 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
@@ -13,7 +13,7 @@ export default {
FailedJobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
@@ -31,7 +31,7 @@ export default {
query: GetFailedJobsQuery,
variables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index 661de43fe3c..61748860983 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -17,7 +17,7 @@ export default {
JobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
@@ -56,7 +56,7 @@ export default {
computed: {
queryVariables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
iid: this.pipelineIid,
};
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
index 03a2eac89e4..a6297213402 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
@@ -1,19 +1,8 @@
<script>
-import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import {
- STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
- I18N,
-} from '~/ci/pipeline_editor/constants';
+import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants';
import Tracking from '~/tracking';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { isExperimentVariant } from '~/experimentation/utils';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import CiTemplates from './ci_templates.vue';
export default {
@@ -21,19 +10,12 @@ export default {
GlButton,
GlCard,
GlSprintf,
- GlIcon,
- GlLink,
- GitlabExperiment,
CiTemplates,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
I18N,
- inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'],
+ inject: ['pipelineEditorPath'],
data() {
return {
gettingStartedTemplateUrl: mergeUrlParams(
@@ -43,26 +25,12 @@ export default {
tracker: null,
};
},
- computed: {
- sharedRunnersHelpPagePath() {
- return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' });
- },
- runnersAvailabilitySectionExperimentEnabled() {
- return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
- },
- created() {
- this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
methods: {
trackEvent(template) {
this.track('template_clicked', {
label: template,
});
},
- trackExperimentEvent(action) {
- this.tracker.event(action);
- },
},
};
</script>
@@ -70,92 +38,42 @@ export default {
<div>
<h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
- <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME">
- <template #candidate>
- <div v-if="anyRunnersAvailable">
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" />
- {{ $options.I18N.runners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.runners.subtitle">
- <template #settingsLink="{ content }">
- <gl-link
- data-testid="settings-link"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- <template #docsLink="{ content }">
- <gl-link
- data-testid="documentation-link"
- :href="sharedRunnersHelpPagePath"
- @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </p>
- </div>
-
- <div v-else>
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" />
- {{ $options.I18N.noRunners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p>
- <gl-button
- data-testid="settings-button"
- category="primary"
- variant="confirm"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)"
- >
- {{ $options.I18N.noRunners.cta }}
- </gl-button>
- </div>
- </template>
- </gitlab-experiment>
-
- <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable">
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
- <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
- <gl-card>
- <div class="gl-flex-direction-row">
- <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800 gl-mb-2">
- {{ $options.I18N.learnBasics.gettingStarted.title }}
- </strong>
- </div>
- <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">
+ {{ $options.I18N.learnBasics.gettingStarted.title }}
+ </strong>
</div>
+ <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ </div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="gettingStartedTemplateUrl"
- data-testid="test-template-link"
- @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
- >
- {{ $options.I18N.learnBasics.gettingStarted.cta }}
- </gl-button>
- </gl-card>
- </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="gettingStartedTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
+ >
+ {{ $options.I18N.learnBasics.gettingStarted.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ci-templates />
- </template>
+ <ci-templates />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index dd62ffb27f7..caeee7edefe 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -36,12 +36,10 @@ export default {
};
},
computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
+ hasActions() {
+ return (
+ this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions
+ );
},
isCancelling() {
return this.cancelingPipeline === this.pipeline.id;
@@ -75,7 +73,7 @@ export default {
<template>
<div class="gl-text-right">
<div class="btn-group">
- <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" />
+ <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" />
<gl-button
v-if="pipeline.flags.retryable"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index fe2ef2c2d71..7ad12d397e5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -144,14 +144,16 @@ export default {
<tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
:href="commitUrl"
- class="commit-row-message gl-text-gray-900"
+ class="commit-row-message gl-font-weight-bold gl-text-gray-900"
data-testid="commit-title"
@click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
>
</tooltip-on-truncate>
</span>
- <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
+ <span v-else class="gl-text-gray-500">{{
+ __("Can't find HEAD commit for this branch")
+ }}</span>
</div>
<div class="gl-mb-2">
<gl-link
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 640129b9c4c..c5537b7ad54 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -346,7 +346,7 @@ export default {
</div>
<div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex">
- <div class="row-content-block gl-display-flex gl-flex-grow-1">
+ <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0">
<pipelines-filtered-search
class="gl-display-flex gl-flex-grow-1 gl-mr-4"
:project-id="projectId"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 50d34070e61..262e82677a7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -8,8 +8,10 @@ import Tracking from '~/tracking';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
+import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql';
export default {
+ name: 'PipelinesManualActions',
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -18,22 +20,52 @@ export default {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLoadingIcon,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath', 'manualActionsLimit'],
props: {
- actions: {
- type: Array,
+ iid: {
+ type: Number,
required: true,
},
},
+ apollo: {
+ actions: {
+ query: getPipelineActionsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ limit: this.manualActionsLimit,
+ };
+ },
+ skip() {
+ return !this.hasDropdownBeenShown;
+ },
+ update({ project }) {
+ return project?.pipeline?.jobs?.nodes || [];
+ },
+ },
+ },
data() {
return {
isLoading: false,
+ actions: [],
+ hasDropdownBeenShown: false,
};
},
+ computed: {
+ isActionsLoading() {
+ return this.$apollo.queries.actions.loading;
+ },
+ isDropdownLimitReached() {
+ return this.actions.length === this.manualActionsLimit;
+ },
+ },
methods: {
async onClickAction(action) {
- if (action.scheduled_at) {
+ if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
@@ -54,12 +86,12 @@ export default {
* Ideally, the component would not make an api call directly.
* However, in order to use the eventhub and know when to
* toggle back the `isLoading` property we'd need an ID
- * to track the request with a wacther - since this component
+ * to track the request with a watcher - since this component
* is rendered at least 20 times in the same page, moving the
* api call directly here is the most performant solution
*/
axios
- .post(`${action.path}.json`)
+ .post(`${action.playPath}.json`)
.then(() => {
this.isLoading = false;
eventHub.$emit('updateTable');
@@ -69,12 +101,12 @@ export default {
createAlert({ message: __('An error occurred while making the request.') });
});
},
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ fetchActions() {
+ this.hasDropdownBeenShown = true;
+
+ this.$apollo.queries.actions.refetch();
- return !action.playable;
+ this.trackClick();
},
trackClick() {
this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table });
@@ -91,21 +123,37 @@ export default {
right
lazy
icon="play"
- @shown="trackClick"
+ @shown="fetchActions"
>
+ <gl-dropdown-item v-if="isActionsLoading">
+ <div class="gl-display-flex">
+ <gl-loading-icon class="mr-2" />
+ <span>{{ __('Loading...') }}</span>
+ </div>
+ </gl-dropdown-item>
+
<gl-dropdown-item
v-for="action in actions"
- :key="action.path"
- :disabled="isActionDisabled(action)"
+ v-else
+ :key="action.id"
+ :disabled="!action.canPlayJob"
@click="onClickAction(action)"
>
<div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
{{ action.name }}
- <span v-if="action.scheduled_at">
+ <span v-if="action.scheduledAt">
<gl-icon name="clock" />
- <gl-countdown :end-date-string="action.scheduled_at" />
+ <gl-countdown :end-date-string="action.scheduledAt" />
</span>
</div>
</gl-dropdown-item>
+
+ <template #footer>
+ <gl-dropdown-item v-if="isDropdownLimitReached">
+ <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg">
+ {{ __('Showing first 50 actions.') }}
+ </span>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 365572f194b..b2da0df17c0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -15,7 +15,7 @@ import PipelinesStatusBadge from './pipelines_status_badge.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
const DEFAULT_TH_CLASSES =
- 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!';
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 960af030421..e15676849da 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -41,7 +41,7 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column time-ago">
+ <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago">
<span
v-if="showInProgress"
class="gl-display-inline-flex gl-align-items-center"
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
new file mode 100644
index 00000000000..d1878c01e91
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
@@ -0,0 +1,24 @@
+query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs(
+ first: $limit
+ whenExecuted: ["manual", "delayed"]
+ retried: false
+ statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED]
+ ) {
+ nodes {
+ id
+ name
+ canPlayJob
+ manualJob
+ scheduledAt
+ scheduled
+ playPath
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index d94602c23b4..27debec7bb3 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -29,7 +29,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeLicenseScanningData,
failedJobsCount,
failedJobsSummary,
- fullPath,
+ projectPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
@@ -50,8 +50,6 @@ export const createAppOptions = (selector, apolloProvider, router) => {
testsCount,
} = dataset;
- // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved
- const projectPath = fullPath;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
return {
@@ -83,7 +81,6 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData),
failedJobsCount,
failedJobsSummary: JSON.parse(failedJobsSummary),
- fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 6dccdb1a3e6..49e2e1644e2 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -1,5 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import {
parseBoolean,
historyReplaceState,
@@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store';
Vue.use(Translate);
Vue.use(GlToast);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const el = document.querySelector(selector);
@@ -38,22 +45,22 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
projectId,
defaultBranchName,
params,
- ciRunnerSettingsPath,
- anyRunnersAvailable,
iosRunnersAvailable,
registrationToken,
+ fullPath,
} = el.dataset;
return new Vue({
el,
+ apolloProvider,
provide: {
pipelineEditorPath,
artifactsEndpoint,
artifactsEndpointPlaceholder,
suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
- ciRunnerSettingsPath,
- anyRunnersAvailable: parseBoolean(anyRunnersAvailable),
iosRunnersAvailable: parseBoolean(iosRunnersAvailable),
+ fullPath,
+ manualActionsLimit: 50,
},
data() {
return {
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index 76fb13919df..21f8a2d3500 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,18 +1,44 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
+ personalProjects: s__('UserProfile|Personal projects'),
+ viewAll: s__('UserProfile|View all'),
+ },
+ components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList },
+ props: {
+ personalProjects: {
+ type: Array,
+ required: true,
+ },
+ personalProjectsLoading: {
+ type: Boolean,
+ required: true,
+ },
},
- components: { GlTab, ActivityCalendar },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
<activity-calendar />
+ <div class="gl-mx-n3 gl-display-flex gl-flex-wrap">
+ <div class="gl-px-3 gl-w-full gl-lg-w-half"></div>
+ <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
+ <div
+ class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <h4 class="gl-flex-grow-1">{{ $options.i18n.personalProjects }}</h4>
+ <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
+ </div>
+ <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="personalProjects" />
+ </div>
+ </div>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index b39bfabb832..25b94d7dc7f 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -1,6 +1,10 @@
<script>
import { GlTabs } from '@gitlab/ui';
+import { getUserProjects } from '~/rest_api';
+import { s__ } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
import OverviewTab from './overview_tab.vue';
import ActivityTab from './activity_tab.vue';
import GroupsTab from './groups_tab.vue';
@@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue';
import FollowingTab from './following_tab.vue';
export default {
+ i18n: {
+ personalProjectsErrorMessage: s__(
+ 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.',
+ ),
+ },
components: {
GlTabs,
OverviewTab,
@@ -62,6 +71,22 @@ export default {
component: FollowingTab,
},
],
+ inject: ['userId'],
+ data() {
+ return {
+ personalProjectsLoading: true,
+ personalProjects: [],
+ };
+ },
+ async mounted() {
+ try {
+ const response = await getUserProjects(this.userId, { per_page: 10 });
+ this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true });
+ this.personalProjectsLoading = false;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.personalProjectsErrorMessage });
+ }
+ },
};
</script>
@@ -72,6 +97,8 @@ export default {
v-for="{ key, component } in $options.tabs"
:key="key"
class="container-fluid container-limited"
+ :personal-projects="personalProjects"
+ :personal-projects-loading="personalProjectsLoading"
/>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 050b004f657..107bfd159dd 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -3,6 +3,8 @@
import $ from 'jquery';
import 'cropper';
import { isString } from 'lodash';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
import { loadCSSFile } from '../lib/utils/css_utils';
(() => {
@@ -139,11 +141,20 @@ import { loadCSSFile } from '../lib/utils/css_utils';
}
readFile(input) {
- const _this = this;
const reader = new FileReader();
reader.onload = () => {
- _this.modalCropImg.attr('src', reader.result);
- return _this.modalCrop.modal('show');
+ this.modalCropImg.attr('src', reader.result);
+ import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal')
+ .then(() => {
+ this.modalCrop.modal('show');
+ })
+ .catch(() => {
+ createAlert({
+ message: s__(
+ 'UserProfile|Failed to set avatar. Please reload the page to try again.',
+ ),
+ });
+ });
};
return reader.readAsDataURL(input.files[0]);
}
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index fbe0e3534d8..101e52c873e 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -13,15 +13,17 @@ export const initProfileTabs = () => {
if (!el) return false;
- const { followees, followers, userCalendarPath, utcOffset } = el.dataset;
+ const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset;
return new Vue({
el,
+ name: 'ProfileRoot',
provide: {
followees: parseInt(followers, 10),
followers: parseInt(followees, 10),
userCalendarPath,
utcOffset,
+ userId,
},
render(createElement) {
return createElement(ProfileTabs);
diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
index 0fd31381ba6..a4edc988d67 100644
--- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -93,7 +93,7 @@ export default {
data-testid="email-patches-link"
data-qa-selector="email_patches"
>
- {{ s__('DownloadCommit|Email Patches') }}
+ {{ __('Patches') }}
</gl-dropdown-item>
<gl-dropdown-item
:href="plainDiffPath"
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index f56884f605f..3179fcb14fd 100644
--- a/app/assets/javascripts/projects/commits/index.js
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -35,6 +35,10 @@ export const initCommitsRefSwitcher = () => {
const { projectId, ref, commitsPath, refType } = el.dataset;
const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0];
+ const generateRefDestinationUrl = (selectedRef, selectedRefType) => {
+ const commitsPathSuffix = selectedRefType ? `?ref_type=${selectedRefType}` : '';
+ return `${commitsPathPrefix}/${encodeURIComponent(selectedRef)}${commitsPathSuffix}`;
+ };
const useSymbolicRefNames = Boolean(refType);
return new Vue({
el,
@@ -48,15 +52,11 @@ export const initCommitsRefSwitcher = () => {
},
on: {
input(selected) {
- if (useSymbolicRefNames) {
- const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
- if (matches) {
- visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`);
- } else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
- }
+ const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
+ if (useSymbolicRefNames && matches) {
+ visitUrl(generateRefDestinationUrl(matches[2], matches[1]));
} else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
+ visitUrl(generateRefDestinationUrl(selected));
}
},
},
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index a44855c14d5..fb201576e85 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -121,4 +121,8 @@ export default {
text: s__('ProjectTemplates|TYPO3 Distribution'),
icon: '.template-option .icon-typo3',
},
+ laravel: {
+ text: s__('ProjectTemplates|Laravel Framework'),
+ icon: '.template-option .icon-laravel',
+ },
};
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 1599661505f..0a160a357e5 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -59,6 +59,10 @@ export default {
SafeHtml,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
projectsUrl: {
type: String,
required: true,
@@ -92,12 +96,14 @@ export default {
computed: {
initialBreadcrumbs() {
- return [
- this.parentGroupUrl
- ? { text: this.parentGroupName, href: this.parentGroupUrl }
- : { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
- { text: s__('ProjectsNew|New project'), href: '#' },
- ];
+ const breadcrumbs = this.parentGroupUrl
+ ? [{ text: this.parentGroupName, href: this.parentGroupUrl }]
+ : [
+ { text: s__('Navigation|Your work'), href: this.rootPath },
+ { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
+ ];
+ breadcrumbs.push({ text: s__('ProjectsNew|New project'), href: '#' });
+ return breadcrumbs;
},
availablePanels() {
return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL);
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 7330874eefe..5ec50355a82 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -18,6 +18,7 @@ export function initNewProjectCreation() {
parentGroupUrl,
parentGroupName,
projectsUrl,
+ rootPath,
} = el.dataset;
const props = {
@@ -27,6 +28,7 @@ export function initNewProjectCreation() {
parentGroupUrl,
parentGroupName,
projectsUrl,
+ rootPath,
};
const provide = {
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
index b0abe7ac463..dbcb77b67f3 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
@@ -187,6 +187,7 @@ export default {
:roles="pushAccessLevels.roles"
:users="pushAccessLevels.users"
:groups="pushAccessLevels.groups"
+ data-qa-selector="allowed_to_push_content"
/>
<!-- Allowed to merge -->
@@ -197,6 +198,7 @@ export default {
:roles="mergeAccessLevels.roles"
:users="mergeAccessLevels.users"
:groups="mergeAccessLevels.groups"
+ data-qa-selector="allowed_to_merge_content"
/>
<!-- Force push -->
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
index 721248e53e3..3a5b3409596 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
@@ -101,7 +101,13 @@ export default {
<div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div>
- <div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level">
+ <div
+ v-for="(item, index) in accessLevels"
+ :key="index"
+ data-testid="access-level"
+ data-qa-selector="access_level_content"
+ :data-qa-role="item.accessLevelDescription"
+ >
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
{{ item.accessLevelDescription }}
</div>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index 7709419b6f8..dcf5155644d 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -69,9 +69,14 @@ export default {
<div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div>
- <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{
- $options.i18n.addBranchRule
- }}</gl-button>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ class="gl-mt-5"
+ data-qa-selector="add_branch_rule_button"
+ category="secondary"
+ variant="info"
+ >{{ $options.i18n.addBranchRule }}</gl-button
+ >
<gl-modal
:ref="$options.modalId"
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
index b565bda247d..a5ff478a826 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -153,7 +153,11 @@ export default {
</script>
<template>
- <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between">
+ <div
+ class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"
+ data-qa-selector="branch_content"
+ :data-qa-branch-name="name"
+ >
<div>
<strong class="gl-font-monospace">{{ name }}</strong>
@@ -169,7 +173,7 @@ export default {
<li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
</ul>
</div>
- <gl-button class="gl-align-self-start" :href="detailsPath">
+ <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath">
{{ $options.i18n.detailsButtonLabel }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index b79b3fa4573..79ece99e6ec 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -8,7 +8,7 @@ import ServiceDeskSetting from './service_desk_setting.vue';
export default {
customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
}),
components: {
GlAlert,
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 85550e262e6..8af2e787740 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -102,12 +102,12 @@ export default {
},
emailSuffixHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'configuring-a-custom-email-address-suffix',
+ anchor: 'configure-a-custom-email-address-suffix',
});
},
customEmailAddressHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
});
},
},
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index b5d00cb7e82..5342874250c 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -9,3 +9,9 @@ export const LEVEL_TYPES = {
GROUP: 'group',
DEPLOY_KEY: 'deploy_key',
};
+
+export const BRANCH_RULES_ANCHOR = '#branch-rules';
+
+export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created';
+
+export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings';
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index cd37c0de6a5..cdbe39fd5e0 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,17 +1,24 @@
import $ from 'jquery';
import CreateItemDropdown from '~/create_item_dropdown';
-import { createAlert } from '~/alert';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { initToggle } from '~/toggles';
-import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
+import { expandSection } from '~/settings_panels';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import {
+ BRANCH_RULES_ANCHOR,
+ PROTECTED_BRANCHES_ANCHOR,
+ IS_PROTECTED_BRANCH_CREATED,
+ ACCESS_LEVELS,
+ LEVEL_TYPES,
+} from './constants';
export default class ProtectedBranchCreate {
constructor(options) {
this.hasLicense = options.hasLicense;
-
this.$form = $('.js-new-protected-branch');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
@@ -22,7 +29,7 @@ export default class ProtectedBranchCreate {
if (this.hasLicense) {
this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle'));
}
-
+ this.showSuccessAlertIfNeeded();
this.bindEvents();
}
@@ -81,6 +88,49 @@ export default class ProtectedBranchCreate {
callback(gon.open_branches);
}
+ // eslint-disable-next-line class-methods-use-this
+ expandAndScroll(anchor) {
+ expandSection(anchor);
+ scrollToElement(anchor);
+ }
+
+ hasProtectedBranchSuccessAlert() {
+ return (
+ window.gon?.features?.branchRules &&
+ this.isLocalStorageAvailable &&
+ localStorage.getItem(IS_PROTECTED_BRANCH_CREATED)
+ );
+ }
+
+ createSuccessAlert() {
+ this.alert = createAlert({
+ variant: VARIANT_SUCCESS,
+ containerSelector: '.js-alert-protected-branch-created-container',
+ title: s__('ProtectedBranch|View protected branches as branch rules'),
+ message: s__('ProtectedBranch|Manage branch related settings in one area with branch rules.'),
+ primaryButton: {
+ text: s__('ProtectedBranch|View branch rule'),
+ clickHandler: () => {
+ this.expandAndScroll(BRANCH_RULES_ANCHOR);
+ },
+ },
+ secondaryButton: {
+ text: __('Dismiss'),
+ clickHandler: () => this.alert.dismiss(),
+ },
+ });
+ }
+
+ showSuccessAlertIfNeeded() {
+ if (!this.hasProtectedBranchSuccessAlert()) {
+ return;
+ }
+ this.expandAndScroll(PROTECTED_BRANCHES_ANCHOR);
+
+ this.createSuccessAlert();
+ localStorage.removeItem(IS_PROTECTED_BRANCH_CREATED);
+ }
+
getFormData() {
const formData = {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
@@ -127,6 +177,9 @@ export default class ProtectedBranchCreate {
axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
.then(() => {
+ if (this.isLocalStorageAvailable) {
+ localStorage.setItem(IS_PROTECTED_BRANCH_CREATED, 'true');
+ }
window.location.reload();
})
.catch(() =>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 9826124912b..9a84726d42f 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: '',
},
+ queryParams: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
refType: {
type: String,
required: false,
@@ -93,6 +98,7 @@ export default {
matches: (state) => state.matches,
lastQuery: (state) => state.query,
selectedRef: (state) => state.selectedRef,
+ params: (state) => state.params,
}),
...mapGetters(['isLoading', 'isQueryPossiblyASha']),
i18n() {
@@ -186,6 +192,7 @@ export default {
this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
+ this.setParams(this.queryParams);
this.$watch(
'enabledRefTypes',
@@ -206,6 +213,7 @@ export default {
...mapActions([
'setEnabledRefTypes',
'setUseSymbolicRefNames',
+ 'setParams',
'setProjectId',
'setSelectedRef',
]),
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index a6019f21e73..3d6b46abf52 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -5,6 +5,8 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes);
+export const setParams = ({ commit }, params) => commit(types.SET_PARAMS, params);
+
export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
@@ -29,7 +31,7 @@ export const search = ({ state, dispatch, commit }, query) => {
export const searchBranches = ({ commit, state }) => {
commit(types.REQUEST_START);
- Api.branches(state.projectId, state.query)
+ Api.branches(state.projectId, state.query, state.params)
.then((response) => {
commit(types.RECEIVE_BRANCHES_SUCCESS, response);
})
diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js
index 2bebffc19ab..fb2196fa1d0 100644
--- a/app/assets/javascripts/ref/stores/index.js
+++ b/app/assets/javascripts/ref/stores/index.js
@@ -14,3 +14,11 @@ export default () =>
mutations,
state: createState(),
});
+
+export const createRefModule = () => ({
+ namespaced: true,
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+});
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index 4c602908cae..6178106fe00 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,5 +1,6 @@
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
+export const SET_PARAMS = 'SET_PARAMS';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index 9846ac0adb7..43c4318ad6c 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -10,6 +10,9 @@ export default {
[types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
state.useSymbolicRefNames = useSymbolicRefNames;
},
+ [types.SET_PARAMS](state, params) {
+ state.params = params;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
index 3affa8f8d03..1619b43c02e 100644
--- a/app/assets/javascripts/ref/stores/state.js
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -15,5 +15,6 @@ export default () => ({
commits: createRefTypeState(),
},
selectedRef: null,
+ params: null,
requestCount: 0,
});
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index 043d925198c..24b350c7f18 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -194,9 +194,11 @@ export default {
'gl-border-b-1': isOpen,
'gl-border-b-0': !isOpen,
}"
- class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
+ class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
>
- <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <h3
+ class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24"
+ >
<gl-link
id="user-content-related-issues"
class="anchor position-absolute gl-text-decoration-none"
diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue
new file mode 100644
index 00000000000..44269bccec9
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_create.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { uniqueId } from 'lodash';
+import { __, s__ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ RefSelector,
+ },
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+ props: {
+ value: { type: String, required: true },
+ },
+ data() {
+ return {
+ nameId: uniqueId('tag-name-'),
+ refId: uniqueId('ref-'),
+ messageId: uniqueId('message-'),
+ };
+ },
+ computed: {
+ ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ },
+ methods: {
+ ...mapActions('editNew', ['updateReleaseTagMessage', 'updateCreateFrom']),
+ },
+ i18n: {
+ tagNameLabel: __('Tag name'),
+ refLabel: __('Create from'),
+ messageLabel: s__('CreateGitTag|Set tag message'),
+ messagePlaceholder: s__(
+ 'CreateGitTag|Add a message to the tag. Leaving this blank creates a lightweight tag.',
+ ),
+ create: __('Save'),
+ cancel: s__('Release|Select another tag'),
+ refSelector: {
+ noRefSelected: __('No source selected'),
+ searchPlaceholder: __('Search branches, tags, and commits'),
+ dropdownHeader: __('Select source'),
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-p-3" data-testid="create-from-field">
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.tagNameLabel"
+ :label-for="nameId"
+ label-sr-only
+ >
+ <gl-form-input :id="nameId" :value="value" autofocus @input="$emit('change', $event)" />
+ </gl-form-group>
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.refLabel" :label-for="refId" label-sr-only>
+ <ref-selector
+ :id="refId"
+ :project-id="projectId"
+ :value="createFrom"
+ :translations="$options.i18n.refSelector"
+ @input="updateCreateFrom"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.messageLabel"
+ :label-for="messageId"
+ label-sr-only
+ >
+ <gl-form-textarea
+ :id="messageId"
+ :placeholder="$options.i18n.messagePlaceholder"
+ :no-resize="false"
+ :value="release.tagMessage"
+ @input="updateReleaseTagMessage"
+ />
+ </gl-form-group>
+ <gl-button class="gl-mr-3" variant="confirm" @click="$emit('create')">
+ {{ $options.i18n.create }}
+ </gl-button>
+ <gl-button @click="$emit('cancel')">{{ $options.i18n.cancel }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 2ac61988393..ec058cc3603 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,225 +1,133 @@
<script>
-import {
- GlCollapse,
- GlLink,
- GlFormGroup,
- GlFormTextarea,
- GlDropdownItem,
- GlSprintf,
-} from '@gitlab/ui';
-import { uniqueId } from 'lodash';
+import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__ } from '~/locale';
-import RefSelector from '~/ref/components/ref_selector.vue';
-import { REF_TYPE_TAGS } from '~/ref/constants';
-import FormFieldContainer from './form_field_container.vue';
+
+import TagSearch from './tag_search.vue';
+import TagCreate from './tag_create.vue';
export default {
- name: 'TagFieldNew',
components: {
- GlCollapse,
+ GlDropdown,
GlFormGroup,
- GlFormTextarea,
- GlLink,
- RefSelector,
- FormFieldContainer,
- GlDropdownItem,
- GlSprintf,
+ GlPopover,
+ TagSearch,
+ TagCreate,
},
data() {
- return {
- // Keeps track of whether or not the user has interacted with
- // the input field. This is used to avoid showing validation
- // errors immediately when the page loads.
- isInputDirty: false,
- };
+ return { id: 'release-tag-name', newTagName: '', show: false, isInputDirty: false };
},
computed: {
- ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']),
- ...mapGetters('editNew', ['validationErrors']),
- tagName: {
- get() {
- return this.release.tagName;
- },
- set(tagName) {
- this.updateReleaseTagName(tagName);
-
- // This setter is used by the `v-model` on the `RefSelector`.
- // When this is called, the selection originated from the
- // dropdown list of existing tag names, so we know the tag
- // already exists and don't need to show the "create from" input
- this.updateShowCreateFrom(false);
- },
- },
- tagMessage: {
- get() {
- return this.release.tagMessage;
- },
- set(tagMessage) {
- this.updateReleaseTagMessage(tagMessage);
- },
- },
- createFromModel: {
- get() {
- return this.createFrom;
- },
- set(createFrom) {
- this.updateCreateFrom(createFrom);
- },
+ ...mapState('editNew', ['release', 'showCreateFrom']),
+ ...mapGetters('editNew', ['validationErrors', 'isSearching', 'isCreating']),
+ title() {
+ return this.isCreating ? this.$options.i18n.createTitle : this.$options.i18n.selectTitle;
},
showTagNameValidationError() {
return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid;
},
- tagNameInputId() {
- return uniqueId('tag-name-input-');
- },
- createFromSelectorId() {
- return uniqueId('create-from-selector-');
- },
tagFeedback() {
return this.validationErrors.tagNameValidation.validationErrors[0];
},
+ buttonText() {
+ return this.release?.tagName || s__('Release|Search or create tag name');
+ },
+ buttonVariant() {
+ return this.showTagNameValidationError ? 'danger' : 'default';
+ },
+ createText() {
+ return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
+ },
},
methods: {
...mapActions('editNew', [
+ 'setSearching',
+ 'setCreating',
+ 'setNewTag',
+ 'setExistingTag',
'updateReleaseTagName',
- 'updateReleaseTagMessage',
- 'updateCreateFrom',
'fetchTagNotes',
- 'updateShowCreateFrom',
]),
- markInputAsDirty() {
- this.isInputDirty = true;
+ startCreate(query) {
+ this.newTagName = query;
+ this.setCreating();
},
- createTagClicked(newTagName) {
- this.updateReleaseTagName(newTagName);
+ selected(tag) {
+ this.updateReleaseTagName(tag);
- // 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.updateShowCreateFrom(true);
- },
- shouldShowCreateTagOption(isLoading, matches, query) {
- // Show the "create tag" option if:
- return (
- // we're not currently loading any results, and
- !isLoading &&
- // the search query isn't just whitespace, and
- query.trim() &&
- // the `matches` object is non-null, and
- matches &&
- // the tag name doesn't already exist
- !matches.tags.list.some(
- (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(),
- )
- );
+ if (this.isSearching) {
+ this.fetchTagNotes(tag);
+ this.setExistingTag();
+ this.newTagName = '';
+ } else {
+ this.setNewTag();
+ }
+
+ this.hidePopover();
},
- },
- translations: {
- tagName: {
- noRefSelected: __('No tag selected'),
- dropdownHeader: __('Tag name'),
- searchPlaceholder: __('Search or create tag'),
- label: __('Tag name'),
- labelDescription: __('*Required'),
+ markInputAsDirty() {
+ this.isInputDirty = true;
},
- createFrom: {
- noRefSelected: __('No source selected'),
- searchPlaceholder: __('Search branches, tags, and commits'),
- dropdownHeader: __('Select source'),
- label: __('Create from'),
- description: __('Existing branch name, tag, or commit SHA'),
+ showPopover() {
+ this.show = true;
},
- annotatedTag: {
- label: s__('CreateGitTag|Set tag message'),
- description: s__(
- 'CreateGitTag|Add a message to the tag. Leaving this blank creates a %{linkStart}lightweight tag%{linkEnd}.',
- ),
+ hidePopover() {
+ this.show = false;
},
},
- tagMessageId: uniqueId('tag-message-'),
-
- tagNameEnabledRefTypes: [REF_TYPE_TAGS],
- gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/',
+ i18n: {
+ selectTitle: __('Tags'),
+ createTitle: s__('Release|Create tag'),
+ label: __('Tag name'),
+ required: __('(required)'),
+ create: __('Create'),
+ cancel: __('Cancel'),
+ },
};
</script>
<template>
- <div>
+ <div class="row">
<gl-form-group
- data-testid="tag-name-field"
+ class="col-md-4 col-sm-10"
+ :label="$options.i18n.label"
+ :label-for="id"
+ :optional-text="$options.i18n.required"
:state="!showTagNameValidationError"
:invalid-feedback="tagFeedback"
- :label="$options.translations.tagName.label"
- :label-for="tagNameInputId"
- :label-description="$options.translations.tagName.labelDescription"
+ optional
+ data-testid="tag-name-field"
>
- <form-field-container>
- <ref-selector
- :id="tagNameInputId"
- v-model="tagName"
- :project-id="projectId"
- :translations="$options.translations.tagName"
- :enabled-ref-types="$options.tagNameEnabledRefTypes"
- :state="!showTagNameValidationError"
- @input="fetchTagNotes"
- @hide.once="markInputAsDirty"
- >
- <template #footer="{ isLoading, matches, query }">
- <gl-dropdown-item
- v-if="shouldShowCreateTagOption(isLoading, matches, query)"
- is-check-item
- :is-checked="tagName === query"
- @click="createTagClicked(query)"
- >
- <gl-sprintf :message="__('Create tag %{tagName}')">
- <template #tagName>
- <b>{{ query }}</b>
- </template>
- </gl-sprintf>
- </gl-dropdown-item>
- </template>
- </ref-selector>
- </form-field-container>
+ <gl-dropdown
+ :id="id"
+ :variant="buttonVariant"
+ :text="buttonText"
+ :toggle-class="['gl-text-gray-900!']"
+ category="secondary"
+ class="gl-w-30"
+ @show.prevent="showPopover"
+ />
+ <gl-popover
+ :show="show"
+ :target="id"
+ :title="title"
+ :css-classes="['gl-z-index-200', 'release-tag-selector']"
+ placement="bottom"
+ triggers="manual"
+ container="content-body"
+ show-close-button
+ @close-button-clicked="hidePopover"
+ @hide.once="markInputAsDirty"
+ >
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200">
+ <tag-create
+ v-if="isCreating"
+ v-model="newTagName"
+ @create="selected(newTagName)"
+ @cancel="setSearching"
+ />
+ <tag-search v-else v-model="newTagName" @create="startCreate" @select="selected" />
+ </div>
+ </gl-popover>
</gl-form-group>
- <gl-collapse :visible="showCreateFrom">
- <div class="gl-pl-6 gl-border-l-1 gl-border-l-solid gl-border-gray-300">
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.createFrom.label"
- :label-for="createFromSelectorId"
- data-testid="create-from-field"
- >
- <form-field-container>
- <ref-selector
- :id="createFromSelectorId"
- v-model="createFromModel"
- :project-id="projectId"
- :translations="$options.translations.createFrom"
- />
- </form-field-container>
- <template #description>{{ $options.translations.createFrom.description }}</template>
- </gl-form-group>
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.annotatedTag.label"
- :label-for="$options.tagMessageId"
- data-testid="annotated-tag-message-field"
- >
- <gl-form-textarea :id="$options.tagMessageId" v-model="tagMessage" />
- <template #description>
- <gl-sprintf :message="$options.translations.annotatedTag.description">
- <template #link="{ content }">
- <gl-link
- :href="$options.gitTagDocsLink"
- rel="noopener noreferrer"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </template>
- </gl-form-group>
- </div>
- </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue
new file mode 100644
index 00000000000..33b44c90e1f
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_search.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { debounce } from 'lodash';
+import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlSprintf,
+ },
+ model: {
+ prop: 'query',
+ event: 'change',
+ },
+ props: {
+ query: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return { tagName: '' };
+ },
+ computed: {
+ ...mapState('ref', ['matches']),
+ ...mapState('editNew', ['projectId', 'release']),
+ tags() {
+ return this.matches?.tags?.list || [];
+ },
+ createText() {
+ return this.query ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
+ },
+ selectedNotShown() {
+ return this.release.tagName && !this.tags.some((tag) => tag.name === this.release.tagName);
+ },
+ },
+ created() {
+ this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
+ },
+ mounted() {
+ this.setProjectId(this.projectId);
+ this.setEnabledRefTypes([REF_TYPE_TAGS]);
+ this.search(this.query);
+ },
+ methods: {
+ ...mapActions('ref', ['setEnabledRefTypes', 'setProjectId', 'search']),
+ onSearchBoxInput(searchQuery = '') {
+ const query = searchQuery.trim();
+ this.$emit('change', query);
+ this.debouncedSearch(query);
+ },
+ selected(tagName) {
+ return (this.release?.tagName ?? '') === tagName;
+ },
+ },
+ i18n: {
+ noResults: __('No results found'),
+ createTag: s__('Release|Create tag %{tag}'),
+ typeNew: s__('Release|Or type a new tag name'),
+ },
+};
+</script>
+<template>
+ <div data-testid="tag-name-search">
+ <gl-search-box-by-type
+ :value="query"
+ class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"
+ borderless
+ autofocus
+ @input="onSearchBoxInput"
+ />
+ <div class="gl-overflow-y-auto release-tag-list">
+ <div v-if="tags.length || release.tagName">
+ <gl-dropdown-item
+ v-if="selectedNotShown"
+ is-checked
+ is-check-item
+ class="gl-list-style-none"
+ >
+ {{ release.tagName }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-for="tag in tags"
+ :key="tag.name"
+ :is-checked="selected(tag.name)"
+ is-check-item
+ class="gl-list-style-none"
+ @click="$emit('select', tag.name)"
+ >
+ {{ tag.name }}
+ </gl-dropdown-item>
+ </div>
+ <div
+ v-else
+ class="gl-my-5 gl-text-gray-500 gl-display-flex gl-font-base gl-justify-content-center"
+ >
+ {{ $options.i18n.noResults }}
+ </div>
+ </div>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200 gl-py-3">
+ <gl-button
+ category="tertiary"
+ class="gl-justify-content-start! gl-rounded-0!"
+ block
+ :disabled="!query"
+ @click="$emit('create', query)"
+ >
+ <gl-sprintf :message="createText">
+ <template #tag>
+ <span class="gl-font-weight-bold">{{ query }}</span>
+ </template>
+ </gl-sprintf>
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index 0a3f8b5e63b..efd82edcdf0 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import { createRefModule } from '../ref/stores';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
import createEditNewModule from './stores/modules/edit_new';
@@ -12,6 +13,7 @@ export default () => {
const store = createStore({
modules: {
editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }),
+ ref: createRefModule(),
},
});
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 a7d8825ed33..f5191e000f7 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -274,3 +274,9 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
});
});
};
+
+export const setSearching = ({ commit }) => commit(types.SET_SEARCHING);
+export const setCreating = ({ commit }) => commit(types.SET_CREATING);
+
+export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG);
+export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG);
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/constants.js b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
new file mode 100644
index 00000000000..0f12f150525
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
@@ -0,0 +1,4 @@
+export const SEARCH = 'SEARCH';
+export const CREATE = 'CREATE';
+export const EXISTING_TAG = 'EXISTING_TAG';
+export const NEW_TAG = 'NEW_TAG';
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 8ff479058f2..edf6c81c9e9 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -4,6 +4,7 @@ import { hasContent } from '~/lib/utils/text_utility';
import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
import { validateTag, ValidationResult } from '~/lib/utils/ref_validator';
import { i18n } from '~/releases/constants';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
/**
* @param {Object} link The link to test
@@ -169,10 +170,23 @@ export const releaseDeleteMutationVariables = (state) => ({
},
});
-export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) =>
- includeTagNotes && tagNotes
- ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
+export const formattedReleaseNotes = ({
+ includeTagNotes,
+ release: { description, tagMessage },
+ tagNotes,
+ showCreateFrom,
+}) => {
+ const notes = showCreateFrom ? tagMessage : tagNotes;
+ return includeTagNotes && notes
+ ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n`
: description;
+};
export const releasedAtChanged = ({ originalReleasedAt, release }) =>
originalReleasedAt !== release.releasedAt;
+
+export const isSearching = ({ step }) => step === SEARCH;
+export const isCreating = ({ step }) => step === CREATE;
+
+export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG;
+export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG;
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 e52eccd6a21..fc450970cde 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
@@ -29,3 +29,9 @@ 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';
+
+export const SET_SEARCHING = 'SET_SEARCHING';
+export const SET_CREATING = 'SET_CREATING';
+
+export const SET_EXISTING_TAG = 'SET_EXISTING_TAG';
+export const SET_NEW_TAG = 'SET_NEW_TAG';
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 ccd168aafc9..7ff18245a80 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -1,6 +1,7 @@
import { uniqueId, cloneDeep } from 'lodash';
import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants';
import * as types from './mutation_types';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
const findReleaseLink = (release, id) => {
return release.assets.links.find((l) => l.id === id);
@@ -127,4 +128,17 @@ export default {
[types.UPDATE_RELEASED_AT](state, releasedAt) {
state.release.releasedAt = releasedAt;
},
+
+ [types.SET_SEARCHING](state) {
+ state.step = SEARCH;
+ },
+ [types.SET_CREATING](state) {
+ state.step = CREATE;
+ },
+ [types.SET_EXISTING_TAG](state) {
+ state.tagStep = EXISTING_TAG;
+ },
+ [types.SET_NEW_TAG](state) {
+ state.tagStep = NEW_TAG;
+ },
};
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 3112becfa9e..7bd3968dd93 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -1,3 +1,5 @@
+import { SEARCH, EXISTING_TAG } from './constants';
+
export default ({
isExistingRelease,
projectId,
@@ -62,4 +64,6 @@ export default ({
includeTagNotes: false,
existingRelease: null,
originalReleasedAt: new Date(),
+ step: SEARCH,
+ tagStep: EXISTING_TAG,
});
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 236351005e7..334e7964bc2 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -34,12 +34,14 @@ export default {
ForkSuggestion,
WebIdeLink,
CodeIntelligence,
+ AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
},
+ explainCodeAvailable: { default: false },
},
apollo: {
projectInfo: {
@@ -142,6 +144,9 @@ export default {
};
},
computed: {
+ shouldRenderGenie() {
+ return this.explainCodeAvailable;
+ },
isLoggedIn() {
return isLoggedIn();
},
@@ -316,9 +321,9 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-relative">
<gl-loading-icon v-if="isLoading" size="sm" />
- <div v-if="blobInfo && !isLoading" class="file-holder">
+ <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder">
<blob-header
:blob="blobInfo"
:hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs"
@@ -393,5 +398,11 @@ export default {
:wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
+ <ai-genie
+ v-if="shouldRenderGenie"
+ container-id="fileHolder"
+ :file-path="path"
+ class="gl-ml-7"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 1a834ba1d82..f3dbf98312e 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,13 +1,15 @@
<script>
import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import { createAlert } from '~/alert';
+import { createAlert, VARIANT_INFO } from '~/alert';
import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import eventHub from '../event_hub';
import {
POLLING_INTERVAL_DEFAULT,
POLLING_INTERVAL_BACKOFF,
FIVE_MINUTES_IN_MS,
+ FORK_UPDATED_EVENT,
} from '../constants';
import forkDetailsQuery from '../queries/fork_details.query.graphql';
import ConflictsModal from './fork_sync_conflicts_modal.vue';
@@ -22,7 +24,11 @@ export const i18n = {
behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'),
error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'),
- sync: s__('ForksDivergence|Update fork'),
+ updateFork: s__('ForksDivergence|Update fork'),
+ createMergeRequest: s__('ForksDivergence|Create merge request'),
+ successMessage: s__(
+ 'ForksDivergence|Successfully fetched and merged from the upstream repository.',
+ ),
};
export default {
@@ -55,7 +61,16 @@ export default {
});
},
result({ loading }) {
- this.handlePolingInterval(loading);
+ if (!loading && this.isSyncing) {
+ this.increasePollInterval();
+ }
+ if (this.isForkUpdated) {
+ createAlert({
+ message: this.$options.i18n.successMessage,
+ variant: VARIANT_INFO,
+ });
+ eventHub.$emit(FORK_UPDATED_EVENT);
+ }
},
pollInterval() {
return this.pollInterval;
@@ -86,6 +101,11 @@ export default {
required: false,
default: '',
},
+ canSyncBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
aheadComparePath: {
type: String,
required: false,
@@ -96,12 +116,21 @@ export default {
required: false,
default: '',
},
+ createMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canUserCreateMrInFork: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
project: {},
currentPollInterval: null,
- isSyncTriggered: false,
};
},
computed: {
@@ -126,6 +155,9 @@ export default {
isSyncing() {
return this.forkDetails?.isSyncing;
},
+ isForkUpdated() {
+ return this.isUpToDate && this.currentPollInterval;
+ },
ahead() {
return this.project?.forkDetails?.ahead;
},
@@ -163,12 +195,16 @@ export default {
hasBehindAheadMessage() {
return this.behindAheadMessage.length > 0;
},
- isSyncButtonAvailable() {
+ hasUpdateButton() {
return (
this.glFeatures.synchronizeFork &&
+ this.canSyncBranch &&
((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
);
},
+ hasCreateMrButton() {
+ return this.canUserCreateMrInFork && this.ahead && this.createMrPath;
+ },
forkDivergenceMessage() {
if (!this.forkDetails) {
return this.$options.i18n.limitedVisibility;
@@ -186,9 +222,8 @@ export default {
},
watch: {
hasConflicts(newVal) {
- if (newVal && this.isSyncTriggered) {
+ if (newVal && this.currentPollInterval) {
this.showConflictsModal();
- this.isSyncTriggered = false;
}
},
},
@@ -227,7 +262,6 @@ export default {
this.$refs.modal.show();
},
startSyncing() {
- this.isSyncTriggered = true;
this.syncForkWithPolling();
},
checkIfSyncIsPossible() {
@@ -237,18 +271,11 @@ export default {
this.startSyncing();
}
},
- handlePolingInterval(loading) {
- if (!loading && this.isSyncing) {
- const backoff = POLLING_INTERVAL_BACKOFF;
- const interval = this.currentPollInterval;
- const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS);
- this.currentPollInterval = this.currentPollInterval
- ? newInterval
- : POLLING_INTERVAL_DEFAULT;
- }
- if (this.currentPollInterval === FIVE_MINUTES_IN_MS) {
- this.$apollo.queries.forkDetailsQuery.stopPolling();
- }
+ increasePollInterval() {
+ const backoff = POLLING_INTERVAL_BACKOFF;
+ const interval = this.currentPollInterval;
+ const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS);
+ this.currentPollInterval = this.currentPollInterval ? newInterval : POLLING_INTERVAL_DEFAULT;
},
},
};
@@ -283,16 +310,29 @@ export default {
>
{{ $options.i18n.inaccessibleProject }}
</div>
- <gl-button
- v-if="isSyncButtonAvailable"
- :disabled="forkDetails.isSyncing"
- @click="checkIfSyncIsPossible"
- >
- <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
- <span>{{ $options.i18n.sync }}</span>
- </gl-button>
+ <div class="gl-display-flex gl-xs-display-none!">
+ <gl-button
+ v-if="hasCreateMrButton"
+ class="gl-ml-4"
+ :href="createMrPath"
+ data-testid="create-mr-button"
+ >
+ <span>{{ $options.i18n.createMergeRequest }}</span>
+ </gl-button>
+ <gl-button
+ v-if="hasUpdateButton"
+ class="gl-ml-4"
+ :disabled="forkDetails.isSyncing"
+ data-testid="update-fork-button"
+ @click="checkIfSyncIsPossible"
+ >
+ <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
+ <span>{{ $options.i18n.updateFork }}</span>
+ </gl-button>
+ </div>
<conflicts-modal
ref="modal"
+ :selected-branch="selectedBranch"
:source-name="sourceName"
:source-path="sourcePath"
:source-default-branch="sourceDefaultBranch"
diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
index 0bfb90bb3ec..ffe4fd4cd38 100644
--- a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
+++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
@@ -19,10 +19,9 @@ export const i18n = {
"ForksDivergence|Fetch the latest changes from the upstream repository's default branch:",
),
step2Text: s__(
- "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.",
+ "ForksDivergence|Check out to a branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.",
),
step3Text: s__('ForksDivergence|Push the updates to remote:'),
- step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."),
copyToClipboard: __('Copy to clipboard'),
close: __('Close'),
};
@@ -53,12 +52,20 @@ export default {
required: false,
default: '',
},
+ selectedBranch: {
+ type: String,
+ required: true,
+ default: '',
+ },
},
computed: {
instructionsStep1() {
const baseUrl = getBaseURL();
return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`;
},
+ instructionsStep2() {
+ return `git checkout ${this.selectedBranch}\ngit merge FETCH_HEAD`;
+ },
},
methods: {
show() {
@@ -69,9 +76,7 @@ export default {
},
},
i18n,
- instructionsStep2: 'git checkout -b &lt;new-branch-name&gt;\ngit merge FETCH_HEAD',
- instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD',
- instructionsStep3: 'git commit\ngit push',
+ instructionsStep3: 'git push',
};
</script>
<template>
@@ -100,14 +105,12 @@ export default {
<b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }}
</p>
<div class="gl-display-flex gl-mb-4">
- <pre
- class="gl-w-full gl-mb-0 gl-mr-3"
- data-testid="resolve-conflict-instructions"
- v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */"
- ></pre>
+ <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{
+ instructionsStep2
+ }}</pre>
<modal-copy-button
modal-id="fork-sync-conflicts-modal"
- :text="$options.instructionsStep2Clipboard"
+ :text="instructionsStep2"
:title="$options.i18n.copyToClipboard"
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
/>
@@ -127,9 +130,6 @@ export default {
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3"
/>
</div>
- <p>
- <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }}
- </p>
<template #modal-footer>
<gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button>
</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 2d2e21dfd92..82dd1fda2a0 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -12,6 +12,8 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima
import SignatureBadge from '~/commit/components/signature_badge.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
+import eventHub from '../event_hub';
+import { FORK_UPDATED_EVENT } from '../constants';
export default {
components: {
@@ -97,10 +99,19 @@ export default {
this.commit = null;
},
},
+ mounted() {
+ eventHub.$on(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
+ beforeDestroy() {
+ eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
},
+ refetchLastCommit() {
+ this.$apollo.queries.commit.refetch();
+ },
},
defaultAvatarUrl,
safeHtmlConfig: {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index a6191203b2f..b711f671850 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -112,3 +112,5 @@ export const POLLING_INTERVAL_DEFAULT = 2500;
export const POLLING_INTERVAL_BACKOFF = 2;
export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal';
+
+export const FORK_UPDATED_EVENT = 'fork:updated';
diff --git a/app/assets/javascripts/repository/event_hub.js b/app/assets/javascripts/repository/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/repository/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 0db9dcb43df..5a3958d8e4a 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, {
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
+ const {
+ projectPath,
+ projectShortPath,
+ ref,
+ escapedRef,
+ fullName,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -70,11 +79,15 @@ export default function setupVueRepositoryList() {
return null;
}
const {
+ selectedBranch,
sourceName,
sourcePath,
sourceDefaultBranch,
+ createMrPath,
+ canSyncBranch,
aheadComparePath,
behindComparePath,
+ canUserCreateMrInFork,
} = forkEl.dataset;
return new Vue({
el: forkEl,
@@ -82,13 +95,16 @@ export default function setupVueRepositoryList() {
render(h) {
return h(ForkInfo, {
props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
projectPath,
- selectedBranch: ref,
+ selectedBranch,
sourceName,
sourcePath,
sourceDefaultBranch,
aheadComparePath,
behindComparePath,
+ createMrPath,
+ canUserCreateMrInFork,
},
});
},
@@ -138,6 +154,7 @@ export default function setupVueRepositoryList() {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -151,8 +168,8 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
initBlobControlsApp();
- initForkInfo();
initRefSwitcher();
+ initForkInfo();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
@@ -273,6 +290,7 @@ export default function setupVueRepositoryList() {
store: createStore(),
router,
apolloProvider,
+ provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) },
render(h) {
return h(App);
},
diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue
deleted file mode 100644
index 3ad5642afc7..00000000000
--- a/app/assets/javascripts/saved_replies/components/list_item.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { uniqueId } from 'lodash';
-import { GlButton, GlModal, GlModalDirective, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
-
-export default {
- components: {
- GlButton,
- GlModal,
- GlSprintf,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- props: {
- reply: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- isDeleting: false,
- modalId: uniqueId('delete-saved-reply-'),
- };
- },
- computed: {
- id() {
- return getIdFromGraphQLId(this.reply.id);
- },
- },
- methods: {
- onDelete() {
- this.isDeleting = true;
-
- this.$apollo.mutate({
- mutation: deleteSavedReplyMutation,
- variables: {
- id: this.reply.id,
- },
- update: (cache) => {
- const cacheId = cache.identify(this.reply);
- cache.evict({ id: cacheId });
- },
- });
- },
- },
- actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
- actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
-};
-</script>
-
-<template>
- <li class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center">
- <strong data-testid="saved-reply-name">{{ reply.name }}</strong>
- <div class="gl-ml-auto">
- <gl-button
- v-gl-tooltip
- :to="{ name: 'edit', params: { id: id } }"
- icon="pencil"
- :title="__('Edit')"
- :aria-label="__('Edit')"
- class="gl-mr-3"
- data-testid="saved-reply-edit-btn"
- />
- <gl-button
- v-gl-modal="modalId"
- v-gl-tooltip
- icon="remove"
- :aria-label="__('Delete')"
- :title="__('Delete')"
- variant="danger"
- category="secondary"
- data-testid="saved-reply-delete-btn"
- :loading="isDeleting"
- />
- </div>
- </div>
- <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
- <gl-modal
- :title="__('Delete saved reply')"
- :action-primary="$options.actionPrimary"
- :action-secondary="$options.actionSecondary"
- :modal-id="modalId"
- size="sm"
- @primary="onDelete"
- >
- <gl-sprintf
- :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
- >
- <template #name
- ><strong>{{ reply.name }}</strong></template
- >
- </gl-sprintf>
- </gl-modal>
- </li>
-</template>
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d71785d7fac..1e4b1e36514 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -15,6 +15,7 @@ export const initSearchApp = () => {
const store = createStore({
query,
navigation,
+ useNewNavigation: gon.use_new_navigation,
});
initTopbar(store);
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 60de63c7d7a..317145d4cd1 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,21 +1,23 @@
<script>
import { mapState, mapGetters } from 'vuex';
import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue';
+import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue';
import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
import ResultsFilters from './results_filters.vue';
-import LanguageFilter from './language_filter.vue';
+import LanguageFilter from './language_filter/index.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
ResultsFilters,
ScopeNavigation,
+ ScopeNewNavigation,
LanguageFilter,
+ SidebarPortal,
},
- mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['urlQuery']),
+ ...mapState(['urlQuery', 'useNewNavigation']),
...mapGetters(['currentScope']),
showIssueAndMergeFilters() {
return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
@@ -23,12 +25,23 @@ export default {
showBlobFilter() {
return this.currentScope === SCOPE_BLOB;
},
+ showOldNavigation() {
+ return Boolean(this.currentScope);
+ },
},
};
</script>
<template>
+ <section v-if="useNewNavigation">
+ <sidebar-portal>
+ <scope-new-navigation />
+ <results-filters v-if="showIssueAndMergeFilters" />
+ <language-filter v-if="showBlobFilter" />
+ </sidebar-portal>
+ </section>
<section
+ v-else
class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5"
>
<scope-navigation />
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
index f7873a994aa..feff3f77dd2 100644
--- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
@@ -1,10 +1,15 @@
<script>
+import Vue from 'vue';
import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { intersection } from 'lodash';
+import Tracking from '~/tracking';
import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants';
import { formatSearchResultCount } from '../../store/utils';
+export const TRACKING_LABEL_SET = 'set';
+export const TRACKING_LABEL_CHECKBOX = 'checkbox';
+
export default {
name: 'CheckboxFilter',
components: {
@@ -16,9 +21,13 @@ export default {
type: Object,
required: true,
},
+ trackingNamespace: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
...mapGetters(['queryLanguageFilters']),
dataFilters() {
return Object.values(this.filtersData?.filters || []);
@@ -30,8 +39,11 @@ export default {
get() {
return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
},
- set(value) {
+ async set(value) {
this.setQuery({ key: this.filtersData?.filterParam, value });
+
+ await Vue.nextTick();
+ this.trackSelectCheckbox();
},
},
labelCountClasses() {
@@ -40,9 +52,15 @@ export default {
},
methods: {
...mapActions(['setQuery']),
- getFormatedCount(count) {
+ getFormattedCount(count) {
return formatSearchResultCount(count);
},
+ trackSelectCheckbox() {
+ Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, {
+ label: TRACKING_LABEL_SET,
+ property: this.selectedFilter,
+ });
+ },
},
NAV_LINK_COUNT_DEFAULT_CLASSES,
LABEL_DEFAULT_CLASSES,
@@ -51,7 +69,7 @@ export default {
<template>
<div class="gl-mx-5">
- <h5 class="gl-mt-0">{{ filtersData.header }}</h5>
+ <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5>
<gl-form-checkbox-group v-model="selectedFilter">
<gl-form-checkbox
v-for="f in dataFilters"
@@ -67,7 +85,7 @@ export default {
{{ f.label }}
</span>
<span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
- {{ getFormatedCount(f.count) }}
+ {{ getFormattedCount(f.count) }}
</span>
</span>
</gl-form-checkbox>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index e7aa3d61409..56e44d454a1 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
confidentialFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
index df44a58a14b..df44a58a14b 100644
--- a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
index b2f8d3e1f5f..40b50f657f0 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
@@ -3,10 +3,18 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data';
-import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants';
-import { convertFiltersData } from '../utils';
-import CheckboxFilter from './checkbox_filter.vue';
+import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
+import { convertFiltersData } from '../../utils';
+import CheckboxFilter from '../checkbox_filter.vue';
+import {
+ trackShowMore,
+ trackShowHasOverMax,
+ trackSubmitQuery,
+ trackResetQuery,
+ TRACKING_ACTION_SELECT,
+} from './tracking';
+
+import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data';
export default {
name: 'LanguageFilter',
@@ -30,7 +38,7 @@ export default {
reset: s__('GlobalSearch|Reset filters'),
},
computed: {
- ...mapState(['aggregations', 'sidebarDirty']),
+ ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']),
...mapGetters([
'languageAggregationBuckets',
'currentUrlQueryHasLanguageFilters',
@@ -76,11 +84,21 @@ export default {
]),
onShowMore() {
this.showAll = true;
+ trackShowMore();
+
+ if (this.hasOverMax) {
+ trackShowHasOverMax();
+ }
+ },
+ submitQuery() {
+ trackSubmitQuery();
+ this.applyQuery();
},
trimBuckets(length) {
return this.languageAggregationBuckets.slice(0, length);
},
cleanResetFilters() {
+ trackResetQuery();
if (this.currentUrlQueryHasLanguageFilters) {
return this.resetLanguageQueryWithRedirect();
}
@@ -89,6 +107,7 @@ export default {
},
},
HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_SELECT,
};
</script>
@@ -96,15 +115,18 @@ export default {
<gl-form
v-if="hasBuckets"
class="gl-pt-5 gl-md-pt-0 language-filter-checkbox"
- @submit.prevent="applyQuery"
+ @submit.prevent="submitQuery"
>
- <hr :class="dividerClasses" />
+ <hr v-if="!useNewNavigation" :class="dividerClasses" />
<div
v-if="!aggregations.error"
class="gl-overflow-x-hidden gl-overflow-y-auto"
:class="{ 'language-filter-max-height': showAll }"
>
- <checkbox-filter :filters-data="filtersData" />
+ <checkbox-filter
+ :filters-data="filtersData"
+ :tracking-namespace="$options.TRACKING_ACTION_SELECT"
+ />
<span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
$options.i18n.showingMax
}}</span>
@@ -125,7 +147,7 @@ export default {
</gl-button>
</div>
<div v-if="!aggregations.error">
- <hr :class="$options.HR_DEFAULT_CLASSES" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5"
>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
new file mode 100644
index 00000000000..db107830329
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
@@ -0,0 +1,39 @@
+import Tracking from '~/tracking';
+import { MAX_ITEM_LENGTH } from './data';
+
+export const TRACKING_CATEGORY = 'Language filters';
+export const TRACKING_LABEL_FILTERS = 'Filters';
+
+export const TRACKING_LABEL_MAX = 'Max Shown';
+export const TRACKING_LABEL_SHOW_MORE = 'Show More';
+export const TRACKING_LABEL_APPLY = 'Apply Filters';
+export const TRACKING_LABEL_RESET = 'Reset Filters';
+export const TRACKING_LABEL_ALL = 'All Filters';
+export const TRACKING_PROPERTY_MAX = `More than ${MAX_ITEM_LENGTH} filters to show`;
+
+export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click';
+export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show';
+
+// select is imported and used in checkbox_filter.vue
+export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select';
+
+export const trackShowMore = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, {
+ label: TRACKING_LABEL_ALL,
+ });
+
+export const trackShowHasOverMax = () =>
+ Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, {
+ label: TRACKING_LABEL_MAX,
+ property: TRACKING_PROPERTY_MAX,
+ });
+
+export const trackSubmitQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+
+export const trackResetQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: TRACKING_CATEGORY,
+ });
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index 0733dc72d2e..477ba37dab7 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -16,7 +16,7 @@ export default {
},
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
...mapGetters(['currentScope']),
ANY() {
return this.filterData.filters.ANY;
@@ -56,7 +56,7 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5>
<gl-form-radio-group v-model="selectedFilter">
<gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
{{ radioLabel(f) }}
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index 7d995f26684..24804baef44 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { HR_DEFAULT_CLASSES } from '../constants/index';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import { stateFilterData } from '../constants/state_filter_data';
import ConfidentialityFilter from './confidentiality_filter.vue';
@@ -15,7 +16,7 @@ export default {
ConfidentialityFilter,
},
computed: {
- ...mapState(['urlQuery', 'sidebarDirty']),
+ ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
...mapGetters(['currentScope']),
showReset() {
return this.urlQuery.state || this.urlQuery.confidential;
@@ -26,6 +27,9 @@ export default {
showStatusFilter() {
return Object.values(stateFilterData.scopes).includes(this.currentScope);
},
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
+ },
},
methods: {
...mapActions(['applyQuery', 'resetQuery']),
@@ -35,7 +39,7 @@ export default {
<template>
<form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" />
+ <hr v-if="!useNewNavigation" :class="hrClasses" />
<status-filter v-if="showStatusFilter" />
<confidentiality-filter v-if="showConfidentialityFilter" />
<div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5">
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 02a3870f499..fc41baee831 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -3,8 +3,8 @@ import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
-import { formatSearchResultCount } from '../../store/utils';
import { slugifyWithUnderscore } from '../../../lib/utils/text_utility';
export default {
@@ -22,15 +22,17 @@ export default {
...mapState(['navigation', 'urlQuery']),
},
created() {
- this.fetchSidebarCount();
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
},
methods: {
...mapActions(['fetchSidebarCount']),
- showFormatedCount(count) {
- return formatSearchResultCount(count);
+ showFormatedCount(countString) {
+ return formatSearchResultCount(countString);
},
- isCountOverLimit(count) {
- return count.includes('+');
+ isCountOverLimit(countString) {
+ return Boolean(addCountOverLimit(countString));
},
handleClick(scope) {
this.track('click_menu_item', { label: `vertical_navigation_${scope}` });
diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
new file mode 100644
index 00000000000..86b7cc577a6
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
+
+export default {
+ name: 'ScopeNewNavigation',
+ i18n: {
+ countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'),
+ },
+ components: {
+ NavItem,
+ },
+ mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState(['navigation', 'urlQuery']),
+ ...mapGetters(['navigationItems']),
+ },
+ created() {
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
+ },
+ methods: {
+ ...mapActions(['fetchSidebarCount']),
+ },
+ NAV_LINK_DEFAULT_CLASSES,
+ NAV_LINK_COUNT_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <nav data-testid="search-filter" class="gl-py-2 gl-relative">
+ <ul class="gl-px-2 gl-list-style-none">
+ <nav-item v-for="item in navigationItems" :key="`menu-${item.title}`" :item="item" />
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index c3deabfcc26..44d6b537b7b 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { stateFilterData } from '../constants/state_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
stateFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
index 19b1ad0905b..9519154a571 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -4,7 +4,7 @@ export const SCOPE_BLOB = 'blobs';
export const LABEL_DEFAULT_CLASSES = [
'gl-display-flex',
'gl-flex-direction-row',
- 'gl-flex-wrap-nowrap',
+ 'gl-flex-nowrap',
'gl-text-gray-900',
];
export const NAV_LINK_DEFAULT_CLASSES = [
@@ -14,3 +14,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [
export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal'];
export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100'];
export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
+
+export const TRACKING_LABEL_CHECKBOX = 'Checkbox';
diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js
index 4357d6202df..78e03fcdeee 100644
--- a/app/assets/javascripts/search/sidebar/utils.js
+++ b/app/assets/javascripts/search/sidebar/utils.js
@@ -1,4 +1,4 @@
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
export const convertFiltersData = (rawBuckets) =>
rawBuckets.reduce(
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index da2bf4b602e..3d6ca2a6eee 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -4,7 +4,7 @@ import axios from '~/lib/utils/axios_utils';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
import {
@@ -140,8 +140,7 @@ export const fetchLanguageAggregation = ({ commit, state }) => {
commit(types.REQUEST_AGGREGATIONS);
return axios
.get(getAggregationsUrl())
- .then((result) => {
- const { data } = result;
+ .then(({ data }) => {
commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data));
})
.catch((e) => {
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index ba4fe85db9d..c8ee0a3f9d9 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,6 +1,6 @@
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -17,3 +17,15 @@ export const SIDEBAR_PARAMS = [
];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
+
+export const ICON_MAP = {
+ blobs: 'code',
+ issues: 'issues',
+ merge_requests: 'merge-request',
+ commits: 'commit',
+ notes: 'comments',
+ milestones: 'tag',
+ users: 'users',
+ projects: 'project',
+ wiki_blobs: 'overview',
+};
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 36d98233e28..135c9a3d67c 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,7 +1,8 @@
import { findKey, has } from 'lodash';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
-import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants';
export const frequentGroups = (state) => {
return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
@@ -26,3 +27,13 @@ export const queryLanguageFilters = (state) => state.query[languageFilterData.fi
export const currentUrlQueryHasLanguageFilters = (state) =>
has(state.urlQuery, languageFilterData.filterParam) &&
state.urlQuery[languageFilterData.filterParam]?.length > 0;
+
+export const navigationItems = (state) =>
+ Object.values(state.navigation).map((item) => ({
+ title: item.label,
+ icon: ICON_MAP[item.scope] || '',
+ link: item.link,
+ is_active: Boolean(item?.active),
+ pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '',
+ items: [],
+ }));
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index e20a43808cf..634f8f7a7fa 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -7,11 +7,11 @@ import createState from './state';
Vue.use(Vuex);
-export const getStoreConfig = ({ query, navigation }) => ({
+export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({
actions,
getters,
mutations,
- state: createState({ query, navigation }),
+ state: createState({ query, navigation, useNewNavigation }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index d85a135bb4e..a62b6728819 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
-const createState = ({ query, navigation }) => ({
+const createState = ({ query, navigation, useNewNavigation }) => ({
urlQuery: cloneDeep(query),
query,
groups: [],
@@ -14,6 +14,7 @@ const createState = ({ query, navigation }) => ({
},
sidebarDirty: false,
navigation,
+ useNewNavigation,
aggregations: {
error: false,
fetching: false,
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index 8e484e69646..2f02ef3475c 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -2,7 +2,7 @@ import { isEqual, orderBy } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { formatNumber } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
@@ -144,3 +144,7 @@ export const prepareSearchAggregations = (state, aggregationData) =>
return item;
});
+
+export const addCountOverLimit = (count = '') => {
+ return count.includes('+') ? '+' : '';
+};
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
deleted file mode 100644
index 94244eeb12e..00000000000
--- a/app/assets/javascripts/search_autocomplete.js
+++ /dev/null
@@ -1,520 +0,0 @@
-/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
-
-import $ from 'jquery';
-import { escape, throttle } from 'lodash';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, __, sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import axios from './lib/utils/axios_utils';
-import { spriteIcon } from './lib/utils/common_utils';
-import {
- isInGroupsPage,
- isInProjectPage,
- getGroupSlug,
- getProjectSlug,
-} from './search_autocomplete_utils';
-
-/**
- * Search input in top navigation bar.
- * On click, opens a dropdown
- * As the user types it filters the results
- * When the user clicks `x` button it cleans the input and closes the dropdown.
- */
-
-const KEYCODE = {
- ESCAPE: 27,
- BACKSPACE: 8,
- ENTER: 13,
- UP: 38,
- DOWN: 40,
-};
-
-function setSearchOptions() {
- const $projectOptionsDataEl = $('.js-search-project-options');
- const $groupOptionsDataEl = $('.js-search-group-options');
- const $dashboardOptionsDataEl = $('.js-search-dashboard-options');
-
- if ($projectOptionsDataEl.length) {
- gl.projectOptions = gl.projectOptions || {};
-
- const projectPath = $projectOptionsDataEl.data('projectPath');
-
- gl.projectOptions[projectPath] = {
- name: $projectOptionsDataEl.data('name'),
- issuesPath: $projectOptionsDataEl.data('issuesPath'),
- issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
- mrPath: $projectOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($groupOptionsDataEl.length) {
- gl.groupOptions = gl.groupOptions || {};
-
- const groupPath = $groupOptionsDataEl.data('groupPath');
-
- gl.groupOptions[groupPath] = {
- name: $groupOptionsDataEl.data('name'),
- issuesPath: $groupOptionsDataEl.data('issuesPath'),
- mrPath: $groupOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($dashboardOptionsDataEl.length) {
- gl.dashboardOptions = {
- name: s__('SearchAutocomplete|All GitLab'),
- issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
- mrPath: $dashboardOptionsDataEl.data('mrPath'),
- };
- }
-}
-
-export class SearchAutocomplete {
- constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
- setSearchOptions();
- this.bindEventContext();
- this.wrap = wrap || $('.search');
- this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
- this.projectId = projectId || this.optsEl.data('autocompleteProjectId') || '';
- this.projectRef = projectRef || this.optsEl.data('autocompleteProjectRef') || '';
- this.dropdown = this.wrap.find('.dropdown');
- this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
- this.dropdownMenu = this.dropdown.find('.dropdown-menu');
- this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.scopeInputEl = this.getElement('#scope');
- this.searchInput = this.getElement('.search-input');
- this.projectInputEl = this.getElement('#search_project_id');
- this.groupInputEl = this.getElement('#group_id');
- this.searchCodeInputEl = this.getElement('#search_code');
- this.repositoryInputEl = this.getElement('#repository_ref');
- this.clearInput = this.getElement('.js-clear-input');
- this.scrollFadeInitialized = false;
- this.saveOriginalState();
-
- // Only when user is logged in
- if (gon.current_user_id) {
- this.createAutocomplete();
- }
-
- this.bindEvents();
- this.dropdownToggle.dropdown();
- this.searchInput.addClass('js-autocomplete-disabled');
- }
-
- // Finds an element inside wrapper element
- bindEventContext() {
- this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
- this.onClearInputClick = this.onClearInputClick.bind(this);
- this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
- this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
- this.onSearchInputChange = this.onSearchInputChange.bind(this);
- this.setScrollFade = this.setScrollFade.bind(this);
- }
- getElement(selector) {
- return this.wrap.find(selector);
- }
-
- saveOriginalState() {
- return (this.originalState = this.serializeState());
- }
-
- createAutocomplete() {
- return initDeprecatedJQueryDropdown(this.searchInput, {
- filterInputBlur: false,
- filterable: true,
- filterRemote: true,
- highlight: true,
- icon: true,
- enterCallback: false,
- filterInput: 'input#search',
- search: {
- fields: ['text'],
- },
- id: this.getSearchText,
- data: this.getData.bind(this),
- selectable: true,
- clicked: this.onClick.bind(this),
- trackSuggestionClickedLabel: 'search_autocomplete_suggestion',
- });
- }
-
- getSearchText(selectedObject) {
- return selectedObject.id ? selectedObject.text : '';
- }
-
- getData(term, callback) {
- if (!term) {
- const contents = this.getCategoryContents();
- if (contents) {
- const deprecatedJQueryDropdownInstance = this.searchInput.data('deprecatedJQueryDropdown');
-
- if (deprecatedJQueryDropdownInstance) {
- deprecatedJQueryDropdownInstance.filter.options.callback(contents);
- }
- this.enableAutocomplete();
- }
- return;
- }
-
- // Prevent multiple ajax calls
- if (this.loadingSuggestions) {
- return;
- }
-
- this.loadingSuggestions = true;
-
- return axios
- .get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term,
- },
- })
- .then((response) => {
- const options = this.scopedSearchOptions(term);
-
- // List results
- let lastCategory = null;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- options.push({ type: 'separator' });
- options.push({
- type: 'header',
- content: suggestion.category,
- });
- lastCategory = suggestion.category;
- }
-
- // Add the suggestion
- options.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- icon: this.getAvatar(suggestion),
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
-
- callback(options);
-
- this.loadingSuggestions = false;
- this.highlightFirstRow();
- this.setScrollFade();
- })
- .catch(() => {
- this.loadingSuggestions = false;
- });
- }
-
- getCategoryContents() {
- const userName = gon.current_username;
- const { projectOptions, groupOptions, dashboardOptions } = gl;
-
- // Get options
- let options;
- if (isInProjectPage() && projectOptions) {
- options = projectOptions[getProjectSlug()];
- } else if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
- }
-
- const { issuesPath, mrPath, name, issuesDisabled } = options;
- const baseItems = [];
-
- if (name) {
- baseItems.push({
- type: 'header',
- content: `${name}`,
- });
- }
-
- const issueItems = [
- {
- text: s__('SearchAutocomplete|Issues assigned to me'),
- url: `${issuesPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Issues I've created"),
- url: `${issuesPath}/?author_username=${userName}`,
- },
- ];
- const mergeRequestItems = [
- {
- text: s__('SearchAutocomplete|Merge requests assigned to me'),
- url: `${mrPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"),
- url: `${mrPath}/?reviewer_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests I've created"),
- url: `${mrPath}/?author_username=${userName}`,
- },
- ];
-
- let items;
- if (issuesDisabled) {
- items = baseItems.concat(mergeRequestItems);
- } else {
- items = baseItems.concat(...issueItems, ...mergeRequestItems);
- }
- return items;
- }
-
- // Add option to proceed with the search for each
- // scope that is currently available, namely:
- //
- // - Search in this project
- // - Search in this group (or project's group)
- // - Search in all GitLab
- scopedSearchOptions(term) {
- const icon = spriteIcon('search', 's16 inline-search-icon');
- const projectId = this.projectInputEl.val();
- const groupId = this.groupInputEl.val();
- const options = [];
-
- if (projectId) {
- const projectOptions = gl.projectOptions[getProjectSlug()];
- const url = groupId
- ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar`
- : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`;
-
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in project %{projectName}`),
- {
- projectName: `<i>${projectOptions.name}</i>`,
- },
- false,
- ),
- url,
- });
- }
-
- if (groupId) {
- const groupOptions = gl.groupOptions[getGroupSlug()];
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in group %{groupName}`),
- {
- groupName: `<i>${groupOptions.name}</i>`,
- },
- false,
- ),
- url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`,
- });
- }
-
- options.push({
- icon,
- text: term,
- template: s__('SearchAutocomplete|in all GitLab'),
- url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`,
- });
-
- return options;
- }
-
- serializeState() {
- return {
- // Search Criteria
- search_project_id: this.projectInputEl.val(),
- group_id: this.groupInputEl.val(),
- search_code: this.searchCodeInputEl.val(),
- repository_ref: this.repositoryInputEl.val(),
- scope: this.scopeInputEl.val(),
- };
- }
-
- bindEvents() {
- this.searchInput.on('input', this.onSearchInputChange);
- this.searchInput.on('keyup', this.onSearchInputKeyUp);
- this.searchInput.on('focus', this.onSearchInputFocus);
- this.searchInput.on('blur', this.onSearchInputBlur);
- this.clearInput.on('click', this.onClearInputClick);
- this.dropdownContent.on(
- 'scroll',
- throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- );
-
- this.searchInput.on('click', (e) => {
- e.stopPropagation();
- });
- }
-
- enableAutocomplete() {
- this.setScrollFade();
-
- // No need to enable anything if user is not logged in
- if (!gon.current_user_id) {
- return;
- }
-
- // If the dropdown is closed, we'll open it
- if (!this.dropdown.hasClass('show')) {
- this.loadingSuggestions = false;
- this.dropdownToggle.dropdown('toggle');
-
- const trackEvent = 'click_search_bar';
- const trackCategory = undefined; // will be default set in event method
-
- Tracking.event(trackCategory, trackEvent, {
- label: 'main_navigation',
- property: 'navigation',
- });
-
- return this.searchInput.removeClass('js-autocomplete-disabled');
- }
- }
-
- onSearchInputChange() {
- this.enableAutocomplete();
- }
-
- onSearchInputKeyUp(e) {
- switch (e.keyCode) {
- case KEYCODE.ESCAPE:
- this.restoreOriginalState();
- break;
- case KEYCODE.ENTER:
- this.disableAutocomplete();
- break;
- default:
- }
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- }
-
- onSearchInputFocus() {
- this.isFocused = true;
- this.wrap.addClass('search-active');
- if (this.getValue() === '') {
- return this.getData();
- }
- }
-
- getValue() {
- return this.searchInput.val();
- }
-
- onClearInputClick(e) {
- e.preventDefault();
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- return this.searchInput.val('').focus();
- }
-
- onSearchInputBlur() {
- this.isFocused = false;
- this.wrap.removeClass('search-active');
- // If input is blank then restore state
- if (this.searchInput.val() === '') {
- this.restoreOriginalState();
- }
- this.dropdownMenu.removeClass('show');
- }
-
- restoreOriginalState() {
- const inputs = Object.keys(this.originalState);
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- this.getElement(`#${input}`).val(this.originalState[input]);
- }
- }
-
- resetSearchState() {
- const inputs = Object.keys(this.originalState);
- const results = [];
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- results.push(this.getElement(`#${input}`).val(''));
- }
- return results;
- }
-
- disableAutocomplete() {
- if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
- this.searchInput.addClass('js-autocomplete-disabled');
- this.dropdownToggle.dropdown('toggle');
- this.restoreMenu();
- }
- }
-
- restoreMenu() {
- const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
- return this.dropdownContent.html(html);
- }
-
- onClick(item, $el, e) {
- if (window.location.pathname.indexOf(item.url) !== -1) {
- if (!e.metaKey) e.preventDefault();
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- }
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- }
- $el.removeClass('is-active');
- this.disableAutocomplete();
- return this.searchInput.val('').focus();
- }
- }
-
- highlightFirstRow() {
- this.searchInput.data('deprecatedJQueryDropdown').highlightRowAtIndex(null, 0);
- }
-
- getAvatar(item) {
- if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) {
- return false;
- }
-
- const { label, id } = item;
- const avatarUrl = item.avatar_url;
- const avatar = avatarUrl
- ? `<img class="search-item-avatar" src="${avatarUrl}" />`
- : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
- escape(label),
- )}</div>`;
-
- return avatar;
- }
-
- isScrolledUp() {
- const el = this.dropdownContent[0];
- const currentPosition = this.contentClientHeight + el.scrollTop;
-
- return currentPosition < this.maxPosition;
- }
-
- initScrollFade() {
- const el = this.dropdownContent[0];
- this.scrollFadeInitialized = true;
-
- this.contentClientHeight = el.clientHeight;
- this.maxPosition = el.scrollHeight;
- this.dropdownMenu.addClass('dropdown-content-faded-mask');
- }
-
- setScrollFade() {
- this.initScrollFade();
-
- this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
- }
-}
-
-export default function initSearchAutocomplete(opts) {
- return new SearchAutocomplete(opts);
-}
diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js
deleted file mode 100644
index a9a0f941e93..00000000000
--- a/app/assets/javascripts/search_autocomplete_utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { getPagePath } from './lib/utils/common_utils';
-
-export const isInGroupsPage = () => getPagePath() === 'groups';
-
-export const isInProjectPage = () => getPagePath() === 'projects';
-
-export const getProjectSlug = () => {
- if (isInProjectPage()) {
- return document?.body?.dataset?.project;
- }
- return null;
-};
-
-export const getGroupSlug = () => {
- if (isInProjectPage() || isInGroupsPage()) {
- return document?.body?.dataset?.group;
- }
- return null;
-};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index ccfaa678201..e96f71981e5 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -1,7 +1,6 @@
<script>
import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { parseErrorMessage } from '~/lib/utils/error_message';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
@@ -34,9 +33,6 @@ export const i18n = {
'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.',
),
securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'),
- genericErrorText: s__(
- `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
- ),
};
export default {
@@ -128,9 +124,8 @@ export default {
dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
- onError(error) {
- const { message, userFacing } = parseErrorMessage(error);
- this.errorMessage = userFacing ? message : i18n.genericErrorText;
+ onError(message) {
+ this.errorMessage = message;
},
dismissAlert() {
this.errorMessage = '';
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 6beb6cd4d34..1d5ff5eb16f 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -6,6 +6,7 @@ import {
REPORT_TYPE_SAST_IAC,
REPORT_TYPE_DAST,
REPORT_TYPE_DAST_PROFILES,
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
@@ -67,6 +68,30 @@ export const DAST_PROFILES_DESCRIPTION = s__(
);
export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles');
+export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature');
+export const BAS_BADGE_TOOLTIP = s__(
+ 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.',
+);
+export const BAS_DESCRIPTION = s__(
+ 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.',
+);
+export const BAS_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+);
+export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)');
+export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS');
+
+export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__(
+ 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.',
+);
+export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+ { anchor: 'extend-dynamic-application-security-testing-dast' },
+);
+export const BAS_DAST_FEATURE_FLAG_NAME = s__(
+ 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)',
+);
+
export const SECRET_DETECTION_NAME = __('Secret Detection');
export const SECRET_DETECTION_DESCRIPTION = __(
'Analyze your source code and git history for secrets.',
@@ -142,6 +167,7 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ BAS: BAS_SHORT_NAME,
GENERIC: s__('ciReport|Manually added'),
};
@@ -223,6 +249,25 @@ export const securityFeatures = [
configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
},
},
+ {
+ anchor: 'bas',
+ badge: {
+ alwaysDisplay: true,
+ text: BAS_BADGE_TEXT,
+ tooltipText: BAS_BADGE_TOOLTIP,
+ variant: 'info',
+ },
+ description: BAS_DESCRIPTION,
+ name: BAS_NAME,
+ helpPath: BAS_HELP_PATH,
+ secondary: {
+ configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH,
+ description: BAS_DAST_FEATURE_FLAG_DESCRIPTION,
+ name: BAS_DAST_FEATURE_FLAG_NAME,
+ },
+ shortName: BAS_SHORT_NAME,
+ type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ },
];
export const complianceFeatures = [
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 19b412d66ca..d1b705fe2fc 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -1,7 +1,10 @@
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
-import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants';
+import {
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ REPORT_TYPE_SAST_IAC,
+} from '~/vue_shared/security_reports/constants';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import FeatureCardBadge from './feature_card_badge.vue';
@@ -68,8 +71,7 @@ export default {
};
},
hasSecondary() {
- const { name, description, configurationText } = this.feature.secondary ?? {};
- return Boolean(name && description && configurationText);
+ return Boolean(this.feature.secondary);
},
// This condition is a temporary hack to not display any wrong information
// until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307.
@@ -78,7 +80,17 @@ export default {
return this.feature.type !== REPORT_TYPE_SAST_IAC;
},
hasBadge() {
- return Boolean(this.available && this.feature.badge?.text);
+ const shouldDisplay = this.available || this.feature.badge?.alwaysDisplay;
+ return Boolean(shouldDisplay && this.feature.badge?.text);
+ },
+ hasEnabledStatus() {
+ return (
+ this.isNotSastIACTemporaryHack &&
+ this.feature.type !== REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION
+ );
+ },
+ showSecondaryConfigurationHelpPath() {
+ return Boolean(this.available && this.feature.secondary?.configurationHelpPath);
},
},
methods: {
@@ -118,19 +130,25 @@ export default {
:badge-href="feature.badge.badgeHref"
/>
- <template v-if="enabled">
- <span>
- <gl-icon name="check-circle-filled" />
- <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
- </span>
- </template>
-
- <template v-else-if="available">
- <span>{{ $options.i18n.notEnabled }}</span>
+ <template v-if="hasEnabledStatus">
+ <template v-if="enabled">
+ <span>
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </span>
+ </template>
+
+ <template v-else-if="available">
+ <span>{{ $options.i18n.notEnabled }}</span>
+ </template>
+
+ <template v-else>
+ {{ $options.i18n.availableWith }}
+ </template>
</template>
- <template v-else>
- {{ $options.i18n.availableWith }}
+ <template v-else-if="!available">
+ <span>{{ $options.i18n.availableWith }}</span>
</template>
</div>
</div>
@@ -186,6 +204,16 @@ export default {
>
{{ feature.secondary.configurationText }}
</gl-button>
+
+ <gl-button
+ v-else-if="showSecondaryConfigurationHelpPath"
+ icon="external-link"
+ :href="feature.secondary.configurationHelpPath"
+ category="secondary"
+ class="gl-mt-5"
+ >
+ {{ $options.i18n.configurationGuide }}
+ </gl-button>
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index 8b40b48b54a..c61c02c8b3a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,7 +1,7 @@
<script>
-import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
-import { s__, sprintf } from '~/locale';
+import { __ } from '~/locale';
const AVAILABILITY_STATUS = {
NOT_SET: 'NOT_SET',
@@ -11,6 +11,7 @@ const AVAILABILITY_STATUS = {
export default {
components: {
GlAvatarLabeled,
+ GlBadge,
GlIcon,
},
props: {
@@ -25,30 +26,23 @@ export default {
},
},
computed: {
- userLabel() {
- const { name, status } = this.user;
- if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) {
- return name;
- }
- return sprintf(
- s__('UserAvailability|%{author} (Busy)'),
- {
- author: name,
- },
- false,
- );
+ isBusy() {
+ return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY;
},
hasCannotMergeIcon() {
return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge;
},
},
+ i18n: {
+ busy: __('Busy'),
+ },
};
</script>
<template>
<gl-avatar-labeled
:size="32"
- :label="userLabel"
+ :label="user.name"
:sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url"
class="gl-align-items-center gl-relative sidebar-participant"
@@ -61,6 +55,9 @@ export default {
class="merge-icon"
:size="12"
/>
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ $options.i18n.busy }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
index bed84dc5706..72084fdafb1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
@@ -1,10 +1,11 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlBadge, GlSprintf } from '@gitlab/ui';
import { isUserBusy } from '~/set_status_modal/utils';
export default {
name: 'UserNameWithStatus',
components: {
+ GlBadge,
GlSprintf,
},
props: {
@@ -40,17 +41,17 @@ export default {
</script>
<template>
<span :class="containerClasses">
- <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')">
+ <gl-sprintf :message="s__('UserAvailability|%{author}%{badgeStart}Busy%{badgeEnd}')">
<template #author
- >{{ name }}
- <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ ><span>{{ name }}</span
+ ><span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-ml-1"
>({{ pronouns }})</span
></template
>
- <template #span="{ content }"
- ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{
- content
- }}</span>
+ <template #badge="{ content }">
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ content }}
+ </gl-badge>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 190b8c1de62..5a9545f3460 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -106,6 +106,19 @@ export default {
),
});
},
+ subscribeToMore: {
+ document() {
+ return this.dateQueries[this.issuableType].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.skipIssueDueDateSubscription;
+ },
+ },
},
},
computed: {
@@ -163,6 +176,12 @@ export default {
dataTestId() {
return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date';
},
+ issuableId() {
+ return this.issuable.id;
+ },
+ skipIssueDueDateSubscription() {
+ return this.issuableType !== TYPE_ISSUE || !this.issuableId || this.isLoading;
+ },
},
methods: {
epicDatePopoverEl() {
@@ -302,6 +321,7 @@ export default {
v-if="!isLoading"
ref="datePicker"
class="gl-relative"
+ :value="parsedDate"
:min-date="minDate"
:max-date="maxDate"
:default-date="parsedDate"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
index 227d85d952b..8535398decf 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
@@ -78,6 +78,7 @@ export default {
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
+ data-testid="label-title"
/>
</div>
<div class="dropdown-content px-2">
@@ -113,6 +114,7 @@ export default {
category="primary"
variant="confirm"
class="float-left d-flex align-items-center"
+ data-testid="create-click"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
index 852ef0c6283..881d84a7d6e 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
@@ -1,5 +1,6 @@
export const SCOPED_LABEL_DELIMITER = '::';
export const DEBOUNCE_DROPDOWN_DELAY = 200;
+export const DEFAULT_LABEL_COLOR = '#6699cc';
export const DropdownVariant = {
Sidebar: 'sidebar',
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
index 1174ec3f01e..30eeb0fbe31 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
@@ -13,6 +13,7 @@ import { WORKSPACE_GROUP } from '~/issues/constants';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
+import { DEFAULT_LABEL_COLOR } from './constants';
const errorMessage = __('Error creating label.');
@@ -44,11 +45,16 @@ export default {
type: String,
required: true,
},
+ searchKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- labelTitle: '',
- selectedColor: '',
+ labelTitle: this.searchKey,
+ selectedColor: DEFAULT_LABEL_COLOR,
labelCreateInProgress: false,
error: undefined,
};
diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
index 76c47305369..581537264db 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
@@ -26,10 +26,11 @@ export default {
},
},
methods: {
- moveIssue(targetProject) {
+ async moveIssue(targetProject) {
this.moveInProgress = true;
- return this.$apollo
- .mutate({
+
+ try {
+ const { data } = await this.$apollo.mutate({
mutation: moveIssueMutation,
variables: {
moveIssueInput: {
@@ -38,24 +39,25 @@ export default {
targetProjectPath: targetProject.full_path,
},
},
- })
- .then(({ data = {} }) => {
- if (!data.issueMove) return;
+ });
+
+ if (!data.issueMove) return;
+
+ const { errors } = data.issueMove;
+ if (errors?.length > 0) {
+ throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
+ }
- const { errors } = data.issueMove;
- if (errors?.length > 0) {
- throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
- }
- visitUrl(data.issueMove?.issue.webUrl);
- })
- .catch((error) => {
- this.moveInProgress = false;
- createAlert({
- message: this.$options.i18n.moveErrorMessage,
- captureError: true,
- error,
- });
+ visitUrl(data.issueMove?.issue.webUrl);
+ } catch (error) {
+ createAlert({
+ message: this.$options.i18n.moveErrorMessage,
+ captureError: true,
+ error,
});
+ } finally {
+ this.moveInProgress = false;
+ }
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index a9d102eb303..bbd3cda0ad3 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -69,7 +69,7 @@ export default {
},
participantLabel() {
return sprintf(
- n__('%{count} participant', '%{count} participants', this.participants.length),
+ n__('%{count} Participant', '%{count} Participants', this.participants.length),
{ count: this.loading ? '' : this.participantCount },
);
},
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index a3710d9534e..99f9d5e872c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -6,6 +6,7 @@ import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
+const JUST_APPROVED = 'approved';
export default {
i18n: {
@@ -42,7 +43,7 @@ export default {
},
watch: {
users: {
- handler(users) {
+ handler(users, previousUsers) {
this.loadingStates = users.reduce(
(acc, user) => ({
...acc,
@@ -50,14 +51,41 @@ export default {
}),
this.loadingStates,
);
+ if (previousUsers) {
+ users.forEach((user) => {
+ const userPreviousState = previousUsers.find(({ id }) => id === user.id);
+ if (
+ userPreviousState &&
+ user.mergeRequestInteraction.approved &&
+ !userPreviousState.mergeRequestInteraction.approved
+ ) {
+ this.showApprovalAnimation(user.id);
+ }
+ });
+ }
},
immediate: true,
},
},
methods: {
+ showApprovalAnimation(userId) {
+ this.loadingStates[userId] = JUST_APPROVED;
+
+ setTimeout(() => {
+ this.loadingStates[userId] = null;
+ }, 1500);
+ },
+ approveAnimation(userId) {
+ return {
+ 'merge-request-approved-icon': this.loadingStates[userId] === JUST_APPROVED,
+ };
+ },
approvedByTooltipTitle(user) {
return sprintf(s__('MergeRequest|Approved by @%{username}'), user);
},
+ reviewedButNotApprovedTooltip(user) {
+ return sprintf(s__('MergeRequest|Reviewed by @%{username} but not yet approved'), user);
+ },
toggleShowLess() {
this.showLess = !this.showLess;
},
@@ -105,35 +133,38 @@ export default {
{{ user.name }}
</div>
</reviewer-avatar-link>
- <gl-icon
- v-if="user.mergeRequestInteraction.approved"
- v-gl-tooltip.left
- :size="16"
- :title="approvedByTooltipTitle(user)"
- name="status-success"
- class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
- data-testid="re-approved"
- />
- <gl-icon
- v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
- :size="24"
- name="check"
- class="float-right gl-py-2 gl-mr-2 gl-text-green-500"
- data-testid="re-request-success"
- />
<gl-button
- v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
+ v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
- class="float-right gl-text-gray-500!"
+ class="float-right gl-text-gray-500! gl-mr-2"
size="small"
icon="redo"
variant="link"
data-testid="re-request-button"
@click="reRequestReview(user.id)"
/>
+ <gl-icon
+ v-if="user.mergeRequestInteraction.approved"
+ v-gl-tooltip.left
+ :size="16"
+ :title="approvedByTooltipTitle(user)"
+ name="status-success"
+ class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
+ :class="approveAnimation(user.id)"
+ data-testid="approved"
+ />
+ <gl-icon
+ v-else-if="user.mergeRequestInteraction.reviewed"
+ v-gl-tooltip.left
+ :size="16"
+ :title="reviewedButNotApprovedTooltip(user)"
+ name="dotted-circle"
+ class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0"
+ data-testid="reviewed-not-approved"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 19e72da65f2..4721c6fee61 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -81,7 +81,7 @@ export default {
},
},
apollo: {
- currentAttribute: {
+ issuable: {
query() {
const { current } = this.issuableAttributeQuery;
const { query } = current[this.issuableType];
@@ -95,11 +95,12 @@ export default {
};
},
update(data) {
+ return data.workspace?.issuable || {};
+ },
+ result({ data }) {
if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) {
this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic;
}
-
- return data?.workspace?.issuable.attribute;
},
error(error) {
createAlert({
@@ -108,13 +109,26 @@ export default {
error,
});
},
+ subscribeToMore: {
+ document() {
+ return issuableAttributesQueries[this.issuableAttribute].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.shouldSkipRealTimeEpicLinkUpdates;
+ },
+ },
},
},
data() {
return {
updating: false,
selectedTitle: null,
- currentAttribute: null,
+ issuable: {},
hasCurrentAttribute: false,
editConfirmation: false,
tracking: {
@@ -125,6 +139,12 @@ export default {
};
},
computed: {
+ currentAttribute() {
+ return this.issuable.attribute;
+ },
+ issuableId() {
+ return this.issuable.id;
+ },
issuableAttributeQuery() {
return this.issuableAttributesQueries[this.issuableAttribute];
},
@@ -135,7 +155,7 @@ export default {
return this.currentAttribute?.webUrl;
},
loading() {
- return this.$apollo.queries.currentAttribute.loading;
+ return this.$apollo.queries.issuable.loading;
},
attributeTypeTitle() {
return this.widgetTitleText[this.issuableAttribute];
@@ -170,6 +190,9 @@ export default {
? !this.editConfirmation
: false;
},
+ shouldSkipRealTimeEpicLinkUpdates() {
+ return !this.issuableId || this.issuableAttribute !== IssuableAttributeType.Epic;
+ },
},
methods: {
updateAttribute({ id }) {
diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
index 6dacf4e10d3..ba0bf783315 100644
--- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
@@ -32,9 +32,22 @@ export default {
return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
},
},
+ watch: {
+ collapsed(value) {
+ this.updateLayout(value);
+ },
+ },
+ mounted() {
+ this.page = document.querySelector('.layout-page');
+ },
methods: {
toggle() {
this.$emit('toggle');
+ this.updateLayout();
+ },
+ updateLayout(collapsed) {
+ this.page?.classList.remove(collapsed ? 'right-sidebar-expanded' : 'right-sidebar-collapsed');
+ this.page?.classList.add(collapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded');
},
},
};
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 7bca83c4142..0f82182c6e2 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -13,6 +13,7 @@ import {
WORKSPACE_PROJECT,
} from '~/issues/constants';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql';
import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql';
import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
@@ -218,6 +219,7 @@ export const dueDateQueries = {
[TYPE_ISSUE]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
+ subscription: issuableDatesUpdatedSubscription,
},
[TYPE_EPIC]: {
query: epicDueDateQuery,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 99c3fdf82d4..2828b9fbf1a 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -148,7 +148,7 @@ function mountSidebarAssigneesWidget() {
name: 'SidebarAssigneesRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
directlyInviteMembers: Object.prototype.hasOwnProperty.call(
el.dataset,
'directlyInviteMembers',
@@ -162,7 +162,7 @@ function mountSidebarAssigneesWidget() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1,
- editable,
+ editable: parseBoolean(editable),
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -415,7 +415,7 @@ function mountSidebarDueDateWidget() {
name: 'SidebarDueDateWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarDueDateWidget, {
@@ -476,7 +476,7 @@ function mountIssuableLockForm(store) {
render: (createElement) =>
createElement(IssuableLockForm, {
props: {
- isEditable: editable,
+ isEditable: parseBoolean(editable),
},
}),
});
@@ -523,7 +523,7 @@ function mountSidebarSubscriptionsWidget() {
name: 'SidebarSubscriptionsWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSubscriptionsWidget, {
@@ -587,7 +587,7 @@ function mountSidebarSeverityWidget() {
name: 'SidebarSeverityWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSeverityWidget, {
@@ -645,7 +645,7 @@ function mountCopyEmailToClipboard() {
});
}
-export function mountMoveIssuesButton() {
+export async function mountMoveIssuesButton() {
const el = document.querySelector('.js-move-issues');
if (!el) {
@@ -658,7 +658,7 @@ export function mountMoveIssuesButton() {
el,
name: 'MoveIssuesRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render: (createElement) =>
createElement(MoveIssuesButton, {
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index baf906bb96c..ea3b3633ea7 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default class SidebarStore {
constructor(options) {
if (!SidebarStore.singleton) {
@@ -12,7 +14,7 @@ export default class SidebarStore {
const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
- this.editable = editable;
+ this.editable = parseBoolean(editable);
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
index 6b90fb80abf..a61b4e4f066 100644
--- a/app/assets/javascripts/sidebar/utils.js
+++ b/app/assets/javascripts/sidebar/utils.js
@@ -12,7 +12,7 @@ export const updateGlobalTodoCount = (additionalTodoCount) => {
if (countContainer === null) return;
- const currentCount = parseInt(countContainer.innerText, 10);
+ const currentCount = parseInt(countContainer.innerText, 10) || 0;
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b613e356a7a..bab167bb7e4 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
import $ from 'jquery';
import { createAlert } from '~/alert';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -66,7 +64,7 @@ export default class SingleFileDiff {
} else {
this.$chevronDownIcon.removeClass('gl-display-none');
this.$chevronRightIcon.addClass('gl-display-none');
- return this.getContentHTML(cb);
+ return this.getContentHTML(cb); // eslint-disable-line consistent-return
}
}
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index e6aa3be0371..24dd978585c 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -42,7 +42,7 @@ export default {
<label>
{{ __('Visibility level') }}
<gl-link v-if="helpLink" :href="helpLink" target="_blank"
- ><gl-icon :size="12" name="question"
+ ><gl-icon :size="12" name="question-o"
/></gl-link>
</label>
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index b3d4ecdda47..fa9da6cef9d 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -1,10 +1,9 @@
<script>
import * as Sentry from '@sentry/browser';
-import { GlSearchBoxByType } from '@gitlab/ui';
+import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
-import { contextSwitcherItems } from '../mock_data';
import { trackContextAccess, formatContextSwitcherItems } from '../utils';
import NavItem from './nav_item.vue';
import ProjectsList from './projects_list.vue';
@@ -14,12 +13,13 @@ export default {
i18n: {
contextNavigation: s__('Navigation|Context navigation'),
switchTo: s__('Navigation|Switch to...'),
- searchPlaceholder: s__('Navigation|Search for projects or groups'),
+ searchPlaceholder: s__('Navigation|Search your projects or groups'),
+ searchingLabel: s__('Navigation|Retrieving search results'),
+ searchError: s__('Navigation|There was an error fetching search results.'),
},
apollo: {
groupsAndProjects: {
query: searchUserProjectsAndGroups,
- debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
manual: true,
variables() {
return {
@@ -28,6 +28,7 @@ export default {
};
},
result(response) {
+ this.hasError = false;
try {
const {
data: {
@@ -41,11 +42,11 @@ export default {
this.projects = formatContextSwitcherItems(projects);
this.groups = formatContextSwitcherItems(groups);
} catch (e) {
- Sentry.captureException(e);
+ this.handleError(e);
}
},
error(e) {
- Sentry.captureException(e);
+ this.handleError(e);
},
skip() {
return !this.searchString;
@@ -54,11 +55,17 @@ export default {
},
components: {
GlSearchBoxByType,
+ GlLoadingIcon,
+ GlAlert,
NavItem,
ProjectsList,
GroupsList,
},
props: {
+ persistentLinks: {
+ type: Array,
+ required: true,
+ },
username: {
type: String,
required: true,
@@ -82,19 +89,36 @@ export default {
searchString: '',
projects: [],
groups: [],
+ hasError: false,
};
},
computed: {
isSearch() {
return Boolean(this.searchString);
},
+ isSearching() {
+ return this.$apollo.queries.groupsAndProjects.loading;
+ },
},
- contextSwitcherItems,
created() {
if (this.currentContext.namespace) {
trackContextAccess(this.username, this.currentContext);
}
},
+ methods: {
+ /**
+ * This needs to be exposed publicly so that we can auto-focus the search input when the parent
+ * GlCollapse is shown.
+ */
+ focusInput() {
+ this.$refs['search-box'].focusInput();
+ },
+ handleError(e) {
+ Sentry.captureException(e);
+ this.hasError = true;
+ },
+ },
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
};
</script>
@@ -102,21 +126,36 @@ export default {
<div>
<div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white">
<gl-search-box-by-type
+ ref="search-box"
v-model="searchString"
class="context-switcher-search-box"
:placeholder="$options.i18n.searchPlaceholder"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
borderless
/>
</div>
- <nav :aria-label="$options.i18n.contextNavigation">
+ <gl-loading-icon
+ v-if="isSearching"
+ class="gl-mt-5"
+ size="md"
+ :label="$options.i18n.searchingLabel"
+ />
+ <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2">
+ {{ $options.i18n.searchError }}
+ </gl-alert>
+ <nav v-else :aria-label="$options.i18n.contextNavigation">
<ul class="gl-p-0 gl-list-style-none">
<li v-if="!isSearch">
<div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
{{ $options.i18n.switchTo }}
</div>
<ul :aria-label="$options.i18n.switchTo" class="gl-p-0">
- <nav-item :item="$options.contextSwitcherItems.yourWork" />
- <nav-item :item="$options.contextSwitcherItems.explore" />
+ <nav-item
+ v-for="item in persistentLinks"
+ :key="item.link"
+ :item="item"
+ :link-classes="{ [item.link_classes]: item.link_classes }"
+ />
</ul>
</li>
<projects-list
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index e0b6870872c..e56ef9e410b 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -38,13 +38,13 @@ export default {
<button
v-collapse-toggle.context-switcher
type="button"
- class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8"
+ class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8"
>
<span
v-if="context.icon"
class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4"
>
- <gl-icon :name="context.icon" :size="16" />
+ <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" />
</span>
<gl-avatar
v-else
@@ -55,11 +55,11 @@ export default {
:src="context.avatar"
class="gl-mr-3 gl-ml-4"
/>
- <div class="gl-overflow-auto">
+ <div class="gl-overflow-auto gl-text-gray-900">
<gl-truncate :text="context.title" />
</div>
<span class="gl-flex-grow-1 gl-text-right gl-mr-4">
- <gl-icon :name="collapseIcon" />
+ <gl-icon class="gl-text-gray-400" :name="collapseIcon" />
</span>
</button>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index e79b609545e..98417b7cd25 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -7,7 +7,7 @@ export default {
},
props: {
count: {
- type: Number,
+ type: [Number, String],
required: true,
},
href: {
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index d3bb31a69fa..4cff4642cf7 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -1,6 +1,10 @@
<script>
import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
+import { DROPDOWN_Y_OFFSET } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -147;
export default {
components: {
@@ -16,7 +20,22 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ dropdownOpen: false,
+ };
+ },
toggleId: 'create-menu-toggle',
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
+ },
};
</script>
@@ -30,9 +49,17 @@ export default {
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
+ :popper-options="$options.popperOptions"
data-qa-selector="new_menu_toggle"
+ @shown="dropdownOpen = true"
+ @hidden="dropdownOpen = false"
/>
- <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
+ <gl-tooltip
+ v-if="!dropdownOpen"
+ :target="`#${$options.toggleId}`"
+ placement="bottom"
+ container="#super-sidebar"
+ >
{{ $options.i18n.createNew }}
</gl-tooltip>
</div>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index 5269c7f8d5e..56143a29f52 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -1,13 +1,20 @@
<script>
+import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import { s__ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
import ItemsList from './items_list.vue';
export default {
components: {
+ GlIcon,
+ GlButton,
ItemsList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
title: {
type: String,
@@ -29,12 +36,16 @@ export default {
data() {
return {
cachedFrequentItems: [],
+ isItemsListEditable: false,
};
},
computed: {
isEmpty() {
return !this.cachedFrequentItems.length;
},
+ allowItemsEditing() {
+ return !this.isEmpty && AccessorUtilities.canUseLocalStorage();
+ },
},
created() {
this.getItemsFromLocalStorage();
@@ -52,6 +63,27 @@ export default {
Sentry.captureException(e);
}
},
+ toggleItemsListEditablity() {
+ this.isItemsListEditable = !this.isItemsListEditable;
+ },
+ handleItemRemove(item) {
+ try {
+ // Remove item from local storage
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
+ localStorage.setItem(
+ this.storageKey,
+ JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)),
+ );
+
+ // Update the list
+ this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ },
+ i18n: {
+ frequentItemsEditToggle: s__('Navigation|Toggle edit mode'),
},
};
</script>
@@ -61,14 +93,32 @@ export default {
<div
data-testid="list-title"
aria-hidden="true"
- class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
>
- {{ title }}
+ <span class="gl-flex-grow-1">{{ title }}</span>
+ <gl-button
+ v-if="allowItemsEditing"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.frequentItemsEditToggle"
+ :title="$options.i18n.frequentItemsEditToggle"
+ :class="{ 'gl-bg-gray-100!': isItemsListEditable }"
+ class="gl-p-2!"
+ @click="toggleItemsListEditablity"
+ >
+ <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" />
+ </gl-button>
</div>
<div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
{{ pristineText }}
</div>
- <items-list :aria-label="title" :items="cachedFrequentItems">
+ <items-list
+ :aria-label="title"
+ :items="cachedFrequentItems"
+ :editable="isItemsListEditable"
+ @remove-item="handleItemRemove"
+ >
<template #view-all-items>
<slot name="view-all-items"></slot>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
index 6798607b954..e8a54b0515e 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -6,73 +6,64 @@ import {
GlToken,
GlTooltipDirective,
GlResizeObserverDirective,
+ GlModal,
} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import { debounce } from 'lodash';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { debounce, clamp } from 'lodash';
import { truncate } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys';
import {
+ MIN_SEARCH_TERM,
SEARCH_GITLAB,
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
SEARCH_DESCRIBED_BY_DEFAULT,
SEARCH_DESCRIBED_BY_UPDATED,
SEARCH_RESULTS_LOADING,
SEARCH_RESULTS_SCOPE,
- KBD_HELP,
} from '~/vue_shared/global_search/constants';
import {
- FIRST_DROPDOWN_INDEX,
- SEARCH_BOX_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
IS_SEARCHING,
- IS_FOCUSED,
- IS_NOT_FOCUSED,
+ SEARCH_MODAL_ID,
+ SEARCH_INPUT_SELECTOR,
+ SEARCH_RESULTS_ITEM_SELECTOR,
} from '../constants';
-import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue';
-import HeaderSearchDefaultItems from './global_search_default_items.vue';
-import HeaderSearchScopedItems from './global_search_scoped_items.vue';
+import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
+import GlobalSearchDefaultItems from './global_search_default_items.vue';
+import GlobalSearchScopedItems from './global_search_scoped_items.vue';
export default {
- name: 'HeaderSearchApp',
+ name: 'GlobalSearchModal',
+ SEARCH_MODAL_ID,
i18n: {
SEARCH_GITLAB,
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
SEARCH_DESCRIBED_BY_DEFAULT,
SEARCH_DESCRIBED_BY_UPDATED,
SEARCH_RESULTS_LOADING,
SEARCH_RESULTS_SCOPE,
- KBD_HELP,
+ MIN_SEARCH_TERM,
},
directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
- HeaderSearchDefaultItems,
- HeaderSearchScopedItems,
- HeaderSearchAutocompleteItems,
- DropdownKeyboardNavigation,
+ GlobalSearchDefaultItems,
+ GlobalSearchScopedItems,
+ GlobalSearchAutocompleteItems,
GlIcon,
GlToken,
- },
- data() {
- return {
- showDropdown: false,
- isFocused: false,
- currentFocusIndex: SEARCH_BOX_INDEX,
- };
+ GlModal,
},
computed: {
...mapState(['search', 'loading', 'searchContext']),
- ...mapGetters(['searchQuery', 'searchOptions']),
+ ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
searchText: {
get() {
return this.search;
@@ -81,51 +72,26 @@ export default {
this.setSearch(value);
},
},
- currentFocusedOption() {
- return this.searchOptions[this.currentFocusIndex];
- },
- currentFocusedId() {
- return this.currentFocusedOption?.html_id;
- },
- isLoggedIn() {
- return Boolean(gon?.current_username);
- },
- showSearchDropdown() {
- if (!this.showDropdown || !this.isLoggedIn) {
- return false;
- }
- return this.searchOptions?.length > 0;
- },
showDefaultItems() {
return !this.searchText;
},
searchTermOverMin() {
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
- defaultIndex() {
- if (this.showDefaultItems) {
- return SEARCH_BOX_INDEX;
- }
- return FIRST_DROPDOWN_INDEX;
- },
-
- searchInputDescribeBy() {
- if (this.isLoggedIn) {
- return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
- }
- return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
+ showScopedSearchItems() {
+ return this.searchTermOverMin && this.scopedSearchOptions.length > 1;
},
- dropdownResultsDescription() {
- if (!this.showSearchDropdown) {
- return ''; // This allows aria-live to see register an update when the dropdown is shown
- }
-
+ searchResultsDescription() {
if (this.showDefaultItems) {
return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
count: this.searchOptions.length,
});
}
+ if (!this.searchTermOverMin) {
+ return this.$options.i18n.MIN_SEARCH_TERM;
+ }
+
return this.loading
? this.$options.i18n.SEARCH_RESULTS_LOADING
: sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
@@ -135,12 +101,10 @@ export default {
searchBarClasses() {
return {
[IS_SEARCHING]: this.searchTermOverMin,
- [IS_FOCUSED]: this.isFocused,
- [IS_NOT_FOCUSED]: !this.isFocused,
};
},
showScopeHelp() {
- return this.searchTermOverMin && this.isFocused;
+ return this.searchTermOverMin;
},
searchBarItem() {
return this.searchOptions?.[0];
@@ -159,47 +123,7 @@ export default {
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
- openDropdown() {
- this.showDropdown = true;
-
- // check isFocused state to avoid firing duplicate events
- if (!this.isFocused) {
- this.isFocused = true;
- this.$emit('expandSearchBar', true);
-
- Tracking.event(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }
- },
- closeDropdown() {
- this.showDropdown = false;
- },
- collapseAndCloseSearchBar() {
- // we need a delay on this method
- // for the search bar not to remove
- // the clear button from dom
- // and register clicks on dropdown items
- setTimeout(() => {
- this.showDropdown = false;
- this.isFocused = false;
- this.$emit('collapseSearchBar');
-
- Tracking.event(undefined, 'blur_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }, 200);
- },
- 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) {
- this.openDropdown();
if (!searchTerm) {
this.clearAutocomplete();
} else {
@@ -216,105 +140,174 @@ export default {
}
inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
},
+ getFocusableOptions() {
+ return Array.from(
+ this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [],
+ );
+ },
+ onKeydown(event) {
+ const { code, target } = event;
+
+ let stop = true;
+
+ const elements = this.getFocusableOptions();
+ if (elements.length < 1) return;
+
+ const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
+
+ if (code === HOME_KEY) {
+ this.focusItem(0, elements);
+ } else if (code === END_KEY) {
+ this.focusItem(elements.length - 1, elements);
+ } else if (code === ARROW_UP_KEY) {
+ if (isSearchInput) return;
+
+ if (elements.indexOf(target) === 0) {
+ this.focusSearchInput();
+ return;
+ }
+ this.focusNextItem(event, elements, -1);
+ } else if (code === ARROW_DOWN_KEY) {
+ this.focusNextItem(event, elements, 1);
+ } else if (code === ESC_KEY) {
+ this.$refs.searchModal.close();
+ } else {
+ stop = false;
+ }
+
+ if (stop) {
+ event.preventDefault();
+ }
+ },
+ focusSearchInput() {
+ this.$refs.searchInputBox.$el.querySelector('input').focus();
+ },
+ focusNextItem(event, elements, offset) {
+ const { target } = event;
+ const currentIndex = elements.indexOf(target);
+ const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
+
+ this.focusItem(nextIndex, elements);
+ },
+ focusItem(index, elements) {
+ this.nextFocusedItemIndex = index;
+
+ elements[index]?.focus();
+ },
+ submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return;
+ }
+ visitUrl(this.searchQuery);
+ },
},
- SEARCH_BOX_INDEX,
- FIRST_DROPDOWN_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
};
</script>
<template>
- <form
- v-outside="closeDropdown"
- role="search"
- :aria-label="$options.i18n.SEARCH_GITLAB"
- class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="searchBarClasses"
- data-testid="header-search-form"
+ <gl-modal
+ ref="searchModal"
+ :modal-id="$options.SEARCH_MODAL_ID"
+ hide-header
+ hide-footer
+ hide-header-close
+ scrollable
+ body-class="gl-p-0!"
+ modal-class="global-search-modal"
+ :centered="false"
>
- <gl-search-box-by-type
- id="search"
- ref="searchInputBox"
- v-model="searchText"
- role="searchbox"
- class="gl-z-index-1"
- data-qa-selector="search_term_field"
- autocomplete="off"
- :placeholder="$options.i18n.SEARCH_GITLAB"
- :aria-activedescendant="currentFocusedId"
- :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
- @focus="openDropdown"
- @click="openDropdown"
- @blur="collapseAndCloseSearchBar"
- @input="getAutocompleteOptions"
- @keydown.enter.stop.prevent="submitSearch"
- @keydown.esc.stop.prevent="closeDropdown"
- />
- <gl-token
- v-if="showScopeHelp"
- 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.SEARCH_RESULTS_SCOPE, {
- scope: infieldHelpContent,
- }),
- )
- }}
- </gl-token>
- <kbd
- v-show="!isFocused"
- 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.KBD_HELP"
- >/</kbd
- >
- <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
- searchInputDescribeBy
- }}</span>
- <span
- role="region"
- :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
- class="gl-sr-only"
- aria-live="polite"
- aria-atomic="true"
+ <form
+ role="search"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
+ class="gl-relative gl-rounded-base gl-w-full"
+ :class="searchBarClasses"
+ data-testid="global-search-form"
>
- {{ dropdownResultsDescription }}
- </span>
- <div
- v-if="showSearchDropdown"
- data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
- >
- <div class="header-search-dropdown-content gl-py-2">
- <dropdown-keyboard-navigation
- v-model="currentFocusIndex"
- :max="searchOptions.length - 1"
- :min="$options.FIRST_DROPDOWN_INDEX"
- :default-index="defaultIndex"
- @tab="closeDropdown"
- />
- <header-search-default-items
- v-if="showDefaultItems"
- :current-focused-option="currentFocusedOption"
+ <div class="gl-p-1">
+ <gl-search-box-by-type
+ id="search"
+ ref="searchInputBox"
+ v-model="searchText"
+ role="searchbox"
+ data-testid="global-search-input"
+ autocomplete="off"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
+ borderless
+ @input="getAutocompleteOptions"
+ @keydown.enter.stop.prevent="submitSearch"
+ @keydown="onKeydown"
/>
- <template v-else>
- <header-search-scoped-items
- v-if="searchTermOverMin"
- :current-focused-option="currentFocusedOption"
+ <gl-token
+ v-if="showScopeHelp"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help gl-sm-display-block gl-display-none"
+ view-only
+ :title="scopeTokenTitle"
+ >
+ <gl-icon
+ v-if="infieldHelpIcon"
+ class="gl-mr-2"
+ :aria-label="infieldHelpContent"
+ :name="infieldHelpIcon"
+ :size="16"
/>
- <header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
+ {{
+ getTruncatedScope(
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }),
+ )
+ }}
+ </gl-token>
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
+ {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }}
+ </span>
+ </div>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ searchResultsDescription }}
+ </span>
+ <div
+ ref="resultsList"
+ data-testid="global-search-results"
+ class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
+ @keydown="onKeydown"
+ >
+ <global-search-default-items v-if="showDefaultItems" />
+ <template v-else>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
</template>
</div>
- </div>
- </form>
+
+ <template v-if="searchContext">
+ <input
+ v-if="searchContext.group"
+ type="hidden"
+ name="group_id"
+ :value="searchContext.group.id"
+ />
+ <input
+ v-if="searchContext.project"
+ type="hidden"
+ name="project_id"
+ :value="searchContext.project.id"
+ />
+
+ <template v-if="searchContext.group || searchContext.project">
+ <input type="hidden" name="scope" :value="searchContext.scope" />
+ <input type="hidden" name="search_code" :value="searchContext.code_search" />
+ </template>
+
+ <input type="hidden" name="snippets" :value="searchContext.for_snippets" />
+ <input type="hidden" name="repository_ref" :value="searchContext.ref" />
+ </template>
+ </form>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
index 1838214def6..cd623200b03 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -1,113 +1,36 @@
<script>
-import {
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
- GlAvatar,
- GlAlert,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import { truncateNamespace } from '~/lib/utils/text_utility';
-import {
- GROUPS_CATEGORY,
- PROJECTS_CATEGORY,
- MERGE_REQUEST_CATEGORY,
- ISSUES_CATEGORY,
- RECENT_EPICS_CATEGORY,
- AUTOCOMPLETE_ERROR_MESSAGE,
-} from '~/vue_shared/global_search/constants';
-import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants';
export default {
- name: 'HeaderSearchAutocompleteItems',
+ name: 'GlobalSearchAutocompleteItems',
i18n: {
AUTOCOMPLETE_ERROR_MESSAGE,
},
components: {
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
GlAvatar,
GlAlert,
GlLoadingIcon,
+ GlDisclosureDropdownGroup,
},
directives: {
SafeHtml,
},
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
- },
computed: {
- ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']),
- ...mapGetters(['autocompleteGroupedSearchOptions']),
- },
- watch: {
- currentFocusedOption() {
- const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
-
- if (focusedElement) {
- focusedElement.scrollIntoView(false);
- }
+ ...mapState(['search', 'loading', 'autocompleteError']),
+ ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
+ isPrecededByScopedOptions() {
+ return this.scopedSearchOptions.length > 1;
},
},
methods: {
- truncateNamespace(string) {
- if (string.split(' / ').length > 2) {
- return truncateNamespace(string);
- }
-
- return string;
- },
highlightedName(val) {
return highlight(val, this.search);
},
- avatarSize(data) {
- if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
- return LARGE_AVATAR_PX;
- }
-
- return SMALL_AVATAR_PX;
- },
- isOptionFocused(data) {
- return this.currentFocusedOption?.html_id === data.html_id;
- },
- isProjectsCategory(data) {
- return data.category === PROJECTS_CATEGORY;
- },
- getEntityId(data) {
- switch (data.category) {
- case GROUPS_CATEGORY:
- case RECENT_EPICS_CATEGORY:
- return data.group_id || data.id || this.searchContext?.group?.id;
- case PROJECTS_CATEGORY:
- case ISSUES_CATEGORY:
- case MERGE_REQUEST_CATEGORY:
- return data.project_id || data.id || this.searchContext?.project?.id;
- default:
- return data.id;
- }
- },
- getEntitytName(data) {
- switch (data.category) {
- case GROUPS_CATEGORY:
- case RECENT_EPICS_CATEGORY:
- return data.group_name || data.value || data.label || this.searchContext?.group?.name;
- case PROJECTS_CATEGORY:
- case ISSUES_CATEGORY:
- case MERGE_REQUEST_CATEGORY:
- return data.project_name || data.value || data.label || this.searchContext?.project?.name;
- default:
- return data.label;
- }
- },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -115,46 +38,46 @@ export default {
<template>
<div>
- <template v-if="!loading">
- <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category">
- <gl-dropdown-divider v-if="index > 0" />
- <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="data in option.data"
- :id="data.html_id"
- :ref="data.html_id"
- :key="data.html_id"
- :class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
- :aria-selected="isOptionFocused(data)"
- :aria-label="data.label"
- tabindex="-1"
- :href="data.url"
- >
- <div class="gl-display-flex gl-align-items-center" aria-hidden="true">
+ <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group
+ v-for="group in autocompleteGroupedSearchOptions"
+ :key="group.name"
+ :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }"
+ :group="group"
+ bordered
+ >
+ <template #list-item="{ item }">
+ <div class="gl-display-flex gl-align-items-center">
<gl-avatar
- v-if="data.avatar_url !== undefined"
- :src="data.avatar_url"
- :entity-id="getEntityId(data)"
- :entity-name="getEntitytName(data)"
- :size="avatarSize(data)"
+ v-if="item.avatar_url !== undefined"
+ class="gl-mr-3"
+ :src="item.avatar_url"
+ :entity-id="item.entity_id"
+ :entity-name="item.entity_name"
+ :size="item.avatar_size"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ aria-hidden="true"
/>
<span class="gl-display-flex gl-flex-direction-column">
<span
- v-safe-html="highlightedName(data.value || data.label)"
+ v-safe-html="highlightedName(item.text)"
class="gl-text-gray-900"
+ data-testid="autocomplete-item-name"
></span>
<span
- v-if="data.value"
- v-safe-html="truncateNamespace(data.label)"
+ v-if="item.value"
+ v-safe-html="item.namespace"
class="gl-font-sm gl-text-gray-500"
+ data-testid="autocomplete-item-namespace"
></span>
</span>
</div>
- </gl-dropdown-item>
- </div>
- </template>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
+
<gl-loading-icon v-else size="lg" class="my-4" />
+
<gl-alert
v-if="autocompleteError"
class="gl-text-body gl-mt-2"
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
index f0d398297e9..239c61fd750 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -1,23 +1,15 @@
<script>
-import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
export default {
- name: 'HeaderSearchDefaultItems',
+ name: 'GlobalSearchDefaultItems',
i18n: {
ALL_GITLAB,
},
components: {
- GlDropdownSectionHeader,
- GlDropdownItem,
- },
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
+ GlDisclosureDropdownGroup,
},
computed: {
...mapState(['searchContext']),
@@ -29,30 +21,18 @@ export default {
this.$options.i18n.ALL_GITLAB
);
},
- },
- methods: {
- isOptionFocused(option) {
- return this.currentFocusedOption?.html_id === option.html_id;
+ defaultItemsGroup() {
+ return {
+ name: this.sectionHeader,
+ items: this.defaultSearchOptions,
+ };
},
},
};
</script>
<template>
- <div>
- <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="option in defaultSearchOptions"
- :id="option.html_id"
- :ref="option.html_id"
- :key="option.html_id"
- :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
- :aria-selected="isOptionFocused(option)"
- :aria-label="option.title"
- tabindex="-1"
- :href="option.url"
- >
- <span aria-hidden="true">{{ option.title }}</span>
- </gl-dropdown-item>
- </div>
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" />
+ </ul>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
index 1ef88492b23..76600f829f6 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -1,47 +1,26 @@
<script>
-import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
+import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
-import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants';
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
- name: 'HeaderSearchScopedItems',
- i18n: {
- SCOPED_SEARCH_ITEM_ARIA_LABEL,
- },
+ name: 'GlobalSearchScopedItems',
components: {
- GlDropdownItem,
GlIcon,
GlToken,
- },
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
+ GlDisclosureDropdownGroup,
},
computed: {
...mapState(['search']),
- ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']),
+ ...mapGetters(['scopedSearchGroup']),
},
methods: {
- isOptionFocused(option) {
- return this.currentFocusedOption?.html_id === option.html_id;
- },
- ariaLabel(option) {
- return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
- search: this.search,
- description: option.description || option.icon,
- scope: option.scope || '',
- });
- },
- titleLabel(option) {
+ titleLabel(item) {
return sprintf(s__('GlobalSearch|in %{scope}'), {
search: this.search,
- scope: option.scope || option.description,
+ scope: item.scope || item.description,
});
},
getTruncatedScope(scope) {
@@ -53,35 +32,23 @@ export default {
<template>
<div>
- <gl-dropdown-item
- v-for="option in scopedSearchOptions"
- :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
- 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"
+ <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!">
+ <template #list-item="{ item }">
+ <span
+ class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full"
>
- <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-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" />
+ <span class="gl-flex-grow-1">
+ <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only>
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(item)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index b9bb4e573fd..cb267df6122 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
-export const FIRST_DROPDOWN_INDEX = 0;
-
-export const SEARCH_BOX_INDEX = -1;
-
export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
@@ -20,14 +16,13 @@ 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'];
+export const INPUT_FIELD_PADDING = 84;
export const IS_SEARCHING = 'is-searching';
-export const IS_FOCUSED = 'is-focused';
-export const IS_NOT_FOCUSED = 'is-not-focused';
export const FETCH_TYPES = ['generic', 'search'];
+export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
+
+export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
-export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
+export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
index f86463b94d1..4a42f416206 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -1,6 +1,5 @@
import { omitBy, isNil } from 'lodash';
import { objectToQuery } from '~/lib/utils/url_utility';
-
import {
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
@@ -10,8 +9,10 @@ import {
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
- DROPDOWN_ORDER,
+ SEARCH_RESULTS_ORDER,
} from '~/vue_shared/global_search/constants';
+import { getFormattedItem } from '../utils';
+
import {
ICON_GROUP,
ICON_SUBGROUP,
@@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => {
const issues = [
{
- html_id: 'default-issues-assigned',
- title: MSG_ISSUES_ASSIGNED_TO_ME,
- url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ text: MSG_ISSUES_ASSIGNED_TO_ME,
+ href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
},
{
- html_id: 'default-issues-created',
- title: MSG_ISSUES_IVE_CREATED,
- url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ text: MSG_ISSUES_IVE_CREATED,
+ href: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
];
const mergeRequests = [
{
- html_id: 'default-mrs-assigned',
- title: MSG_MR_ASSIGNED_TO_ME,
- url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ text: MSG_MR_ASSIGNED_TO_ME,
+ href: `${getters.scopedMRPath}/?assignee_username=${userName}`,
},
{
- html_id: 'default-mrs-reviewer',
- title: MSG_MR_IM_REVIEWER,
- url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ text: MSG_MR_IM_REVIEWER,
+ href: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
},
{
- html_id: 'default-mrs-created',
- title: MSG_MR_IVE_CREATED,
- url: `${getters.scopedMRPath}/?author_username=${userName}`,
+ text: MSG_MR_IVE_CREATED,
+ href: `${getters.scopedMRPath}/?author_username=${userName}`,
},
];
return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
@@ -145,58 +141,64 @@ export const allUrl = (state) => {
};
export const scopedSearchOptions = (state, getters) => {
- const options = [];
+ const items = [];
if (state.searchContext?.project) {
- options.push({
- html_id: 'scoped-in-project',
+ items.push({
+ text: 'scoped-in-project',
scope: state.searchContext.project?.name || '',
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
- url: getters.projectUrl,
+ href: getters.projectUrl,
});
}
if (state.searchContext?.group) {
- options.push({
- html_id: 'scoped-in-group',
+ items.push({
+ text: 'scoped-in-group',
scope: state.searchContext.group?.name || '',
scopeCategory: GROUPS_CATEGORY,
icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
- url: getters.groupUrl,
+ href: getters.groupUrl,
});
}
- options.push({
- html_id: 'scoped-in-all',
+ items.push({
+ text: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
- url: getters.allUrl,
+ href: getters.allUrl,
});
- return options;
+ return items;
+};
+
+export const scopedSearchGroup = (state, getters) => {
+ const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : [];
+ return { items };
};
export const autocompleteGroupedSearchOptions = (state) => {
const groupedOptions = {};
const results = [];
- state.autocompleteOptions.forEach((option) => {
- const category = groupedOptions[option.category];
+ state.autocompleteOptions.forEach((item) => {
+ const group = groupedOptions[item.category];
+ const formattedItem = getFormattedItem(item, state.searchContext);
- if (category) {
- category.data.push(option);
+ if (group) {
+ group.items.push(formattedItem);
} else {
- groupedOptions[option.category] = {
- category: option.category,
- data: [option],
+ groupedOptions[item.category] = {
+ name: formattedItem.category,
+ items: [formattedItem],
};
- results.push(groupedOptions[option.category]);
+ results.push(groupedOptions[formattedItem.category]);
}
});
return results.sort(
- (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+ (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name),
);
};
@@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => {
}
const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
- (options, group) => {
- return [...options, ...group.data];
+ (items, group) => {
+ return [...items, ...group.items];
},
[],
);
@@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => {
return sortedAutocompleteOptions;
}
- return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
+ return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions);
};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
index 6e65345757f..d7d9ebecd16 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
@@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE';
-
export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
index 19b4d4ec330..9936c3f59d8 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -8,11 +8,7 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
- state.autocompleteOptions = [...state.autocompleteOptions].concat(
- data.map((d, i) => {
- return { html_id: `autocomplete-${d.category}-${i}`, ...d };
- }),
- );
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(data);
state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
new file mode 100644
index 00000000000..11d1fa1ab95
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
@@ -0,0 +1,81 @@
+import { pickBy } from 'lodash';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants';
+
+const getTruncatedNamespace = (string) => {
+ if (string.split(' / ').length > 2) {
+ return truncateNamespace(string);
+ }
+
+ return string;
+};
+const getAvatarSize = (category) => {
+ if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+};
+
+const getEntityId = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_id || item.id || searchContext?.group?.id;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_id || item.id || searchContext?.project?.id;
+ default:
+ return item.id;
+ }
+};
+const getEntityName = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_name || item.value || item.label || searchContext?.group?.name;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_name || item.value || item.label || searchContext?.project?.name;
+ default:
+ return item.label;
+ }
+};
+
+export const getFormattedItem = (item, searchContext) => {
+ const { id, category, value, label, url: href, avatar_url } = item;
+ let namespace;
+ const text = value || label;
+ if (value) {
+ namespace = getTruncatedNamespace(label);
+ }
+ const avatarSize = getAvatarSize(category);
+ const entityId = getEntityId(item, searchContext);
+ const entityName = getEntityName(item, searchContext);
+
+ return pickBy(
+ {
+ id,
+ category,
+ value,
+ label,
+ text,
+ href,
+ avatar_url,
+ avatar_size: avatarSize,
+ namespace,
+ entity_id: entityId,
+ entity_name: entityName,
+ },
+ (val) => val !== undefined,
+ );
+};
diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue
index 78b5ed2d31e..4fa15f1cd76 100644
--- a/app/assets/javascripts/super_sidebar/components/groups_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -36,16 +36,19 @@ export default {
storageKey() {
return `${this.username}/frequent-groups`;
},
- viewAllItem() {
+ viewAllProps() {
return {
- link: this.viewAllLink,
- title: s__('Navigation|View all groups'),
- icon: 'group',
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your groups'),
+ icon: 'group',
+ },
+ linkClasses: { 'dashboard-shortcuts-groups': true },
};
},
},
i18n: {
- title: s__('Navigation|Frequent groups'),
+ title: s__('Navigation|Frequently visited groups'),
searchTitle: s__('Navigation|Groups'),
pristineText: s__('Navigation|Groups you visit often will appear here.'),
noResultsText: s__('Navigation|No group matches found'),
@@ -61,7 +64,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</search-results>
<frequent-items-list
@@ -72,7 +75,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</frequent-items-list>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
index fb23a4f2deb..01b214f4b2b 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -5,6 +5,11 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import { __ } from '~/locale';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
+import Tracking from '~/tracking';
+import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -4;
export default {
components: {
@@ -14,6 +19,7 @@ export default {
GlDisclosureDropdownGroup,
GitlabVersionCheckBadge,
},
+ mixins: [Tracking.mixin({ property: 'nav_help_menu' })],
i18n: {
help: __('Help'),
support: __('Support'),
@@ -46,21 +52,63 @@ export default {
text: this.$options.i18n.version,
href: helpPagePath('update/index'),
version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`,
+ extraAttrs: {
+ ...this.trackingAttrs('version_help_dropdown'),
+ },
},
],
},
helpLinks: {
items: [
- { text: this.$options.i18n.help, href: helpPagePath() },
- { text: this.$options.i18n.support, href: this.sidebarData.support_path },
- { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' },
- { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` },
- { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' },
+ {
+ text: this.$options.i18n.help,
+ href: helpPagePath(),
+ extraAttrs: {
+ ...this.trackingAttrs('help'),
+ },
+ },
+ {
+ text: this.$options.i18n.support,
+ href: this.sidebarData.support_path,
+ extraAttrs: {
+ ...this.trackingAttrs('support'),
+ },
+ },
+ {
+ text: this.$options.i18n.docs,
+ href: 'https://docs.gitlab.com',
+ extraAttrs: {
+ ...this.trackingAttrs('gitlab_documentation'),
+ },
+ },
+ {
+ text: this.$options.i18n.plans,
+ href: `${PROMO_URL}/pricing`,
+ extraAttrs: {
+ ...this.trackingAttrs('compare_gitlab_plans'),
+ },
+ },
+ {
+ text: this.$options.i18n.forum,
+ href: 'https://forum.gitlab.com/',
+ extraAttrs: {
+ ...this.trackingAttrs('community_forum'),
+ },
+ },
{
text: this.$options.i18n.contribute,
href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
+ extraAttrs: {
+ ...this.trackingAttrs('contribute_to_gitlab'),
+ },
+ },
+ {
+ text: this.$options.i18n.feedback,
+ href: 'https://about.gitlab.com/submit-feedback',
+ extraAttrs: {
+ ...this.trackingAttrs('submit_feedback'),
+ },
},
- { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' },
],
},
helpActions: {
@@ -70,6 +118,9 @@ export default {
action: this.showKeyboardShortcuts,
extraAttrs: {
class: 'js-shortcuts-modal-trigger',
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'keyboard_shortcuts_help',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
},
shortcut: '?',
},
@@ -79,6 +130,11 @@ export default {
count:
this.showWhatsNewNotification &&
this.sidebarData.whats_new_most_recent_release_items_count,
+ extraAttrs: {
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'whats_new',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
+ },
},
].filter(Boolean),
},
@@ -118,12 +174,40 @@ export default {
this.toggleWhatsNewDrawer();
}
},
+
+ trackingAttrs(label) {
+ return {
+ ...HELP_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': label,
+ };
+ },
+
+ trackDropdownToggle(show) {
+ this.track('click_toggle', {
+ label: show ? 'show_help_dropdown' : 'hide_help_dropdown',
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
},
};
</script>
<template>
- <gl-disclosure-dropdown ref="dropdown">
+ <gl-disclosure-dropdown
+ ref="dropdown"
+ :popper-options="$options.popperOptions"
+ @shown="trackDropdownToggle(true)"
+ @hidden="trackDropdownToggle(false)"
+ >
<template #toggle>
<gl-button category="tertiary" icon="question-o" class="btn-with-notification">
<span v-if="showWhatsNewNotification" class="notification-dot-info"></span>
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index 0a72105fcc4..8ee7d57c47c 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -1,18 +1,29 @@
<script>
+import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import NavItem from './nav_item.vue';
export default {
components: {
+ GlIcon,
+ GlButton,
ProjectAvatar,
NavItem,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
items: {
type: Array,
required: false,
default: () => [],
},
+ editable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
};
</script>
@@ -34,6 +45,21 @@ export default {
aria-hidden="true"
/>
</template>
+ <template #actions>
+ <gl-button
+ v-if="editable"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :aria-label="__('Remove')"
+ :title="__('Remove')"
+ class="gl-align-self-center gl-p-1! gl-absolute gl-right-4"
+ data-testid="item-remove"
+ @click.stop.prevent="$emit('remove-item', item)"
+ >
+ <gl-icon name="close" />
+ </gl-button>
+ </template>
</nav-item>
<slot name="view-all-items"></slot>
</ul>
diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
index 94fc6aedcc0..d37e863bed9 100644
--- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -16,7 +16,12 @@ export default {
</script>
<template>
- <gl-disclosure-dropdown :items="items" placement="center">
+ <gl-disclosure-dropdown
+ :items="items"
+ placement="center"
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
+ >
<template #toggle>
<slot></slot>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index cd5363ad7a5..223fbe6d078 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -1,15 +1,41 @@
<script>
import { kebabCase } from 'lodash';
-import { GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
+import { GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ CLICK_MENU_ITEM_ACTION,
+ TRACKING_UNKNOWN_ID,
+ TRACKING_UNKNOWN_PANEL,
+} from '~/super_sidebar/constants';
export default {
+ i18n: {
+ pinItem: s__('Navigation|Pin item'),
+ unpinItem: s__('Navigation|Unpin item'),
+ },
name: 'NavItem',
components: {
+ GlButton,
GlCollapse,
GlIcon,
GlBadge,
},
+ inject: {
+ pinnedItemIds: { default: { ids: [] } },
+ panelSupportsPins: { default: false },
+ panelType: { default: '' },
+ },
props: {
+ draggable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isStatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
item: {
type: Object,
required: true,
@@ -53,25 +79,53 @@ export default {
}
return this.item.is_active;
},
+ isPinnable() {
+ return this.panelSupportsPins && !this.isSection && !this.isStatic;
+ },
+ isPinned() {
+ return this.pinnedItemIds.ids.includes(this.item.id);
+ },
+ trackingProps() {
+ // Set extra event data to debug missing IDs / Panel Types
+ const extraData =
+ !this.item.id || !this.panelType
+ ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) }
+ : {};
+
+ return {
+ 'data-track-action': CLICK_MENU_ITEM_ACTION,
+ 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID,
+ 'data-track-property': this.panelType
+ ? `nav_panel_${this.panelType}`
+ : TRACKING_UNKNOWN_PANEL,
+ ...extraData,
+ };
+ },
linkProps() {
if (this.isSection) {
return {
'aria-controls': this.itemId,
'aria-expanded': String(this.expanded),
+ 'data-qa-menu-item': this.item.title,
};
}
return {
...this.$attrs,
+ ...this.trackingProps,
href: this.item.link,
'aria-current': this.isActive ? 'page' : null,
+ 'data-qa-submenu-item': this.item.title,
};
},
computedLinkClasses() {
return {
// Reset user agent styles on <button>
'gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left': this.isSection,
- 'gl-w-full gl-focus': this.isSection,
+ 'gl-w-full gl-focus--focus': this.isSection,
+ 'nav-item-link': !this.isSection,
'gl-bg-t-gray-a-08': this.isActive,
+ 'gl-py-2': this.isPinnable,
+ 'gl-py-3': !this.isPinnable,
...this.linkClasses,
};
},
@@ -92,11 +146,10 @@ export default {
<component
:is="elem"
v-bind="linkProps"
- class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-text-decoration-none!"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!"
:class="computedLinkClasses"
- data-qa-selector="sidebar_menu_link"
+ data-qa-selector="nav_item_link"
data-testid="nav-item-link"
- :data-qa-menu-item="item.title"
@click="click"
>
<div
@@ -107,26 +160,50 @@ export default {
></div>
<div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
<slot name="icon">
- <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ <gl-icon
+ v-else-if="draggable"
+ name="grip"
+ class="gl-text-gray-400 gl-ml-2 draggable-icon"
+ />
</slot>
</div>
- <div class="gl-pr-3 gl-text-gray-900">
+ <div class="gl-pr-3 gl-text-gray-900 gl-truncate-end">
{{ item.title }}
- <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500">
+ <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end">
{{ item.subtitle }}
</div>
</div>
- <span v-if="isSection || hasPill" class="gl-flex-grow-1 gl-text-right gl-mr-3">
+ <slot name="actions"></slot>
+ <span v-if="isSection || hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3">
<gl-badge v-if="hasPill" size="sm" variant="info">
{{ pillData }}
</gl-badge>
<gl-icon v-else-if="isSection" :name="collapseIcon" />
+ <gl-button
+ v-else-if="isPinnable && !isPinned"
+ size="small"
+ category="tertiary"
+ icon="thumbtack"
+ :aria-label="$options.i18n.pinItem"
+ @click.prevent="$emit('pin-add', item.id)"
+ />
+ <gl-button
+ v-else-if="isPinnable && isPinned"
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.unpinItem"
+ icon="thumbtack-solid"
+ @click.prevent="$emit('pin-remove', item.id)"
+ />
</span>
</component>
<gl-collapse
v-if="isSection"
:id="itemId"
v-model="expanded"
+ data-qa-selector="menu_section"
+ :data-qa-section="item.title"
:aria-label="item.title"
class="gl-list-style-none gl-p-0"
tag="ul"
@@ -135,6 +212,8 @@ export default {
v-for="subItem of item.items"
:key="`${item.title}-${subItem.title}`"
:item="subItem"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
/>
</gl-collapse>
</li>
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
new file mode 100644
index 00000000000..9595bdfb632
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlCollapse, GlIcon } from '@gitlab/ui';
+import Draggable from 'vuedraggable';
+import { s__ } from '~/locale';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants';
+import NavItem from './nav_item.vue';
+
+export default {
+ i18n: {
+ pinned: s__('Navigation|Pinned'),
+ emptyHint: s__('Navigation|Your pinned items appear here.'),
+ },
+ name: 'PinnedSection',
+ components: {
+ Draggable,
+ GlCollapse,
+ GlIcon,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false',
+ draggableItems: this.items,
+ };
+ },
+ computed: {
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ itemIds() {
+ return this.draggableItems.map((item) => item.id);
+ },
+ },
+ watch: {
+ expanded(newExpanded) {
+ setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, {
+ expires: SIDEBAR_COOKIE_EXPIRATION,
+ });
+ },
+ items(newItems) {
+ this.draggableItems = newItems;
+ },
+ },
+ methods: {
+ handleDrag(event) {
+ if (event.oldIndex === event.newIndex) return;
+ this.$emit(
+ 'pin-reorder',
+ this.items[event.oldIndex].id,
+ this.items[event.newIndex].id,
+ event.oldIndex < event.newIndex,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="gl-mx-2">
+ <a
+ href="#"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none!"
+ @click.prevent="expanded = !expanded"
+ >
+ <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
+ <gl-icon name="thumbtack" class="gl-ml-2 item-icon" />
+ </div>
+
+ <span class="gl-font-weight-bold gl-font-sm gl-flex-grow-1">{{ $options.i18n.pinned }}</span>
+ <gl-icon :name="collapseIcon" class="gl-mr-3" />
+ </a>
+ <gl-collapse v-model="expanded">
+ <draggable
+ v-if="items.length > 0"
+ v-model="draggableItems"
+ class="gl-p-0 gl-m-0"
+ data-testid="pinned-nav-items"
+ handle=".draggable-icon"
+ tag="ul"
+ @end="handleDrag"
+ >
+ <nav-item
+ v-for="item of draggableItems"
+ :key="item.id"
+ draggable
+ :item="item"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </draggable>
+ <div v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem">
+ {{ $options.i18n.emptyHint }}
+ </div>
+ </gl-collapse>
+ <hr aria-hidden="true" class="gl-my-2 gl-mx-4" />
+ </section>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
index a545de06bd4..78860e35eb1 100644
--- a/app/assets/javascripts/super_sidebar/components/projects_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -36,16 +36,19 @@ export default {
storageKey() {
return `${this.username}/frequent-projects`;
},
- viewAllItem() {
+ viewAllProps() {
return {
- link: this.viewAllLink,
- title: s__('Navigation|View all projects'),
- icon: 'project',
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your projects'),
+ icon: 'project',
+ },
+ linkClasses: { 'dashboard-shortcuts-projects': true },
};
},
},
i18n: {
- title: s__('Navigation|Frequent projects'),
+ title: s__('Navigation|Frequently visited projects'),
searchTitle: s__('Navigation|Projects'),
pristineText: s__('Navigation|Projects you visit often will appear here.'),
noResultsText: s__('Navigation|No project matches found'),
@@ -62,7 +65,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</search-results>
<frequent-items-list
@@ -73,7 +76,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</frequent-items-list>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue
index 7c172110bad..cfd6184bc47 100644
--- a/app/assets/javascripts/super_sidebar/components/search_results.vue
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -1,10 +1,17 @@
<script>
+import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
import ItemsList from './items_list.vue';
export default {
components: {
+ GlCollapse,
+ GlIcon,
ItemsList,
},
+ directives: {
+ CollapseToggle: GlCollapseToggleDirective,
+ },
props: {
title: {
type: String,
@@ -20,30 +27,69 @@ export default {
default: () => [],
},
},
+ data() {
+ return {
+ expanded: true,
+ };
+ },
computed: {
isEmpty() {
return !this.searchResults.length;
},
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ },
+ created() {
+ this.collapseId = uniqueId('expandable-section-');
},
+ buttonClasses: [
+ // Reset user agent styles
+ 'gl-appearance-none',
+ 'gl-border-0',
+ 'gl-bg-transparent',
+ // Text styles
+ 'gl-text-left',
+ 'gl-text-transform-uppercase',
+ 'gl-text-secondary',
+ 'gl-font-weight-bold',
+ 'gl-font-xs',
+ 'gl-line-height-12',
+ 'gl-letter-spacing-06em',
+ // Border
+ 'gl-border-t',
+ 'gl-border-gray-50',
+ // Spacing
+ 'gl-my-3',
+ 'gl-pt-2',
+ 'gl-w-full',
+ // Layout
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ],
};
</script>
<template>
- <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3">
- <div
- data-testid="list-title"
- aria-hidden="true"
- class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ <li class="gl-border-t gl-border-gray-50 gl-mx-3">
+ <button
+ v-collapse-toggle="collapseId"
+ :class="$options.buttonClasses"
+ data-testid="search-results-toggle"
>
{{ title }}
- </div>
- <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
- {{ noResultsText }}
- </div>
- <items-list :aria-label="title" :items="searchResults">
- <template #view-all-items>
- <slot name="view-all-items"></slot>
- </template>
- </items-list>
+ <gl-icon :name="collapseIcon" :size="16" />
+ </button>
+ <gl-collapse :id="collapseId" v-model="expanded">
+ <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
+ {{ noResultsText }}
+ </div>
+ <items-list :aria-label="title" :items="searchResults">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </gl-collapse>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index fc8968c50ea..ca165dd8602 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,24 +1,157 @@
<script>
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { PANELS_WITH_PINS } from '../constants';
import NavItem from './nav_item.vue';
+import PinnedSection from './pinned_section.vue';
export default {
name: 'SidebarMenu',
components: {
NavItem,
+ PinnedSection,
+ },
+
+ provide() {
+ return {
+ pinnedItemIds: this.changedPinnedItemIds,
+ panelSupportsPins: this.supportsPins,
+ panelType: this.panelType,
+ };
},
props: {
items: {
type: Array,
required: true,
},
+ pinnedItemIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ panelType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatePinsUrl: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ // This is used as a provide and injected into the nav items.
+ // Note: It has to be an object to be reactive.
+ changedPinnedItemIds: { ids: this.pinnedItemIds },
+ };
+ },
+
+ computed: {
+ // Returns the list of items that we want to have static at the top.
+ // Only sidebars that support pins also support a static section.
+ staticItems() {
+ if (!this.supportsPins) return [];
+ return this.items.filter((item) => !item.items || item.items.length === 0);
+ },
+
+ // Returns only the items that aren't static at the top and makes sure no
+ // section shows as active (and expanded) when one of its items is pinned.
+ nonStaticItems() {
+ if (!this.supportsPins) return this.items;
+
+ return this.items
+ .filter((item) => item.items && item.items.length > 0)
+ .map((item) => {
+ const hasActivePinnedChild = item.items.some((childItem) => {
+ return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id);
+ });
+ const showAsActive = item.is_active && !hasActivePinnedChild;
+
+ return { ...item, is_active: showAsActive };
+ });
+ },
+
+ // Returns a flat list of all items that are in sections, but not the sections.
+ // Only items from sections (item.items) can be pinned.
+ flatPinnableItems() {
+ return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean);
+ },
+
+ pinnedItems() {
+ return this.changedPinnedItemIds.ids
+ .map((id) => this.flatPinnableItems.find((item) => item.id === id))
+ .filter(Boolean);
+ },
+ supportsPins() {
+ return PANELS_WITH_PINS.includes(this.panelType);
+ },
+ },
+ methods: {
+ createPin(itemId) {
+ this.changedPinnedItemIds.ids.push(itemId);
+ this.updatePins();
+ },
+ destroyPin(itemId) {
+ this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId);
+ this.updatePins();
+ },
+ movePin(fromId, toId, isDownwards) {
+ const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId);
+ this.changedPinnedItemIds.ids.splice(fromIndex, 1);
+
+ let toIndex = this.changedPinnedItemIds.ids.indexOf(toId);
+
+ // If the item was moved downwards, we insert it *after* the item it was dragged on to.
+ // This matches how vuedraggable previews the change while still dragging.
+ if (isDownwards) toIndex += 1;
+
+ this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId);
+
+ this.updatePins();
+ },
+ updatePins() {
+ axios
+ .put(this.updatePinsUrl, {
+ panel: this.panelType,
+ menu_item_ids: this.changedPinnedItemIds.ids,
+ })
+ .then((response) => {
+ this.changedPinnedItemIds.ids = response.data;
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ });
+ },
},
};
</script>
<template>
<nav class="gl-py-2 gl-relative">
+ <section v-if="staticItems.length > 0" class="gl-mx-2">
+ <ul class="gl-p-0 gl-m-0">
+ <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static />
+ </ul>
+ <hr aria-hidden="true" class="gl-my-2 gl-mx-4" />
+ </section>
+
+ <pinned-section
+ v-if="supportsPins"
+ :items="pinnedItems"
+ @pin-remove="destroyPin"
+ @pin-reorder="movePin"
+ />
+
<ul class="gl-px-2 gl-list-style-none">
- <nav-item v-for="item in items" :key="`menu-${item.title}`" :item="item" />
+ <nav-item
+ v-for="item in nonStaticItems"
+ :key="item.id"
+ :item="item"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
</ul>
</nav>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index e8df534346b..4b54e317639 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,7 +1,13 @@
<script>
import { GlButton, GlCollapse } from '@gitlab/ui';
import { __ } from '~/locale';
-import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ sidebarState,
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+} from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
@@ -20,6 +26,7 @@ export default {
SidebarMenu,
SidebarPortalTarget,
},
+ mixins: [glFeatureFlagsMixin()],
i18n: {
skipToMainContent: __('Skip to main content'),
},
@@ -30,10 +37,7 @@ export default {
},
},
data() {
- return {
- contextSwitcherOpened: false,
- isCollapased: isCollapsed(),
- };
+ return sidebarState;
},
computed: {
menuItems() {
@@ -44,6 +48,37 @@ export default {
collapseSidebar() {
toggleSuperSidebarCollapsed(true, false);
},
+ onContextSwitcherShown() {
+ this.$refs['context-switcher'].focusInput();
+ },
+ onHoverAreaMouseEnter() {
+ this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ },
+ onHoverAreaMouseLeave() {
+ clearTimeout(this.openPeekTimer);
+ },
+ onSidebarMouseEnter() {
+ clearTimeout(this.closePeekTimer);
+ },
+ onSidebarMouseLeave() {
+ this.closePeekTimer = setTimeout(this.closePeek, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ },
+ closePeek() {
+ if (this.isPeek) {
+ this.isPeek = false;
+ this.isCollapsed = true;
+ }
+ },
+ openPeek() {
+ this.isPeek = true;
+ this.isCollapsed = false;
+
+ // Cancel and start the timer to close sidebar, in case the user moves
+ // the cursor fast enough away to not trigger a mouseenter event.
+ // This is cancelled if the user moves the cursor into the sidebar.
+ this.onSidebarMouseEnter();
+ this.onSidebarMouseLeave();
+ },
},
};
</script>
@@ -51,14 +86,22 @@ export default {
<template>
<div>
<div class="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <div
+ v-if="!isPeek && glFeatures.superSidebarPeek"
+ class="super-sidebar-hover-area gl-fixed gl-left-0 gl-top-0 gl-bottom-0 gl-w-3"
+ data-testid="super-sidebar-hover-area"
+ @mouseenter="onHoverAreaMouseEnter"
+ @mouseleave="onHoverAreaMouseLeave"
+ ></div>
<aside
id="super-sidebar"
- :aria-hidden="String(isCollapased)"
class="super-sidebar"
+ :class="{ 'super-sidebar-peek': isPeek }"
data-testid="super-sidebar"
data-qa-selector="navbar"
- :inert="isCollapased"
- tabindex="-1"
+ :inert="isCollapsed"
+ @mouseenter="onSidebarMouseEnter"
+ @mouseleave="onSidebarMouseLeave"
>
<gl-button
class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3"
@@ -67,23 +110,36 @@ export default {
>
{{ $options.i18n.skipToMainContent }}
</gl-button>
- <user-bar :sidebar-data="sidebarData" />
+ <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" />
<div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
<div class="gl-flex-grow-1 gl-overflow-auto">
<context-switcher-toggle
:context="sidebarData.current_context_header"
- :expanded="contextSwitcherOpened"
+ :expanded="contextSwitcherOpen"
+ data-qa-selector="context_switcher"
/>
- <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
+ <gl-collapse
+ id="context-switcher"
+ v-model="contextSwitcherOpen"
+ data-qa-selector="context_section"
+ @shown="onContextSwitcherShown"
+ >
<context-switcher
+ ref="context-switcher"
+ :persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
:current-context="sidebarData.current_context"
/>
</gl-collapse>
- <gl-collapse :visible="!contextSwitcherOpened">
- <sidebar-menu :items="menuItems" />
+ <gl-collapse :visible="!contextSwitcherOpen">
+ <sidebar-menu
+ :items="menuItems"
+ :panel-type="sidebarData.panel_type"
+ :pinned-item-ids="sidebarData.pinned_items"
+ :update-pins-url="sidebarData.update_pins_url"
+ />
<sidebar-portal-target />
</gl-collapse>
</div>
@@ -92,5 +148,14 @@ export default {
</div>
</div>
</aside>
+ <a
+ v-for="shortcutLink in sidebarData.shortcut_links"
+ :key="shortcutLink.href"
+ :href="shortcutLink.href"
+ :class="shortcutLink.css_class"
+ class="gl-display-none"
+ >
+ {{ shortcutLink.title }}
+ </a>
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
new file mode 100644
index 00000000000..3064b91ca7d
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tooltipContainer: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'right',
+ },
+ },
+ i18n: {
+ collapseSidebar: __('Collapse sidebar'),
+ expandSidebar: __('Expand sidebar'),
+ navigationSidebar: __('Navigation sidebar'),
+ },
+ data() {
+ return sidebarState;
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.isPeek) return '';
+
+ return this.isCollapsed
+ ? this.$options.i18n.expandSidebar
+ : this.$options.i18n.collapseSidebar;
+ },
+ tooltip() {
+ return {
+ placement: this.tooltipPlacement,
+ container: this.tooltipContainer,
+ title: this.tooltipTitle,
+ };
+ },
+ ariaExpanded() {
+ return String(!this.isCollapsed);
+ },
+ },
+ methods: {
+ toggle() {
+ toggleSuperSidebarCollapsed(!this.isCollapsed, true);
+ this.focusOtherToggle();
+ },
+ focusOtherToggle() {
+ this.$nextTick(() => {
+ const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
+ const otherToggle = document.querySelector(`.${classSelector}`);
+ otherToggle?.focus();
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover="tooltip"
+ aria-controls="super-sidebar"
+ :aria-expanded="ariaExpanded"
+ :aria-label="$options.i18n.navigationSidebar"
+ icon="sidebar"
+ category="tertiary"
+ :disabled="isPeek"
+ @click="toggle"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index e27acb60372..f311c5242f5 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,19 +1,24 @@
<script>
-import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { highCountTrim } from '~/lib/utils/text_utility';
import logo from '../../../../views/shared/_logo.svg';
-import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
import MergeRequestMenu from './merge_request_menu.vue';
import UserMenu from './user_menu.vue';
+import SuperSidebarToggle from './super_sidebar_toggle.vue';
+import { SEARCH_MODAL_ID } from './global_search/constants';
export default {
// "GitLab Next" is a proper noun, so don't translate "Next"
/* eslint-disable-next-line @gitlab/require-i18n-strings */
NEXT_LABEL: 'Next',
logo,
+ JS_TOGGLE_COLLAPSE_CLASS,
+ SEARCH_MODAL_ID,
components: {
Counter,
CreateMenu,
@@ -21,29 +26,63 @@ export default {
GlButton,
MergeRequestMenu,
UserMenu,
+ SearchModal: () =>
+ import(
+ /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
+ ),
+ SuperSidebarToggle,
},
i18n: {
- collapseSidebar: __('Collapse sidebar'),
createNew: __('Create new...'),
+ homepage: __('Homepage'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
search: __('Search'),
+ searchKbdHelp: sprintf(
+ s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+ ),
todoList: __('To-Do list'),
+ stopImpersonating: __('Stop impersonating'),
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
SafeHtml,
},
- inject: ['rootPath'],
+ inject: ['rootPath', 'isImpersonating'],
props: {
+ hasCollapseButton: {
+ default: true,
+ type: Boolean,
+ required: false,
+ },
sidebarData: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ mrMenuShown: false,
+ todoCount: this.sidebarData.todos_pending_count,
+ };
+ },
+ computed: {
+ formattedTodoCount() {
+ return highCountTrim(this.todoCount);
+ },
+ },
+ mounted() {
+ document.addEventListener('todo:toggle', this.updateTodos);
+ },
+ beforeDestroy() {
+ document.removeEventListener('todo:toggle', this.updateTodos);
+ },
methods: {
- collapseSidebar() {
- toggleSuperSidebarCollapsed(true, true, true);
+ updateTodos(e) {
+ this.todoCount = e.detail.count || 0;
},
},
};
@@ -51,8 +90,15 @@ export default {
<template>
<div class="user-bar">
- <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-2">
- <a :href="rootPath">
+ <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2">
+ <a
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
+ :href="rootPath"
+ :title="$options.i18n.homepage"
+ data-track-action="click_link"
+ data-track-label="gitlab_logo_link"
+ data-track-property="nav_core_menu"
+ >
<img
v-if="sidebarData.logo_url"
data-testid="brand-header-custom-logo"
@@ -66,54 +112,86 @@ export default {
variant="success"
:href="sidebarData.canary_toggle_com_url"
size="sm"
- >{{ $options.NEXT_LABEL }}</gl-badge
+ class="gl-ml-2"
>
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
<div class="gl-flex-grow-1"></div>
- <gl-button
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar"
- :aria-label="$options.i18n.collapseSidebar"
- icon="sidebar"
- category="tertiary"
- @click="collapseSidebar"
+ <super-sidebar-toggle
+ v-if="hasCollapseButton"
+ :class="$options.JS_TOGGLE_COLLAPSE_CLASS"
+ tooltip-placement="bottom"
+ tooltip-container="super-sidebar"
+ data-testid="super-sidebar-collapse-button"
/>
<create-menu :groups="sidebarData.create_new_menu_groups" />
+
<gl-button
+ id="super-sidebar-search"
+ v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp"
+ v-gl-modal="$options.SEARCH_MODAL_ID"
+ data-testid="super-sidebar-search-button"
icon="search"
:aria-label="$options.i18n.search"
category="tertiary"
- href="/search"
/>
+ <search-modal />
+
<user-menu :data="sidebarData" />
+
+ <gl-button
+ v-if="isImpersonating"
+ v-gl-tooltip
+ :href="sidebarData.stop_impersonation_path"
+ :title="$options.i18n.stopImpersonating"
+ :aria-label="$options.i18n.stopImpersonating"
+ icon="incognito"
+ variant="confirm"
+ category="tertiary"
+ data-method="delete"
+ data-testid="stop-impersonation-btn"
+ />
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
:count="sidebarData.assigned_open_issues_count"
:href="sidebarData.issues_dashboard_path"
:label="$options.i18n.issues"
+ data-track-action="click_link"
+ data-track-label="issues_link"
+ data-track-property="nav_core_menu"
/>
<merge-request-menu
class="gl-flex-basis-third gl-display-block!"
:items="sidebarData.merge_request_menu"
+ @shown="mrMenuShown = true"
+ @hidden="mrMenuShown = false"
>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests"
+ v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
class="gl-w-full"
icon="merge-request-open"
:count="sidebarData.total_merge_requests_count"
:label="$options.i18n.mergeRequests"
+ data-track-action="click_dropdown"
+ data-track-label="merge_requests_menu"
+ data-track-property="nav_core_menu"
/>
</merge-request-menu>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
- :count="sidebarData.todos_pending_count"
+ :count="formattedTodoCount"
href="/dashboard/todos"
:label="$options.i18n.todoList"
data-qa-selector="todos_shortcut_button"
+ data-track-action="click_link"
+ data-track-label="todos_link"
+ data-track-property="nav_core_menu"
/>
</div>
</div>
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index 34bbb3ce177..c90d1ad9c3e 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -11,13 +11,17 @@ import { s__, __, sprintf } from '~/locale';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import Tracking from '~/tracking';
import PersistentUserCallout from '~/persistent_user_callout';
+import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants';
import UserNameGroup from './user_name_group.vue';
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -211;
+
export default {
- feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533',
+ feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403059',
i18n: {
newNavigation: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
+ badgeLabel: s__('NorthstarNavigation|Beta'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
},
setStatus: s__('SetStatusModal|Set status'),
@@ -72,6 +76,10 @@ export default {
return {
text: this.$options.i18n.startTrial,
href: this.data.trial.url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'start_trial',
+ },
};
},
editProfileItem() {
@@ -80,6 +88,8 @@ export default {
href: this.data.settings.profile_path,
extraAttrs: {
'data-qa-selector': 'edit_profile_link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_edit_profile',
},
};
},
@@ -87,6 +97,10 @@ export default {
return {
text: this.$options.i18n.preferences,
href: this.data.settings.profile_preferences_path,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_preferences',
+ },
};
},
addBuyPipelineMinutesMenuItem() {
@@ -99,6 +113,8 @@ export default {
href: this.data.pipeline_minutes?.buy_pipeline_minutes_path,
extraAttrs: {
class: 'js-follow-link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'buy_pipeline_minutes',
},
};
},
@@ -106,6 +122,10 @@ export default {
return {
text: this.$options.i18n.gitlabNext,
href: this.data.canary_toggle_com_url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'switch_to_canary',
+ },
};
},
feedbackItem() {
@@ -114,6 +134,8 @@ export default {
href: this.$options.feedbackUrl,
extraAttrs: {
target: '_blank',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'provide_nav_beta_feedback',
},
};
},
@@ -139,9 +161,12 @@ export default {
'data-default-emoji': 'speech_balloon',
};
- if (!this.data.status.customized) {
+ const { busy, customized } = this.data.status;
+
+ if (!busy && !customized) {
return defaultData;
}
+
return {
...defaultData,
'data-current-emoji': this.data.status.emoji,
@@ -164,15 +189,20 @@ export default {
},
methods: {
onShow() {
- this.trackEvents();
- this.initCallout();
+ this.initBuyCIMinsCallout();
+ },
+ closeDropdown() {
+ this.$refs.userDropdown.close();
},
- initCallout() {
+ initBuyCIMinsCallout() {
if (this.showNotificationDot) {
PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el);
}
},
- trackEvents() {
+ /* We're not sure this event is tracked by anyone
+ whether it stays will depend on the outcome of this discussion:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */
+ trackBuyCIMins() {
if (this.addBuyPipelineMinutesMenuItem) {
const {
'track-action': trackAction,
@@ -182,6 +212,22 @@ export default {
this.track(trackAction, { label, property });
}
},
+ trackSignOut() {
+ this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], {
+ label: 'user_sign_out',
+ property: USER_MENU_TRACKING_DEFAULTS['data-track-property'],
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
},
};
</script>
@@ -189,7 +235,8 @@ export default {
<template>
<div>
<gl-disclosure-dropdown
- placement="right"
+ ref="userDropdown"
+ :popper-options="$options.popperOptions"
data-testid="user-dropdown"
data-qa-selector="user_menu"
@shown="onShow"
@@ -220,6 +267,7 @@ export default {
v-if="data.status.can_update"
:item="statusItem"
data-testid="status-item"
+ @action="closeDropdown"
/>
<gl-disclosure-dropdown-item
@@ -243,6 +291,7 @@ export default {
:item="buyPipelineMinutesItem"
v-bind="buyPipelineMinutesCalloutData"
data-testid="buy-pipeline-minutes-item"
+ @action="trackBuyCIMins"
>
<template #list-item>
<span class="gl-display-flex gl-flex-direction-column">
@@ -279,6 +328,7 @@ export default {
bordered
:group="signOutGroup"
data-testid="sign-out-group"
+ @action="trackSignOut"
/>
</gl-disclosure-dropdown>
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
index 2489f462122..57958a03edd 100644
--- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -1,16 +1,22 @@
<script>
-import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui';
+import {
+ GlBadge,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlTooltip,
+} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-
import { s__ } from '~/locale';
+import { USER_MENU_TRACKING_DEFAULTS } from '../constants';
export default {
i18n: {
user: {
- busy: s__('UserProfile|(Busy)'),
+ busy: s__('UserProfile|Busy'),
},
},
components: {
+ GlBadge,
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
GlTooltip,
@@ -31,7 +37,13 @@ export default {
};
if (this.user.has_link_to_profile) {
item.href = this.user.link_to_profile;
+
+ item.extraAttrs = {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_profile',
+ };
}
+
return item;
},
},
@@ -47,9 +59,9 @@ export default {
<span class="gl-font-weight-bold">
{{ user.name }}
</span>
- <span v-if="user.status.busy" class="gl-text-gray-500">{{
- $options.i18n.user.busy
- }}</span>
+ <gl-badge v-if="user.status.busy" size="sm" variant="warning">
+ {{ $options.i18n.user.busy }}
+ </gl-badge>
</span>
<span class="gl-text-gray-400">@{{ user.username }}</span>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
index acc03bc48c7..4f5b027c138 100644
--- a/app/assets/javascripts/super_sidebar/constants.js
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -5,10 +5,44 @@
import Vue from 'vue';
export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount';
+export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse';
+export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand';
export const portalState = Vue.observable({
ready: false,
});
+export const sidebarState = Vue.observable({
+ contextSwitcherOpen: false,
+ isCollapsed: false,
+ isPeek: false,
+ openPeekTimer: null,
+ closePeekTimer: null,
+});
+
export const MAX_FREQUENT_PROJECTS_COUNT = 5;
export const MAX_FREQUENT_GROUPS_COUNT = 3;
+
+export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200;
+export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500;
+
+export const TRACKING_UNKNOWN_ID = 'item_without_id';
+export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown';
+export const CLICK_MENU_ITEM_ACTION = 'click_menu_item';
+
+export const PANELS_WITH_PINS = ['group', 'project'];
+
+export const USER_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const HELP_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_help_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded';
+export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10;
+
+export const DROPDOWN_Y_OFFSET = 4;
diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js
deleted file mode 100644
index 5e5ad97eb68..00000000000
--- a/app/assets/javascripts/super_sidebar/mock_data.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { s__ } from '~/locale';
-
-export const contextSwitcherItems = {
- yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
- explore: { title: s__('Navigation|Explore'), link: '/explore', icon: 'compass' },
- recentProjects: [
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Orange',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Lemon',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Coconut',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64',
- },
- ],
- recentGroups: [
- {
- title: 'Developer Evangelism at GitLab',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64',
- },
- {
- title: 'security-products',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64',
- },
- {
- title: 'Tanuki-Workshops',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64',
- },
- ],
-};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 4395cc2f5f0..fdd29a1719c 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,12 +1,16 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import { initStatusTriggers } from '../header';
+import { JS_TOGGLE_EXPAND_CLASS } from './constants';
+import createStore from './components/global_search/store';
import {
bindSuperSidebarCollapsedEvents,
initSuperSidebarCollapsedState,
} from './super_sidebar_collapsed_state_manager';
import SuperSidebar from './components/super_sidebar.vue';
+import SuperSidebarToggle from './components/super_sidebar_toggle.vue';
Vue.use(VueApollo);
@@ -23,6 +27,11 @@ export const initSuperSidebar = () => {
initSuperSidebarCollapsedState();
const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
+ const sidebarData = JSON.parse(sidebar);
+ const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+
+ const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
+ const isImpersonating = parseBoolean(sidebarData.is_impersonating);
return new Vue({
el,
@@ -31,15 +40,48 @@ export const initSuperSidebar = () => {
provide: {
rootPath,
toggleNewNavEndpoint,
+ isImpersonating,
},
+ store: createStore({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search: '',
+ }),
render(h) {
return h(SuperSidebar, {
props: {
- sidebarData: JSON.parse(sidebar),
+ sidebarData,
},
});
},
});
};
+/**
+ * Guard against multiple instantiations, since the js-* class is persisted
+ * in the Vue component.
+ */
+let toggleInstantiated = false;
+
+export const initSuperSidebarToggle = () => {
+ const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`);
+
+ if (!el || toggleInstantiated) return false;
+
+ toggleInstantiated = true;
+
+ return new Vue({
+ el,
+ name: 'SuperSidebarToggleRoot',
+ render(h) {
+ // Copy classes from HAML-defined button to ensure same positioning,
+ // including JS_TOGGLE_EXPAND_CLASS.
+ return h(SuperSidebarToggle, { class: el.className });
+ },
+ });
+};
+
requestIdleCallback(initStatusTriggers);
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
index 549c6c17e44..17e07146678 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -1,14 +1,15 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { sidebarState } from './constants';
export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed';
export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed';
export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10;
+export const SIDEBAR_TRANSITION_DURATION = 200;
export const findPage = () => document.querySelector('.page-with-super-sidebar');
export const findSidebar = () => document.querySelector('.super-sidebar');
-export const findToggles = () => document.querySelectorAll('.js-super-sidebar-toggle');
export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS);
@@ -19,15 +20,15 @@ export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true';
-export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => {
- const sidebar = findSidebar();
- sidebar.ariaHidden = collapsed;
- sidebar.inert = collapsed;
-
- if (!collapsed && isUserAction) sidebar.focus();
+export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
+ clearTimeout(sidebarState.openPeekTimer);
+ clearTimeout(sidebarState.closePeekTimer);
findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
+ sidebarState.isPeek = false;
+ sidebarState.isCollapsed = collapsed;
+
if (saveCookie && isDesktopBreakpoint()) {
setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
@@ -41,11 +42,5 @@ export const initSuperSidebarCollapsedState = () => {
};
export const bindSuperSidebarCollapsedEvents = () => {
- findToggles().forEach((elem) => {
- elem.addEventListener('click', () => {
- toggleSuperSidebarCollapsed(!isCollapsed(), true, true);
- });
- });
-
window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100));
};
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 065e1080897..d79252f6bb7 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
@@ -14,6 +12,7 @@ export default function syntaxHighlight($els = null) {
if (!$els || $els.length === 0) return;
const els = $els.get ? $els.get() : $els;
+ // eslint-disable-next-line consistent-return
const handler = (el) => {
if (el.classList === undefined) {
return el;
diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
new file mode 100644
index 00000000000..3ba0ab29530
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
@@ -0,0 +1,69 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query timeTrackingReport(
+ $startDate: Time
+ $endDate: Time
+ $projectId: ProjectID
+ $groupId: GroupID
+ $username: String
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ timelogs(
+ startDate: $startDate
+ endDate: $endDate
+ projectId: $projectId
+ groupId: $groupId
+ username: $username
+ first: $first
+ last: $last
+ after: $after
+ before: $before
+ sort: SPENT_AT_DESC
+ ) {
+ count
+ totalSpentTime
+ nodes {
+ id
+ project {
+ id
+ webUrl
+ fullPath
+ nameWithNamespace
+ }
+ timeSpent
+ user {
+ id
+ name
+ username
+ avatarUrl
+ webPath
+ }
+ spentAt
+ note {
+ id
+ body
+ }
+ summary
+ issue {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ mergeRequest {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
new file mode 100644
index 00000000000..33b0ac4b58e
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { IssuableStatusText } from '~/issues/constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ timelog: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ subject() {
+ const { issue, mergeRequest } = this.timelog;
+ return issue || mergeRequest;
+ },
+ issuableStatus() {
+ return IssuableStatusText[this.subject.state];
+ },
+ issuableFullReference() {
+ return this.timelog.project.fullPath + this.subject.reference;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!">
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold"
+ data-testid="title-container"
+ >
+ {{ subject.title }}
+ </gl-link>
+ <span>
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900"
+ data-testid="reference-container"
+ >
+ {{ issuableFullReference }}
+ </gl-link>
+ • <span data-testid="state-container">{{ issuableStatus }}</span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
new file mode 100644
index 00000000000..2069e4a6722
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
@@ -0,0 +1,229 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+} from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { formatTimeSpent } from '~/lib/utils/datetime_utility';
+import { s__ } from '~/locale';
+import getTimelogsQuery from './queries/get_timelogs.query.graphql';
+import TimelogsTable from './timelogs_table.vue';
+
+const ENTRIES_PER_PAGE = 20;
+
+// Define initial dates to current date and time
+const INITIAL_TO_DATE = new Date();
+const INITIAL_FROM_DATE = new Date();
+
+// Set the initial 'from' date to 30 days before the current date
+INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30);
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+ TimelogsTable,
+ },
+ props: {
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectId: null,
+ groupId: null,
+ username: null,
+ timeSpentFrom: INITIAL_FROM_DATE,
+ timeSpentTo: INITIAL_TO_DATE,
+ cursor: {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ },
+ queryVariables: {
+ startDate: INITIAL_FROM_DATE,
+ endDate: INITIAL_TO_DATE,
+ projectId: null,
+ groupId: null,
+ username: null,
+ },
+ pageInfo: {},
+ report: [],
+ totalSpentTime: 0,
+ };
+ },
+ apollo: {
+ report: {
+ query: getTimelogsQuery,
+ variables() {
+ return {
+ ...this.queryVariables,
+ ...this.cursor,
+ };
+ },
+ update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) {
+ this.pageInfo = pageInfo;
+ this.totalSpentTime = totalSpentTime;
+ return nodes;
+ },
+ error(error) {
+ createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') });
+ Sentry.captureException(error);
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.report.loading;
+ },
+ showPagination() {
+ return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage;
+ },
+ formattedTotalSpentTime() {
+ return formatTimeSpent(this.totalSpentTime, this.limitToHours);
+ },
+ },
+ methods: {
+ nullIfBlank(value) {
+ return value === '' ? null : value;
+ },
+ runReport() {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ };
+
+ this.queryVariables = {
+ startDate: this.nullIfBlank(this.timeSpentFrom),
+ endDate: this.nullIfBlank(this.timeSpentTo),
+ projectId: this.nullIfBlank(this.projectId),
+ groupId: this.nullIfBlank(this.groupId),
+ username: this.nullIfBlank(this.username),
+ };
+ },
+ nextPage(item) {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: item,
+ last: null,
+ before: null,
+ };
+ },
+ prevPage(item) {
+ this.cursor = {
+ first: null,
+ after: null,
+ last: ENTRIES_PER_PAGE,
+ before: item,
+ };
+ },
+ clearTimeSpentFromDate() {
+ this.timeSpentFrom = null;
+ },
+ clearTimeSpentToDate() {
+ this.timeSpentTo = null;
+ },
+ },
+ i18n: {
+ username: s__('TimeTrackingReport|Username'),
+ from: s__('TimeTrackingReport|From'),
+ to: s__('TimeTrackingReport|To'),
+ runReport: s__('TimeTrackingReport|Run report'),
+ totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5">
+ <form
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
+ @submit.prevent="runReport"
+ >
+ <gl-form-group
+ :label="$options.i18n.username"
+ label-for="timelog-form-username"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-form-input
+ id="timelog-form-username"
+ v-model="username"
+ data-testid="form-username"
+ class="gl-w-full"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-from"
+ :label="$options.i18n.from"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentFrom"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-from-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentFromDate"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-to"
+ :label="$options.i18n.to"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentTo"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-to-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentToDate"
+ />
+ </gl-form-group>
+ <gl-button
+ class="gl-align-self-end gl-w-full gl-md-w-auto"
+ variant="confirm"
+ @click="runReport"
+ >{{ $options.i18n.runReport }}</gl-button
+ >
+ </form>
+ <div
+ v-if="!isLoading"
+ data-testid="table-container"
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span>
+ <span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span>
+ </div>
+
+ <timelogs-table :limit-to-hours="limitToHours" :entries="report" />
+
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ class="gl-mt-3 gl-align-self-center"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
+ <gl-loading-icon v-else size="lg" class="gl-mt-5" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
new file mode 100644
index 00000000000..b2efb44f56f
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { s__ } from '~/locale';
+import TimelogSourceCell from './timelog_source_cell.vue';
+
+const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
+
+export default {
+ components: {
+ GlTable,
+ UserAvatarLink,
+ TimelogSourceCell,
+ },
+ props: {
+ entries: {
+ type: Array,
+ required: true,
+ },
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ fields: [
+ {
+ key: 'spentAt',
+ label: s__('TimeTrackingReport|Spent at'),
+ tdClass: 'gl-md-w-30',
+ },
+ {
+ key: 'source',
+ label: s__('TimeTrackingReport|Source'),
+ },
+ {
+ key: 'user',
+ label: s__('TimeTrackingReport|User'),
+ tdClass: 'gl-md-w-20',
+ },
+ {
+ key: 'timeSpent',
+ label: s__('TimeTrackingReport|Time spent'),
+ tdClass: 'gl-md-w-15',
+ },
+ {
+ key: 'summary',
+ label: s__('TimeTrackingReport|Summary'),
+ },
+ ],
+ };
+ },
+ methods: {
+ formatDate(date) {
+ return formatDate(date, TIME_DATE_FORMAT);
+ },
+ formatTimeSpent(seconds) {
+ return formatTimeSpent(seconds, this.limitToHours);
+ },
+ extractTimelogSummary(timelog) {
+ const { note, summary } = timelog;
+ return note?.body || summary;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="entries" :fields="fields" stacked="md" show-empty>
+ <template #cell(spentAt)="{ item: { spentAt } }">
+ <div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div>
+ </template>
+
+ <template #cell(source)="{ item }">
+ <timelog-source-cell :timelog="item" />
+ </template>
+
+ <template #cell(user)="{ item: { user } }">
+ <user-avatar-link
+ class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900"
+ :link-href="user.webPath"
+ :img-src="user.avatarUrl"
+ :img-size="16"
+ :img-alt="user.name"
+ :tooltip-text="user.name"
+ :username="user.name"
+ />
+ </template>
+
+ <template #cell(timeSpent)="{ item: { timeSpent } }">
+ <div data-testid="time-spent-container" class="gl-text-left!">
+ {{ formatTimeSpent(timeSpent) }}
+ </div>
+ </template>
+
+ <template #cell(summary)="{ item }">
+ <div data-testid="summary-container" class="gl-text-left!">
+ {{ extractTimelogSummary(item) }}
+ </div>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js
new file mode 100644
index 00000000000..9cff01799d9
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import TimelogsApp from './components/timelogs_app.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-timelogs-app');
+ if (!el) {
+ return false;
+ }
+
+ const { limitToHours } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(TimelogsApp, {
+ props: {
+ limitToHours: parseBoolean(limitToHours),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 2593fbe6ed1..7f4c7a91b20 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -5,14 +5,11 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
hostname: window.location.hostname,
cookieDomain: window.location.hostname,
appId: '',
- userFingerprint: false,
respectDoNotTrack: true,
- forceSecureTracker: true,
eventMethod: 'post',
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
- pageUnloadTimer: 10,
formTrackingConfig: {
forms: { allow: [] },
fields: { allow: [] },
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
index 5daeaf1d85b..89d90cf89be 100644
--- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -26,7 +26,14 @@ export function dispatchSnowplowEvent(
}
try {
- window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+ window.snowplow('trackStructEvent', {
+ category,
+ action,
+ label,
+ property,
+ value,
+ context: contexts,
+ });
return true;
} catch (error) {
Sentry.captureException(error);
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index d60eb37a9a2..472ce3c5bbf 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -7,7 +7,7 @@ export { Tracking as default };
/**
* Tracker initialization as defined in:
- * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/.
+ * https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/initialization-options/.
* It also dispatches any event emitted before its execution.
*
* @returns {undefined}
@@ -42,13 +42,19 @@ export function initDefaultTrackers() {
// must be before initializing the trackers
Tracking.setAnonymousUrls();
- window.snowplow('enableActivityTracking', 30, 30);
+ window.snowplow('enableActivityTracking', {
+ minimumVisitLength: 30,
+ heartbeatDelay: 30,
+ });
// must be after enableActivityTracking
const standardContext = getStandardContext();
const experimentContexts = getAllExperimentContexts();
// To not expose personal identifying information, the page title is hardcoded as `GitLab`
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243
- window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]);
+ window.snowplow('trackPageView', {
+ title: 'GitLab',
+ context: [standardContext, ...experimentContexts],
+ });
window.snowplow('setDocumentTitle', 'GitLab');
if (window.snowplowOptions.formTracking) {
diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js
index 85f4979e752..b69b1714952 100644
--- a/app/assets/javascripts/tracking/tracker.js
+++ b/app/assets/javascripts/tracking/tracker.js
@@ -207,14 +207,18 @@ export const Tracker = {
const mappedConfig = {};
if (config.forms) {
- mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
+ mappedConfig.forms = renameKey(config.forms, 'allow', 'allowlist');
}
if (config.fields) {
- mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
+ mappedConfig.fields = renameKey(config.fields, 'allow', 'allowlist');
}
- const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
+ const enabler = () =>
+ window.snowplow('enableFormTracking', {
+ options: mappedConfig,
+ context: userProvidedContexts,
+ });
if (document.readyState === 'complete') {
enabler();
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
index 2b97886e650..beff3b4c0c3 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -9,9 +9,6 @@ import {
PROJECT_TABLE_LABEL_USAGE,
containerRegistryId,
containerRegistryPopoverId,
- uploadsId,
- uploadsPopoverId,
- uploadsPopoverContent,
} from '../constants';
import { descendingStorageUsageSort } from '../utils';
import StorageTypeIcon from './storage_type_icon.vue';
@@ -40,10 +37,6 @@ export default {
popoverId: containerRegistryPopoverId,
popoverContent: this.containerRegistryPopoverContent,
},
- [uploadsId]: {
- popoverId: uploadsPopoverId,
- popoverContent: this.$options.i18n.uploadsPopoverContent,
- },
};
return this.storageTypes
@@ -77,9 +70,6 @@ export default {
thClass: thWidthPercent(10),
},
],
- i18n: {
- uploadsPopoverContent,
- },
};
</script>
<template>
@@ -100,7 +90,7 @@ export default {
:aria-label="helpLinkAriaLabel(item.storageType.name)"
:data-testid="`${item.storageType.id}-help-link`"
>
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</gl-link>
</p>
<p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
index bc7cd42df1e..5142c2c0915 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
@@ -16,7 +16,6 @@ export default {
const storageTypeIconMap = {
lfsObjectsSize: 'doc-image',
snippetsSize: 'snippet',
- uploadsSize: 'upload',
repositorySize: 'infrastructure-registry',
packagesSize: 'package',
};
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
index 7e001685060..e9683924ff8 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -1,17 +1,10 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECT_STORAGE_TYPES } from '../constants';
import { descendingStorageUsageSort } from '../utils';
export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
mixins: [glFeatureFlagMixin()],
props: {
rootStorageStatistics: {
@@ -35,9 +28,7 @@ export default {
storageSize,
wikiSize,
snippetsSize,
- uploadsSize,
} = this.rootStorageStatistics;
- const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
if (storageSize === 0) {
return null;
@@ -70,9 +61,15 @@ export default {
},
{
id: 'buildArtifactsSize',
- style: this.usageStyle(this.barRatio(artifactsSize)),
- class: 'gl-bg-data-viz-green-600',
- size: artifactsSize,
+ style: this.usageStyle(this.barRatio(buildArtifactsSize)),
+ class: 'gl-bg-data-viz-green-500',
+ size: buildArtifactsSize,
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ style: this.usageStyle(this.barRatio(pipelineArtifactsSize)),
+ class: 'gl-bg-data-viz-green-800',
+ size: pipelineArtifactsSize,
},
{
id: 'wikiSize',
@@ -86,12 +83,6 @@ export default {
class: 'gl-bg-data-viz-orange-800',
size: snippetsSize,
},
- {
- id: 'uploadsSize',
- style: this.usageStyle(this.barRatio(uploadsSize)),
- class: 'gl-bg-data-viz-aqua-700',
- size: uploadsSize,
- },
]
.filter((data) => data.size !== 0)
.sort(descendingStorageUsageSort('size'))
@@ -99,11 +90,10 @@ export default {
const storageTypeExtraData = PROJECT_STORAGE_TYPES.find(
(type) => storageType.id === type.id,
);
- const { name, tooltip } = storageTypeExtraData || {};
+ const name = storageTypeExtraData?.name;
return {
name,
- tooltip,
...storageType,
};
});
@@ -155,15 +145,6 @@ export default {
<span class="gl-text-gray-500 gl-font-sm">
{{ formatSize(storageType.size) }}
</span>
- <span
- v-if="storageType.tooltip"
- v-gl-tooltip
- :title="storageType.tooltip"
- :aria-label="storageType.tooltip"
- class="gl-ml-2"
- >
- <gl-icon name="question" :size="12" />
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index bd8cd372ecf..8e3eaff4496 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -8,7 +8,7 @@ export const LEARN_MORE_LABEL = __('Learn more.');
export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
export const TOTAL_USAGE_SUBTITLE = s__(
- 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
+ 'UsageQuota|Includes artifacts, repositories, wiki, and other items.',
);
export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
@@ -20,11 +20,6 @@ export const projectContainerRegistryPopoverContent = s__(
export const containerRegistryId = 'containerRegistrySize';
export const containerRegistryPopoverId = 'container-registry-popover';
-export const uploadsId = 'uploadsSize';
-export const uploadsPopoverId = 'uploads-popover';
-export const uploadsPopoverContent = s__(
- 'NamespaceStorage|Uploads are not counted in namespace storage quotas.',
-);
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
@@ -32,45 +27,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
export const PROJECT_STORAGE_TYPES = [
{
id: 'containerRegistrySize',
- name: s__('UsageQuota|Container Registry'),
+ name: __('Container Registry'),
description: s__(
'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
),
},
{
id: 'buildArtifactsSize',
- name: s__('UsageQuota|Artifacts'),
- description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
- tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
+ name: __('Job artifacts'),
+ description: s__('UsageQuota|Job artifacts created by CI/CD.'),
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ name: __('Pipeline artifacts'),
+ description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'),
},
{
id: 'lfsObjectsSize',
- name: s__('UsageQuota|LFS storage'),
+ name: __('LFS'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
id: 'packagesSize',
- name: s__('UsageQuota|Packages'),
+ name: __('Packages'),
description: s__('UsageQuota|Code packages and container images.'),
},
{
id: 'repositorySize',
- name: s__('UsageQuota|Repository'),
+ name: __('Repository'),
description: s__('UsageQuota|Git repository.'),
},
{
id: 'snippetsSize',
- name: s__('UsageQuota|Snippets'),
+ name: __('Snippets'),
description: s__('UsageQuota|Shared bits of code and text.'),
},
{
- id: 'uploadsSize',
- name: s__('UsageQuota|Uploads'),
- description: s__('UsageQuota|File attachments and smaller design graphics.'),
- },
- {
id: 'wikiSize',
- name: s__('UsageQuota|Wiki'),
+ name: __('Wiki'),
description: s__('UsageQuota|Wiki content.'),
},
];
@@ -86,6 +80,9 @@ export const projectHelpPaths = {
buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
anchor: 'when-job-artifacts-are-deleted',
}),
+ pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', {
+ anchor: 'when-pipeline-artifacts-are-deleted',
+ }),
packages: helpPagePath('user/packages/package_registry/index.md', {
anchor: 'reduce-storage-usage',
}),
diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
index 6637e5e0865..d254f576219 100644
--- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
+++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
@@ -10,7 +10,6 @@ query getProjectStorageStatistics($fullPath: ID!) {
repositorySize
snippetsSize
storageSize
- uploadsSize
wikiSize
}
}
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index a401a9bbf2f..66e54b59187 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -467,6 +467,8 @@ function UsersSelect(currentUser, els, options = {}) {
// display:block overrides the hide-collapse rule
$value.css('display', '');
}
+
+ $('.dropdown-input-field', $block).val('');
},
multiSelect: $dropdown.hasClass('js-multiselect'),
inputMeta: $dropdown.data('inputMeta'),
@@ -694,17 +696,18 @@ UsersSelect.prototype.renderRow = function (
: '';
const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : '';
- const name =
+ const busyBadge =
user?.availability && isUserBusy(user.availability)
- ? sprintf(__('%{name} (Busy)'), { name: user.name })
- : user.name;
+ ? `<span class="badge badge-warning badge-pill gl-badge sm">${__('Busy')}</span>`
+ : '';
return `
<li data-user-id=${user.id} ${dataUserSuggested}>
<a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<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)}
+ ${escape(user.name)}
+ ${busyBadge}
</strong>
${
username
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 77736fb6ef5..e30982985b3 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
@@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
[VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
[VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
+
+export const GROUP_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The group and any public projects can be viewed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The project can be accessed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ '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 = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index f377a185879..5090081d281 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -1,6 +1,7 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -49,7 +50,7 @@ export default {
},
computed: {
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
targetBranchEscaped() {
return escape(this.targetBranch);
@@ -67,7 +68,7 @@ export default {
);
},
message() {
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
} else if (this.isMerged) {
return s__(
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index 74922dd922c..25cf5335fb5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -1,15 +1,15 @@
<script>
import { GlButton, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/alert';
+import { STATUS_MERGED } from '~/issues/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { s__, __ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
-import MrWidgetContainer from '../mr_widget_container.vue';
-import MrWidgetIcon from '../mr_widget_icon.vue';
+import StateContainer from '../state_container.vue';
import { INVALID_RULES_DOCS_PATH } from '../../constants';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
@@ -18,14 +18,17 @@ import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
export default {
name: 'MRWidgetApprovals',
components: {
- MrWidgetContainer,
- MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
+ StateContainer,
GlButton,
GlSprintf,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
+ provide: {
+ expandDetailsTooltip: __('Expand eligible approvers'),
+ collapseDetailsTooltip: __('Collapse eligible approvers'),
+ },
props: {
mr: {
type: Object,
@@ -55,6 +58,11 @@ export default {
required: false,
default: false,
},
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -76,13 +84,22 @@ export default {
return Boolean(this.action);
},
invalidRules() {
- return this.approvals.approvalState?.invalidApproversRules || [];
+ return this.approvals.approvalState?.rules?.filter((rule) => rule.invalid) || [];
+ },
+ invalidApprovedRules() {
+ return this.invalidRules.filter((rule) => rule.allowMergeWhenInvalid);
+ },
+ invalidFailedRules() {
+ return this.invalidRules.filter((rule) => !rule.allowMergeWhenInvalid);
},
hasInvalidRules() {
return this.mr.mergeRequestApproversAvailable && this.invalidRules.length;
},
- invalidRulesText() {
- return this.invalidRules.length;
+ hasInvalidApprovedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length;
+ },
+ hasInvalidFailedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.length;
},
approvedBy() {
return this.approvals.approvedBy?.nodes || [];
@@ -99,7 +116,7 @@ export default {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
},
showUnapprove() {
- return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
+ return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED;
},
approvalText() {
return this.isApproved && this.approvedBy.length > 0
@@ -125,11 +142,29 @@ export default {
return null;
},
- pluralizedRuleText() {
- return this.invalidRules.length > 1
+ pluralizedApprovedRuleText() {
+ return this.invalidApprovedRules.length > 1
? this.$options.i18n.invalidRulesPlural
: this.$options.i18n.invalidRuleSingular;
},
+ pluralizedFailedRuleText() {
+ return this.invalidFailedRules.length > 1
+ ? this.$options.i18n.invalidFailedRulesPlural
+ : this.$options.i18n.invalidFailedRuleSingular;
+ },
+ pluralizedRuleText() {
+ return [
+ this.hasInvalidFailedRules
+ ? sprintf(this.pluralizedFailedRuleText, { rules: this.invalidFailedRules.length })
+ : null,
+ this.hasInvalidApprovedRules
+ ? sprintf(this.pluralizedApprovedRuleText, { rules: this.invalidApprovedRules.length })
+ : null,
+ ]
+ .filter((text) => Boolean(text))
+ .join(', ')
+ .concat('.');
+ },
},
methods: {
approve() {
@@ -197,21 +232,30 @@ export default {
FETCH_LOADING,
linkToInvalidRules: INVALID_RULES_DOCS_PATH,
i18n: {
- invalidRuleSingular: s__(
- 'mrWidget|%{rules} invalid rule has been approved automatically, as no one can approve it.',
+ invalidRuleSingular: s__('mrWidget|%{rules} invalid rule has been approved automatically'),
+ invalidRulesPlural: s__('mrWidget|%{rules} invalid rules have been approved automatically'),
+ invalidFailedRuleSingular: s__(
+ "mrWidget|%{dangerStart}%{rules} rule can't be approved%{dangerEnd}",
),
- invalidRulesPlural: s__(
- 'mrWidget|%{rules} invalid rules have been approved automatically, as no one can approve them.',
+ invalidFailedRulesPlural: s__(
+ "mrWidget|%{dangerStart}%{rules} rules can't be approved%{dangerEnd}",
),
learnMore: __('Learn more.'),
},
};
</script>
<template>
- <mr-widget-container>
- <div class="js-mr-approvals d-flex align-items-start align-items-md-center">
- <mr-widget-icon name="approval" />
- <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div>
+ <div class="js-mr-approvals mr-section-container mr-widget-workflow">
+ <state-container
+ :is-loading="$apollo.queries.approvals.loading"
+ :mr="mr"
+ status="approval"
+ is-collapsible
+ collapse-on-desktop
+ :collapsed="collapsed"
+ @toggle="() => $emit('toggle')"
+ >
+ <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template>
<template v-else>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
@@ -220,7 +264,7 @@ export default {
:variant="action.variant"
:category="action.category"
:loading="isApproving"
- class="gl-mr-5"
+ class="gl-mr-3"
data-qa-selector="approve_button"
@click="action.action"
>
@@ -234,12 +278,15 @@ export default {
<approvals-summary
v-else
:approval-state="approvals"
+ :disable-committers-approval="disableCommittersApproval"
:multiple-approval-rules-available="mr.multipleApprovalRulesAvailable"
/>
</div>
<div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules">
<gl-sprintf :message="pluralizedRuleText">
- <template #rules>{{ invalidRulesText }}</template>
+ <template #danger="{ content }">
+ <span class="gl-font-weight-bold text-danger">{{ content }}</span>
+ </template>
</gl-sprintf>
</div>
</div>
@@ -249,9 +296,7 @@ export default {
:has-approval-auth-error="hasApprovalAuthError"
></slot>
</template>
- </div>
- <template #footer>
- <slot name="footer"></slot>
- </template>
- </mr-widget-container>
+ </state-container>
+ <slot name="footer"></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index 2af033bb80f..650fa798db6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink, GlPopover } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -12,6 +13,8 @@ import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/ma
export default {
components: {
+ GlLink,
+ GlPopover,
UserAvatarList,
},
props: {
@@ -24,6 +27,11 @@ export default {
type: Object,
required: true,
},
+ disableCommittersApproval: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
approvers() {
@@ -101,6 +109,13 @@ export default {
(approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId,
);
},
+ currentUserHasCommitted() {
+ if (!this.currentUserId) return false;
+
+ return this.approvalState.committers?.nodes?.some(
+ (user) => getIdFromGraphQLId(user.id) === this.currentUserId,
+ );
+ },
currentUserId() {
return gon.current_user_id;
},
@@ -115,10 +130,18 @@ export default {
<span v-if="approvalLeftMessage">{{ message }}</span>
<span v-else class="gl-font-weight-bold">{{ message }}</span>
<user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
+ class="gl-display-inline-flex gl-vertical-align-middle"
:img-size="24"
:items="approvers"
/>
</template>
+ <template v-if="disableCommittersApproval && currentUserHasCommitted">
+ <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{
+ __("Why can't I approve?")
+ }}</gl-link>
+ <gl-popover target="cant-approve-popover">
+ {{ __("You can't approve because you added one or more commits to this merge request.") }}
+ </gl-popover>
+ </template>
</div>
</template>
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 f9d0986d60d..1e5f91e12cf 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
@@ -60,7 +60,7 @@ export default {
>
<div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
<div class="gl-display-flex gl-m-auto gl-translate-y-n50">
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
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 e3f87c08ad4..4f8f8d6cb58 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
@@ -6,86 +6,6 @@ import {
TELEMETRY_WIDGET_FULL_REPORT_CLICKED,
} from '../../constants';
-/*
- * Additional events to send beyond the defaults for certain widget extensions
- */
-const nonStandardEvents = {
- codeQuality: {
- uniqueUser: {
- expand: ['i_testing_code_quality_widget_total'],
- },
- counter: {},
- },
- terraform: {
- uniqueUser: {
- expand: ['i_testing_terraform_widget_total'],
- },
- counter: {},
- },
- issues: {
- uniqueUser: {
- expand: ['i_testing_issues_widget_total'],
- },
- counter: {},
- },
- testSummary: {
- uniqueUser: {
- expand: ['i_testing_summary_widget_total'],
- },
- counter: {},
- },
- metrics: {
- uniqueUser: {
- expand: ['i_testing_metrics_report_widget_total'],
- },
- counter: {},
- },
- browserPerformance: {
- uniqueUser: {
- expand: ['i_testing_web_performance_widget_total'],
- },
- counter: {},
- },
- licenseCompliance: {
- uniqueUser: {
- expand: ['i_testing_license_compliance_widget_total'],
- },
- counter: {},
- },
- loadPerformance: {
- uniqueUser: {
- expand: ['i_testing_load_performance_widget_total'],
- },
- counter: {},
- },
- statusChecks: {
- uniqueUser: {
- expand: ['i_testing_status_checks_widget'],
- },
- counter: {},
- },
-};
-
-function combineDeepArray(path, ...objects) {
- const parts = path.split('.');
- const allEntries = objects.reduce((entries, currentObject) => {
- let expandedEntries = entries;
- let traversed = currentObject;
-
- parts.forEach((part) => {
- traversed = traversed?.[part];
- });
-
- if (traversed) {
- expandedEntries = [...entries, ...traversed];
- }
-
- return expandedEntries;
- }, []);
-
- return Array.from(new Set(allEntries));
-}
-
function simplifyWidgetName(componentName) {
const noWidget = componentName.replace(/^Widget/, '');
@@ -166,7 +86,6 @@ function defaultBehaviorEvents({ bus, config }) {
function baseTelemetry(componentName) {
const simpleExtensionName = simplifyWidgetName(componentName);
- const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {};
/*
* Telemetry config format is:
* {
@@ -179,7 +98,7 @@ function baseTelemetry(componentName) {
* - uniqueUser is sent to RedisHLL
* - counter is sent to a regular Redis counter
*/
- const defaultTelemetry = {
+ return {
uniqueUser: {
view: [`${baseRedisEventName(simpleExtensionName)}_view`],
expand: [`${baseRedisEventName(simpleExtensionName)}_expand`],
@@ -191,27 +110,6 @@ function baseTelemetry(componentName) {
clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`],
},
};
-
- return {
- uniqueUser: {
- view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'uniqueUser.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- counter: {
- view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'counter.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- };
}
export function createTelemetryHub(componentName) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 20284c4a3d8..26527361b2e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { escapeShellString } from '~/lib/utils/text_utility';
@@ -87,11 +86,11 @@ export default {
const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
return this.isFork
- ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD`
- : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` // eslint-disable-line @gitlab/require-i18n-strings
+ : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
mergeInfo2() {
- return `git push origin ${this.escapedSourceBranch}`;
+ return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
escapedForkBranch() {
return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 2dec95c3fda..4e16b92fc05 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -173,7 +173,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="sm" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
@@ -187,7 +187,7 @@ export default {
class="gl-display-flex gl-align-items-center gl-ml-2"
>
<gl-icon
- name="question"
+ name="question-o"
:aria-label="__('Link to go to GitLab pipeline documentation')"
/>
</gl-link>
@@ -251,7 +251,7 @@ export default {
</span>
{{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion">
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</span>
<gl-tooltip
:target="() => $refs.pipelineCoverageQuestion"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 1fd1e264c25..5d75f1d27b1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, n__ } from '~/locale';
@@ -30,10 +31,10 @@ export default {
},
computed: {
closesText() {
- if (this.state === 'merged') {
+ if (this.state === STATUS_MERGED) {
return s__('mrWidget|Closed');
}
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidget|Did not close');
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 3239285e53e..ea3f324b8f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './extensions/status_icon.vue';
export default {
@@ -14,22 +15,24 @@ export default {
},
},
computed: {
+ isClosed() {
+ return this.status === STATUS_CLOSED;
+ },
isLoading() {
return this.status === 'loading';
},
+ isMerged() {
+ return this.status === STATUS_MERGED;
+ },
},
};
</script>
<template>
- <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3">
+ <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3">
<div class="gl-display-flex gl-m-auto">
- <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" />
- <gl-icon
- v-else-if="status === 'closed'"
- name="merge-request-close"
- :size="16"
- class="gl-text-red-500"
- />
+ <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" />
+ <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" />
+ <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" />
<status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" />
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
index 3e79c49994f..dd899701de0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './mr_widget_status_icon.vue';
import Actions from './action_buttons.vue';
@@ -13,7 +13,30 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ expandDetailsTooltip: {
+ default: '',
+ },
+ collapseDetailsTooltip: {
+ default: '',
+ },
+ },
props: {
+ isCollapsible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapseOnDesktop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
mr: {
type: Object,
required: false,
@@ -35,14 +58,10 @@ export default {
default: () => [],
},
},
- i18n: {
- expandDetailsTooltip: __('Expand merge details'),
- collapseDetailsTooltip: __('Collapse merge details'),
- },
computed: {
wrapperClasses() {
- if (this.status === 'merged') return 'gl-bg-blue-50';
- if (this.status === 'closed') return 'gl-bg-red-50';
+ if (this.status === STATUS_MERGED) return 'gl-bg-blue-50';
+ if (this.status === STATUS_CLOSED) return 'gl-bg-red-50';
return null;
},
hasActionsSlot() {
@@ -54,11 +73,11 @@ export default {
<template>
<div
- class="mr-widget-body media gl-display-flex gl-align-items-center"
+ class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4"
:class="wrapperClasses"
v-on="$listeners"
>
- <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <div v-if="isLoading" class="gl-w-full mr-state-loader">
<slot name="loading">
<div class="gl-display-flex">
<status-icon status="loading" />
@@ -94,21 +113,19 @@ export default {
</div>
</div>
<div
- v-if="mr"
- class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1"
+ v-if="isCollapsible"
+ :class="{ 'gl-md-display-none': !collapseOnDesktop }"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
v-gl-tooltip
- :title="
- mr.mergeDetailsCollapsed
- ? $options.i18n.expandDetailsTooltip
- : $options.i18n.collapseDetailsTooltip
- "
- :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
+ :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
size="small"
class="gl-vertical-align-top"
- @click="() => mr.toggleMergeDetails()"
+ data-testid="widget-toggle"
+ @click="() => $emit('toggle')"
/>
</div>
</div>
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 6d7ec607557..61eec503951 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
@@ -43,7 +43,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="failedText" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 837f8b32637..722efe2e6d2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<bold-text :message="$options.message" />
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index bcae1a12344..6299f0fcbb8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -131,16 +131,23 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions">
+ <state-container
+ status="scheduled"
+ :is-loading="loading"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="7" width="150" height="16" rx="4" />
- <rect x="190" y="7" width="144" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!loading">
- <h4 class="gl-mr-3" data-testid="statusText">
+ <h4 class="gl-mr-3 gl-flex-grow-1" data-testid="statusText">
<gl-sprintf :message="statusText" data-testid="statusText">
<template #merge_author>
<mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 448805cf8b9..db5ef6c1a0e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -54,7 +54,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :actions="actions">
+ <state-container
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
<template v-if="mergeError">{{ mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index 670bd36d61e..d4b7d60568b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -15,7 +15,12 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="loading">
+ <state-container
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
{{ s__('mrWidget|Checking if merge request can be merged…') }}
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index 6bcf88713a5..aebba67b39a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -79,7 +79,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="closed" :actions="actions">
+ <state-container
+ status="closed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Closed by')"
:author="mr.metrics.closedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 83d718f5a54..55ae390216d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -72,12 +72,18 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :is-loading="isLoading">
+ <state-container
+ status="failed"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="7" width="150" height="16" rx="4" />
- <rect x="158" y="7" width="84" height="16" rx="4" />
- <rect x="250" y="7" width="84" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
@@ -106,7 +112,7 @@ export default {
v-if="userPermissions.canMerge"
size="small"
variant="confirm"
- category="secondary"
+ category="tertiary"
data-testid="merge-locally-button"
class="js-check-out-modal-trigger gl-align-self-start"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index bfc2c282f4c..742f5d4de14 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -95,12 +95,25 @@ export default {
};
</script>
<template>
- <state-container v-if="isRefreshing" :mr="mr" status="loading">
+ <state-container
+ v-if="isRefreshing"
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
{{ s__('mrWidget|Refreshing now') }}
</span>
</state-container>
- <state-container v-else :mr="mr" status="failed" :actions="actions">
+ <state-container
+ v-else
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
v-if="mr.mergeError"
class="has-error-message gl-font-weight-bold"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 4e2b12799d0..4d906f29cb0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -150,7 +150,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" :actions="actions" status="merged">
+ <state-container
+ :actions="actions"
+ status="merged"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Merged by')"
:author="mr.metrics.mergedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index c94718ca756..17c51bc4e6e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,5 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { STATUS_MERGED } from '~/issues/constants';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
@@ -50,7 +51,7 @@ export default {
.poll()
.then((res) => res.data)
.then((data) => {
- if (data.state === 'merged') {
+ if (data.state === STATUS_MERGED) {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
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 fac8d37712a..415f58ea8e6 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
@@ -195,11 +195,17 @@ export default {
</script>
<template>
<div>
- <state-container :mr="mr" :status="status" :is-loading="isLoading">
+ <state-container
+ :status="status"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="5" width="302" height="20" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="302" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 9e67791afc0..a3c529de27c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -16,6 +16,7 @@ import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { createAlert } from '~/alert';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __, s__, n__ } from '~/locale';
@@ -23,6 +24,7 @@ import SmartInterval from '~/smart_interval';
import { helpPagePath } from '~/helpers/help_page_helper';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import {
AUTO_MERGE_STRATEGIES,
WARNING,
@@ -143,6 +145,7 @@ export default {
),
AddedCommitMessage,
RelatedLinks,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -261,7 +264,10 @@ export default {
if (this.isMergingImmediately) {
return __('Merge in progress');
}
- if (this.isAutoMergeAvailable) {
+ if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) {
+ return this.autoMergeTextLegacy;
+ }
+ if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) {
return this.autoMergeText;
}
@@ -271,9 +277,24 @@ export default {
return __('Merge');
},
+ autoMergeLabelsEnabled() {
+ return window.gon?.features?.autoMergeLabelsMrWidget;
+ },
+ showAutoMergeHelperText() {
+ return (
+ !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) &&
+ this.isAutoMergeAvailable
+ );
+ },
hasPipelineMustSucceedConflict() {
return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
+ isNotClosed() {
+ return this.mr.state !== STATUS_CLOSED;
+ },
+ isNeitherClosedNorMerged() {
+ return this.mr.state !== STATUS_CLOSED && this.mr.state !== STATUS_MERGED;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -307,7 +328,7 @@ export default {
);
},
sourceBranchDeletedText() {
- const isPreMerge = this.mr.state !== 'merged';
+ const isPreMerge = this.mr.state !== STATUS_MERGED;
if (isPreMerge) {
return this.mr.shouldRemoveSourceBranch
@@ -325,6 +346,11 @@ export default {
showMergeDetailsHeader() {
return !['readyToMerge'].includes(this.mr.state);
},
+ autoMergeHelpPopoverOptions() {
+ return {
+ title: this.autoMergePopoverSettings.title,
+ };
+ },
},
mounted() {
eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
@@ -495,17 +521,19 @@ export default {
<template>
<div
- :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }"
+ :class="{ 'gl-bg-gray-10': isNeitherClosedNorMerged }"
data-testid="ready_to_merge_state"
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
- <gl-skeleton-loader :width="418" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="0" width="70" height="30" rx="4" />
- <rect x="110" y="7" width="150" height="16" rx="4" />
- <rect x="268" y="7" width="150" height="16" rx="4" />
+ <gl-skeleton-loader :width="418" :height="86">
+ <rect x="0" y="0" width="144" height="20" rx="4" />
+ <rect x="0" y="26" width="100" height="16" rx="4" />
+ <rect x="108" y="26" width="100" height="16" rx="4" />
+ <rect x="0" y="48" width="130" height="16" rx="4" />
+ <rect x="0" y="70" width="80" height="16" rx="4" />
+ <rect x="88" y="70" width="90" height="16" rx="4" />
</gl-skeleton-loader>
</div>
</div>
@@ -517,7 +545,7 @@ export default {
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
<template v-if="shouldShowMergeControls">
<div
- class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-5"
+ class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-2"
>
<gl-form-checkbox
v-if="canRemoveSourceBranch"
@@ -587,9 +615,7 @@ export default {
</li>
</ul>
</div>
- <div
- class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details"
- >
+ <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details">
<template v-if="sourceHasDivergedFromTarget">
<gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText">
<template #link>
@@ -670,7 +696,31 @@ export default {
@cancel="isPipelineFailedModalVisibleNormalMerge = false"
/>
</gl-button-group>
- <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled"
+ class="gl-mx-3"
+ />
+ <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled">
+ <div
+ class="gl-ml-4 gl-text-gray-500 gl-font-sm"
+ data-qa-selector="auto_merge_helper_text"
+ >
+ {{ autoMergeHelperText }}
+ </div>
+ <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions">
+ <gl-sprintf :message="autoMergePopoverSettings.bodyText">
+ <template #link="{ content }">
+ <gl-link
+ :href="autoMergePopoverSettings.helpLink"
+ target="_blank"
+ class="gl-font-sm"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </help-popover>
+ </template>
</template>
<div
v-else
@@ -702,7 +752,7 @@ export default {
/>
</li>
<li
- v-if="mr.state !== 'closed'"
+ v-if="isNotClosed"
class="gl-line-height-normal"
data-testid="source-branch-deleted-text"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 2aa345b420e..9da754d01fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
data-qa-selector="head_mismatch_content"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 0fd5551979d..af036c01032 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import StateContainer from '../state_container.vue';
const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.');
@@ -15,6 +16,7 @@ export default {
GlButton,
StateContainer,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mr: {
type: Object,
@@ -30,7 +32,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="$options.message" />
</span>
@@ -43,17 +50,17 @@ export default {
category="primary"
@click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Go to first unresolved thread') }}
</gl-button>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
+ v-if="mr.createIssueToResolveDiscussionsPath && !glFeatures.hideCreateIssueResolveAll"
:href="mr.createIssueToResolveDiscussionsPath"
class="js-create-issue gl-align-self-start gl-vertical-align-top"
size="small"
variant="confirm"
category="secondary"
>
- {{ s__('mrWidget|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Resolve all with new issue') }}
</gl-button>
</template>
</state-container>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 7163e54985e..7fc4a06cbae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -137,7 +137,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-0! gl-text-body! gl-flex-grow-1">
<bold-text :message="$options.i18n.removeDraftStatus" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
index 6d17ac98d7f..4e8098677cc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
@@ -52,7 +52,7 @@ export default {
}"
class="gl-relative gl-rounded-full gl-mr-3"
>
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
index a754d4e80ea..54eb15c8ac8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -219,6 +219,8 @@ export default {
this.fetchExpandedContent();
}
}
+
+ this.$emit('toggle', { expanded: !this.isCollapsed });
},
async fetchExpandedContent() {
this.isLoadingExpandedContent = true;
@@ -287,7 +289,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="gl-px-5 gl-py-4 gl-align-items-center gl-display-flex">
+ <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex">
<status-icon
:level="1"
:name="widgetName"
@@ -346,6 +348,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
+ data-qa-selector="expand_report_button"
@click="toggleCollapsed"
/>
</div>
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 ff225afbc7b..d2f2d394a1f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import { STATUS_CLOSED } from '~/issues/constants';
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
@@ -82,7 +83,7 @@ export default {
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
- name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
+ name: issue.state === STATUS_CLOSED ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
},
// Badges get rendered next to the text on each row
// badge: issue.state === 'closed' && {
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 183f450854a..a2f088a7a58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,7 +1,3 @@
-// This is a false violation of @gitlab/no-runtime-template-compiler, since it
-// creates a new Vue instance by spreading a _valid_ Vue component definition
-// into the Vue constructor.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
@@ -33,6 +29,10 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
+ // This is a false violation of @gitlab/no-runtime-template-compiler, since it
+ // creates a new Vue instance by spreading a _valid_ Vue component definition
+ // into the Vue constructor.
+ // eslint-disable-next-line @gitlab/no-runtime-template-compiler
const vm = new Vue({
el: '#js-vue-mr-widget',
provide: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
index ae9111b9504..7e658e77d37 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -16,6 +16,8 @@ export default {
result({ data }) {
const { mergeRequest } = data.project;
+ this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval;
+
this.mr.setApprovals(mergeRequest);
},
error() {
@@ -29,6 +31,7 @@ export default {
return {
alerts: [],
approvals: {},
+ disableCommittersApproval: false,
};
},
methods: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index d964b4bacac..10a54c73273 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,3 +1,4 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
@@ -30,10 +31,25 @@ export default {
pipelineMustSucceedConflictText() {
return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
},
- autoMergeText() {
+ autoMergeTextLegacy() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
},
+ autoMergeText() {
+ return __('Set to auto-merge');
+ },
+ autoMergeHelperText() {
+ return __('Merge when pipeline succeeds');
+ },
+ autoMergePopoverSettings() {
+ return {
+ helpLink: helpPagePath('/user/project/merge_requests/merge_when_pipeline_succeeds.html'),
+ bodyText: __(
+ 'When the pipeline for this merge request succeeds, it will %{linkStart}automatically merge%{linkEnd}.',
+ ),
+ title: __('Merge when pipeline succeeds'),
+ };
+ },
shouldShowMergeImmediatelyDropdown() {
return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
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 bbad2c13220..6e0ee1cb912 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
@@ -10,9 +10,9 @@ import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_wid
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { createAlert } from '~/alert';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
-import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -155,6 +155,10 @@ export default {
},
},
mixins: [mergeRequestQueryVariablesMixin],
+ provide: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
props: {
mrData: {
type: Object,
@@ -226,7 +230,7 @@ export default {
return this.mr.allowCollaboration && this.mr.isOpen;
},
shouldRenderMergedPipeline() {
- return this.mr.state === 'merged' && !isEmpty(this.mr.mergePipeline);
+ return this.mr.state === STATUS_MERGED && !isEmpty(this.mr.mergePipeline);
},
showMergePipelineForkWarning() {
return Boolean(
@@ -264,7 +268,7 @@ export default {
return (this.mr.humanAccess || '').toLowerCase();
},
hasMergeError() {
- return this.mr.mergeError && this.state !== 'closed';
+ return this.mr.mergeError && this.state !== STATUS_CLOSED;
},
hasAlerts() {
return this.hasMergeError || this.showMergePipelineForkWarning;
@@ -416,8 +420,8 @@ export default {
);
},
setFaviconHelper() {
- if (this.mr.ciStatusFaviconPath) {
- return setFaviconOverlay(this.mr.ciStatusFaviconPath);
+ if (this.mr.faviconOverlayPath) {
+ return setFaviconOverlay(this.mr.faviconOverlayPath);
}
return Promise.resolve();
},
@@ -474,7 +478,6 @@ export default {
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
- Project.initRefSwitcher();
}
})
.catch(() =>
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 13009651550..a7758191315 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,5 +1,6 @@
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { badgeState } from '~/issuable/components/status_box.vue';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { machine } from '~/lib/utils/finite_state_machine';
import {
@@ -121,7 +122,7 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.mergeRequestState = data.state;
- this.isOpen = this.mergeRequestState === 'opened';
+ this.isOpen = this.mergeRequestState === STATUS_OPEN;
this.latestSHA = data.diff_head_sha;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
@@ -139,7 +140,7 @@ export default class MergeRequestStore {
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked =
data.only_allow_merge_if_pipeline_succeeds && pipelineStatus?.group === 'manual';
- this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+ this.faviconOverlayPath = data.favicon_overlay_path;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
this.accessibilityReportPath = data.accessibility_report_path;
@@ -236,11 +237,11 @@ export default class MergeRequestStore {
this.state = getStateKey.call(this);
} else {
switch (this.mergeRequestState) {
- case 'merged':
- this.state = 'merged';
+ case STATUS_MERGED:
+ this.state = STATUS_MERGED;
break;
- case 'closed':
- this.state = 'closed';
+ case STATUS_CLOSED:
+ this.state = STATUS_CLOSED;
break;
default:
this.state = null;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
deleted file mode 100644
index 9d5006564ef..00000000000
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import * as Sentry from '@sentry/browser';
-import Vue from 'vue';
-import Vuex from 'vuex';
-
-Vue.use(Vuex);
-
-export default {
- props: {
- dashboardUrl: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- metricEmbedComponent: null,
- namespace: 'alertMetrics',
- };
- },
- mounted() {
- if (this.dashboardUrl) {
- Promise.all([
- import('~/monitoring/components/embeds/metric_embed.vue'),
- import('~/monitoring/stores'),
- ])
- .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => {
- this.$store = new Vuex.Store({
- modules: {
- [this.namespace]: monitoringDashboard,
- },
- });
- this.metricEmbedComponent = MetricEmbed;
- })
- .catch((e) => Sentry.captureException(e));
- }
- },
-};
-</script>
-
-<template>
- <div class="gl-py-3">
- <div v-if="dashboardUrl" ref="metricsChart">
- <component
- :is="metricEmbedComponent"
- v-if="metricEmbedComponent"
- :dashboard-url="dashboardUrl"
- :namespace="namespace"
- />
- </div>
- <div v-else ref="emptyState">
- {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }}
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index f2c27cf611e..0577279cdd0 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
@@ -53,11 +53,11 @@ export default {
},
methods: {
updateToDoCount(add) {
- const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
+ const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10) || 0;
const count = add ? oldCount + 1 : oldCount - 1;
const headerTodoEvent = new CustomEvent('todo:toggle', {
detail: {
- count,
+ count: Math.max(count, 0),
},
});
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index d106f545c61..4ee8d19770d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -9,7 +9,8 @@ export const SEVERITY_LEVELS = {
UNKNOWN: s__('severity|Unknown'),
};
-/* eslint-disable @gitlab/require-i18n-strings */
+const category = 'Alert Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
export const PAGE_CONFIG = {
OPERATIONS: {
TITLE: 'OPERATIONS',
@@ -20,14 +21,14 @@ export const PAGE_CONFIG = {
},
// Tracks snowplow event when user views alert details
TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'view_alert_details',
},
// Tracks snowplow event when alert status is updated
TRACK_ALERT_STATUS_UPDATE_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'update_alert_status',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index cb38b3e13bb..8f1f7ba0ad8 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -35,11 +35,6 @@ export default {
required: false,
default: NO_USER_ID,
},
- addButtonClass: {
- type: String,
- required: false,
- default: '',
- },
defaultAwards: {
type: Array,
required: false,
@@ -50,6 +45,11 @@ export default {
required: false,
default: 'selected',
},
+ boundary: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -201,6 +201,8 @@ export default {
v-gl-tooltip.viewport
:title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
+ :right="false"
+ :boundary="boundary"
data-testid="emoji-picker"
@click="handleAward"
@shown="setIsMenuOpen(true)"
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index c89e843b660..b4751d51fcb 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -1,4 +1,5 @@
<script>
+import { v4 as uuidv4 } from 'uuid';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { CHART_CONTAINER_HEIGHT } from './constants';
@@ -17,6 +18,15 @@ export default {
required: true,
},
},
+ data: () => ({
+ chartKey: uuidv4(),
+ }),
+ watch: {
+ chartData() {
+ // Re-render area chart when the data changes
+ this.chartKey = uuidv4();
+ },
+ },
chartContainerHeight: CHART_CONTAINER_HEIGHT,
};
</script>
@@ -27,6 +37,7 @@ export default {
</p>
<gl-area-chart
v-bind="$attrs"
+ :key="chartKey"
responsive
width="auto"
:height="$options.chartContainerHeight"
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 47b96934420..a30b18348ec 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -39,11 +39,10 @@ export default {
</script>
<template>
<div>
- <segmented-control-button-group
- v-model="selectedChart"
- :options="chartRanges"
- class="gl-mb-4"
- />
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <segmented-control-button-group v-model="selectedChart" :options="chartRanges" />
+ <slot name="extend-button-group"></slot>
+ </div>
<ci-cd-analytics-area-chart
v-if="chart"
v-bind="$attrs"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
new file mode 100644
index 00000000000..97143d90c3f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
@@ -0,0 +1,11 @@
+import { RENAMED_DIFF_TRANSITIONS } from '~/diffs/constants';
+
+export const transition = (currentState, transitionEvent) => {
+ const key = `${currentState}:${transitionEvent}`;
+
+ if (RENAMED_DIFF_TRANSITIONS[key]) {
+ return RENAMED_DIFF_TRANSITIONS[key];
+ }
+
+ return currentState;
+};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index b786f7752df..f7b817423de 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -10,15 +10,14 @@ import {
STATE_IDLING,
STATE_LOADING,
STATE_ERRORED,
- RENAMED_DIFF_TRANSITIONS,
} from '~/diffs/constants';
import { truncateSha } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
+import { transition } from '../utils';
export default {
STATE_LOADING,
STATE_ERRORED,
- TRANSITIONS: RENAMED_DIFF_TRANSITIONS,
uiText: {
showLink: __('Show file contents'),
commitLink: __('View file @ %{commitSha}'),
@@ -52,27 +51,23 @@ export default {
},
methods: {
...mapActions('diffs', ['switchToFullDiffFromRenamedFile']),
- transition(transitionEvent) {
- const key = `${this.state}:${transitionEvent}`;
-
- if (this.$options.TRANSITIONS[key]) {
- this.state = this.$options.TRANSITIONS[key];
- }
- },
is(state) {
return this.state === state;
},
switchToFull() {
- this.transition(TRANSITION_LOAD_START);
+ this.transitionState(TRANSITION_LOAD_START);
this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile })
.then(() => {
- this.transition(TRANSITION_LOAD_SUCCEED);
+ this.transitionState(TRANSITION_LOAD_SUCCEED);
})
.catch(() => {
- this.transition(TRANSITION_LOAD_ERROR);
+ this.transitionState(TRANSITION_LOAD_ERROR);
});
},
+ transitionState(transitionEvent) {
+ this.state = transition(this.state, transitionEvent);
+ },
clickLink(event) {
if (this.canLoadFullDiff) {
event.preventDefault();
@@ -81,7 +76,7 @@ export default {
}
},
dismissError() {
- this.transition(TRANSITION_ACKNOWLEDGE_ERROR);
+ this.transitionState(TRANSITION_ACKNOWLEDGE_ERROR);
},
},
};
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 1da84df022f..b920af593df 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -27,6 +27,12 @@ export default {
type: Number,
required: true,
},
+ /* enable possibility to cycle around */
+ enableCycle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
max() {
@@ -64,15 +70,34 @@ export default {
return;
}
- const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
+ let nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
- // Return if the index didn't change
if (nextIndex === this.index) {
- return;
+ // Return if the index didn't change and cycle is not enabled
+ if (!this.enableCycle) {
+ return;
+ }
+ // Update nextIndex if the cycle is enabled
+ nextIndex = this.cycle(nextIndex, val);
}
this.$emit('change', nextIndex);
},
+ cycle(nextIndex, val) {
+ if (val === 1 && nextIndex === this.max) {
+ // if we are moving down +1 and we reached bottom (max)
+ // return top most index (min)
+ return this.min;
+ }
+
+ if (val === -1 && nextIndex === this.min) {
+ // if we are moving up -1 and we reached top (min)
+ // return bottom most index (max)
+ return this.max;
+ }
+
+ return nextIndex;
+ },
},
render() {
return this.$scopedSlots.default?.();
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index 45c50dce8ce..9b45e969c90 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -13,6 +13,11 @@ export default {
GlCollapsibleListbox,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -176,6 +181,7 @@ export default {
<gl-collapsible-listbox
ref="listbox"
v-model="selected"
+ :block="block"
:header-text="headerText"
:reset-button-label="resetButtonLabel"
:toggle-text="toggleText"
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
index 1afbeda74c4..12db70d8e9c 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
@@ -20,6 +20,8 @@ export const initProjectSelects = () => {
orderBy,
selected: initialSelection,
} = el.dataset;
+ const block = parseBoolean(el.dataset.block);
+ const withShared = parseBoolean(el.dataset.withShared);
const includeSubgroups = parseBoolean(el.dataset.includeSubgroups);
const membership = parseBoolean(el.dataset.membership);
const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel);
@@ -37,6 +39,8 @@ export const initProjectSelects = () => {
groupId,
userId,
orderBy,
+ block,
+ withShared,
includeSubgroups,
membership,
initialSelection,
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
index 393991d746e..7af3819f2a5 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
@@ -20,6 +20,11 @@ export default {
SafeHtml,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -47,6 +52,11 @@ export default {
required: false,
default: null,
},
+ withShared: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
includeSubgroups: {
type: Boolean,
required: false,
@@ -86,7 +96,7 @@ export default {
if (this.groupId) {
return Api.groupProjects(this.groupId, searchString, {
...commonParams,
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
simple: true,
});
@@ -99,7 +109,7 @@ export default {
this.userId,
searchString,
{
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
},
(res) => ({ data: res }),
@@ -154,6 +164,7 @@ export default {
:default-toggle-text="$options.i18n.searchForProject"
:fetch-items="fetchProjects"
:fetch-initial-selection-text="fetchProjectName"
+ :block="block"
clearable
>
<template v-if="hasHtmlLabel" #label>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index b0fa3e4c27e..b5783265ffa 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants';
+import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants';
import {
getRecentlyUsedSuggestions,
setTokenValueToRecentlyUsed,
@@ -100,7 +100,7 @@ export default {
return this.getActiveTokenValue(this.suggestions, this.value.data);
},
availableDefaultSuggestions() {
- if (this.value.operator === OPERATOR_NOT) {
+ if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) {
return this.defaultSuggestions.filter(
(suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value),
);
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 2f10e068542..dea279890b1 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
@@ -137,6 +137,7 @@ export default {
v-if="showCopyButton"
:text="value"
:title="copyButtonTitle"
+ data-qa-selector="clipboard_button"
@click="handleCopyButtonClick"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
index 989b14f8711..8b3a54a536e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { updateText } from '~/lib/utils/text_markdown';
import savedRepliesQuery from './saved_replies.query.graphql';
export default {
@@ -9,7 +10,7 @@ export default {
query: savedRepliesQuery,
update: (r) => r.currentUser?.savedReplies?.nodes,
skip() {
- return !this.shouldFetchSavedReplies;
+ return !this.shouldFetchCommentTemplates;
},
},
},
@@ -22,34 +23,52 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- newSavedRepliesPath: {
+ newCommentTemplatePath: {
type: String,
required: true,
},
},
data() {
return {
- shouldFetchSavedReplies: false,
+ shouldFetchCommentTemplates: false,
savedReplies: [],
- savedRepliesSearch: '',
+ commentTemplateSearch: '',
loadingSavedReplies: false,
};
},
computed: {
filteredSavedReplies() {
- const savedReplies = this.savedRepliesSearch
- ? fuzzaldrinPlus.filter(this.savedReplies, this.savedRepliesSearch, { key: ['name'] })
+ const savedReplies = this.commentTemplateSearch
+ ? fuzzaldrinPlus.filter(this.savedReplies, this.commentTemplateSearch, { key: ['name'] })
: this.savedReplies;
return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content }));
},
},
methods: {
- fetchSavedReplies() {
- this.shouldFetchSavedReplies = true;
+ fetchCommentTemplates() {
+ this.shouldFetchCommentTemplates = true;
},
- setSavedRepliesSearch(search) {
- this.savedRepliesSearch = search;
+ setCommentTemplateSearch(search) {
+ this.commentTemplateSearch = search;
+ },
+ onSelect(id) {
+ const savedReply = this.savedReplies.find((r) => r.id === id);
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+
+ if (savedReply && textArea) {
+ updateText({
+ textArea,
+ tag: savedReply.content,
+ cursorOffset: 0,
+ wrap: false,
+ });
+
+ // Wait for text to be added into textarea
+ requestAnimationFrame(() => {
+ textArea.focus();
+ });
+ }
},
},
};
@@ -57,36 +76,32 @@ export default {
<template>
<gl-collapsible-listbox
- :header-text="__('Insert saved reply')"
+ :header-text="__('Insert comment template')"
:items="filteredSavedReplies"
placement="right"
searchable
- class="saved-replies-dropdown"
+ class="comment-template-dropdown"
:searching="$apollo.queries.savedReplies.loading"
- @shown="fetchSavedReplies"
- @search="setSavedRepliesSearch"
+ @shown="fetchCommentTemplates"
+ @search="setCommentTemplateSearch"
+ @select="onSelect"
>
<template #toggle>
<gl-button
v-gl-tooltip
- :title="__('Insert saved reply')"
- :aria-label="__('Insert saved reply')"
+ :title="__('Insert comment template')"
+ :aria-label="__('Insert comment template')"
category="tertiary"
class="gl-px-3!"
- data-testid="saved-replies-dropdown-toggle"
+ data-testid="comment-template-dropdown-toggle"
+ @keydown.prevent
>
<gl-icon name="symlink" class="gl-mr-0!" />
<gl-icon name="chevron-down" />
</gl-button>
</template>
<template #list-item="{ item }">
- <div
- class="gl-display-flex js-saved-reply-content"
- :data-md-tag="item.content"
- data-md-cursor-offset="0"
- data-md-prepend="true"
- data-testid="saved-reply-dropdown-item"
- >
+ <div class="gl-display-flex js-comment-template-content">
<div class="gl-text-truncate">
<strong>{{ item.text }}</strong
><span class="gl-ml-2">{{ item.content }}</span>
@@ -98,11 +113,11 @@ export default {
class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3"
>
<gl-button
- :href="newSavedRepliesPath"
+ :href="newCommentTemplatePath"
category="tertiary"
block
class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
- >{{ __('Add a new saved reply') }}</gl-button
+ >{{ __('Add a new comment template') }}</gl-button
>
</div>
</template>
@@ -110,11 +125,11 @@ export default {
</template>
<style>
-.saved-replies-dropdown .gl-new-dropdown-panel {
+.comment-template-dropdown .gl-new-dropdown-panel {
width: 350px;
}
-.saved-replies-dropdown .gl-new-dropdown-item-check-icon {
+.comment-template-dropdown .gl-new-dropdown-item-check-icon {
display: none;
}
</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
index 9ebf782a1d9..7803d6f53e0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
@@ -23,7 +23,7 @@ export default {
return this.value === 'markdown';
},
text() {
- return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text');
+ return this.markdownEditorSelected ? __('Editing markdown') : __('Editing rich text');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 9623c51d51c..cc153747765 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -416,7 +416,7 @@ export default {
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
- class="referenced-commands"
+ class="referenced-commands gl-mx-n5"
data-testid="referenced-commands"
></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index eeeb0fce55d..3486f231b39 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -17,7 +17,7 @@ import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
import DrawioToolbarButton from './drawio_toolbar_button.vue';
-import SavedRepliesDropdown from './saved_replies_dropdown.vue';
+import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
export default {
components: {
@@ -27,16 +27,19 @@ export default {
GlTabs,
GlTab,
DrawioToolbarButton,
- SavedRepliesDropdown,
+ CommentTemplatesDropdown,
+ AiActionsDropdown: () =>
+ import('ee_component/vue_shared/components/markdown/ai_actions_dropdown.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
inject: {
- newSavedRepliesPath: {
+ newCommentTemplatePath: {
default: null,
},
+ resourceGlobalId: { default: null },
},
props: {
previewMarkdown: {
@@ -118,6 +121,9 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
+ showAiActions() {
+ return this.resourceGlobalId && this.glFeatures.summarizeComments;
+ },
},
watch: {
showSuggestPopover() {
@@ -269,6 +275,7 @@ export default {
</gl-button>
</gl-popover>
</template>
+ <ai-actions-dropdown v-if="showAiActions" :resource-global-id="resourceGlobalId" />
<toolbar-button
tag="**"
:button-title="
@@ -400,9 +407,9 @@ export default {
:uploads-path="uploadsPath"
:markdown-preview-path="markdownPreviewPath"
/>
- <saved-replies-dropdown
- v-if="newSavedRepliesPath && glFeatures.savedReplies"
- :new-saved-replies-path="newSavedRepliesPath"
+ <comment-templates-dropdown
+ v-if="newCommentTemplatePath && glFeatures.savedReplies"
+ :new-comment-template-path="newCommentTemplatePath"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 93583907a11..52d8aab30d5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -20,6 +20,11 @@ export default {
type: String,
required: true,
},
+ setFacade: {
+ type: Function,
+ required: false,
+ default: null,
+ },
renderMarkdownPath: {
type: String,
required: true,
@@ -44,6 +49,16 @@ export default {
required: false,
default: false,
},
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
supportsQuickActions: {
type: Boolean,
required: false,
@@ -54,6 +69,11 @@ export default {
required: false,
default: null,
},
+ markdownDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
quickActionsDocsPath: {
type: String,
required: false,
@@ -97,9 +117,25 @@ export default {
mounted() {
this.autofocusTextarea();
+ this.$emit('input', this.markdown);
this.saveDraft();
+
+ this.setFacade?.({
+ getValue: () => this.getValue(),
+ setValue: (val) => this.setValue(val),
+ });
},
methods: {
+ getValue() {
+ return this.markdown;
+ },
+ setValue(value) {
+ this.markdown = value;
+ this.$emit('input', value);
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
updateMarkdownFromContentEditor({ markdown }) {
this.markdown = markdown;
this.$emit('input', markdown);
@@ -121,6 +157,11 @@ export default {
this.notifyEditingModeChange(editingMode);
},
onEditingModeRestored(editingMode) {
+ if (editingMode === EDITING_MODE_CONTENT_EDITOR && !this.enableContentEditor) {
+ this.editingMode = EDITING_MODE_MARKDOWN_FIELD;
+ return;
+ }
+
this.editingMode = editingMode;
this.$emit(editingMode);
this.notifyEditingModeChange(editingMode);
@@ -161,7 +202,8 @@ export default {
<div>
<local-storage-sync
v-model="editingMode"
- storage-key="gl-wiki-content-editor-enabled"
+ as-string
+ storage-key="gl-markdown-editor-mode"
@input="onEditingModeRestored"
/>
<markdown-field
@@ -173,11 +215,14 @@ export default {
can-attach-file
:textarea-value="markdown"
:uploads-path="uploadsPath"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:show-content-editor-switcher="enableContentEditor"
:drawio-enabled="drawioEnabled"
- class="bordered-box"
@enableContentEditor="onEditingModeChange('contentEditor')"
+ @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<textarea
@@ -205,6 +250,8 @@ export default {
:autofocus="contentEditorAutofocused"
:placeholder="formFieldProps.placeholder"
:drawio-enabled="drawioEnabled"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
:editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
new file mode 100644
index 00000000000..0f2a46f78f7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import MarkdownEditor from './markdown_editor.vue';
+
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
+function organizeQuery(obj, isFallbackKey = false) {
+ if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
+ return obj;
+ }
+
+ if (isFallbackKey) {
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ };
+ }
+
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH],
+ };
+}
+
+function format(searchTerm, isFallbackKey = false) {
+ const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true });
+ const organizeQueryObject = organizeQuery(queryObject, isFallbackKey);
+ const formattedQuery = objectToQuery(organizeQueryObject);
+
+ return formattedQuery;
+}
+
+function getSearchTerm(newIssuePath) {
+ const { search, pathname } = document.location;
+ return newIssuePath === pathname ? '' : format(search);
+}
+
+export function mountMarkdownEditor() {
+ const el = document.querySelector('.js-markdown-editor');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldPlaceholder,
+ formFieldClasses,
+ qaSelector,
+ newIssuePath,
+ } = el.dataset;
+
+ const hiddenInput = el.querySelector('input[type="hidden"]');
+ const formFieldName = hiddenInput.getAttribute('name');
+ const formFieldId = hiddenInput.getAttribute('id');
+ const formFieldValue = hiddenInput.value;
+
+ const searchTerm = getSearchTerm(newIssuePath);
+ const facade = {
+ setValue() {},
+ getValue() {},
+ focus() {},
+ };
+
+ const setFacade = (props) => Object.assign(facade, props);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(h) {
+ return h(MarkdownEditor, {
+ props: {
+ setFacade,
+ enableContentEditor: Boolean(gon.features?.contentEditorOnIssues),
+ value: formFieldValue,
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldProps: {
+ placeholder: formFieldPlaceholder,
+ id: formFieldId,
+ name: formFieldName,
+ class: formFieldClasses,
+ 'data-qa-selector': qaSelector,
+ },
+ autosaveKey: `autosave/${document.location.pathname}/${searchTerm}/description`,
+ enableAutocomplete: true,
+ autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
+ supportsQuickActions: true,
+ autofocus: true,
+ },
+ });
+ },
+ });
+
+ return facade;
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 49eb11f8081..6d1cadf15be 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -176,6 +176,12 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
+ <div
+ v-show="isRendered"
+ ref="container"
+ v-safe-html="noteHtml"
+ data-testid="suggestions-container"
+ class="md suggestions"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index e091fe74717..fac32bfdb24 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -13,7 +13,9 @@ export default {
<template>
<timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
- <div class="timeline-icon"></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ ></div>
<div class="timeline-content">
<div class="note-header"></div>
<div class="note-body gl-mt-4"><gl-skeleton-loader /></div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 1cbbdf0deb0..06ca90fa8c6 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -25,11 +25,18 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { spriteIcon } from '~/lib/utils/common_utils';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+const MR_ICON_COLORS = {
+ check: 'gl-bg-green-100 gl-text-green-700',
+ 'merge-request-close': 'gl-bg-red-100 gl-text-red-700',
+ merge: 'gl-bg-blue-100 gl-text-blue-700',
+};
+const ICON_COLORS = {
+ 'issue-close': 'gl-bg-blue-100 gl-text-blue-700',
+};
export default {
i18n: {
@@ -63,7 +70,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'descriptionVersions']),
+ ...mapGetters(['targetNoteHash', 'descriptionVersions', 'getNoteableData']),
...mapState(['isLoadingDescriptionVersion']),
noteAnchorId() {
return `note_${this.note.id}`;
@@ -71,9 +78,6 @@ export default {
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
- iconHtml() {
- return spriteIcon(this.note.system_note_icon_name);
- },
toggleIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
@@ -87,6 +91,19 @@ export default {
descriptionVersion() {
return this.descriptionVersions[this.note.description_version_id];
},
+ isMergeRequest() {
+ return this.getNoteableData.noteableType === 'MergeRequest';
+ },
+ hasIconColors() {
+ if (!this.isMergeRequest) return true;
+
+ return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name];
+ },
+ iconBgClass() {
+ const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS;
+
+ return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600';
+ },
},
mounted() {
renderGFM(this.$refs['gfm-content']);
@@ -108,9 +125,6 @@ export default {
}
},
},
- safeHtmlConfig: {
- ADD_TAGS: ['use'], // to support icon SVGs
- },
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@@ -121,7 +135,24 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div>
+ <div
+ :class="[
+ iconBgClass,
+ {
+ 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors,
+ 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest,
+ 'mr-system-note-icon': isMergeRequest,
+ },
+ ]"
+ class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon"
+ >
+ <gl-icon
+ v-if="note.system_note_icon_name && hasIconColors"
+ :name="note.system_note_icon_name"
+ :size="isMergeRequest ? 12 : 16"
+ data-testid="timeline-icon"
+ />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
index 35f9ac14681..9d5f494579b 100644
--- a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
@@ -1,3 +1,7 @@
<template>
- <div class="timeline-icon"><slot></slot></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <slot></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
new file mode 100644
index 00000000000..1ace1c52a68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -0,0 +1,37 @@
+<script>
+import ProjectsListItem from './projects_list_item.vue';
+
+export default {
+ components: { ProjectsListItem },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * }[];
+ */
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <projects-list-item v-for="project in projects" :key="project.id" :project="project" />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
new file mode 100644
index 00000000000..f77fd029e93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -0,0 +1,152 @@
+<script>
+import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+
+import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_ENABLED } from '~/featurable/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+
+export default {
+ i18n: {
+ stars: __('Stars'),
+ forks: __('Forks'),
+ issues: __('Issues'),
+ archived: __('Archived'),
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlLink,
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ */
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.project.visibility];
+ },
+ visibilityTooltip() {
+ return PROJECT_VISIBILITY_TYPE[this.project.visibility];
+ },
+ accessLevel() {
+ return this.project.permissions?.projectAccess?.accessLevel;
+ },
+ accessLevelLabel() {
+ return ACCESS_LEVEL_LABELS[this.accessLevel];
+ },
+ shouldShowAccessLevel() {
+ return this.accessLevel !== undefined;
+ },
+ starsHref() {
+ return `${this.project.webUrl}/-/starrers`;
+ },
+ forksHref() {
+ return `${this.project.webUrl}/-/forks`;
+ },
+ issuesHref() {
+ return `${this.project.webUrl}/-/issues`;
+ },
+ isForkingEnabled() {
+ return (
+ this.project.forkingAccessLevel === FEATURABLE_ENABLED &&
+ this.project.forksCount !== undefined
+ );
+ },
+ isIssuesEnabled() {
+ return this.project.issuesAccessLevel === FEATURABLE_ENABLED;
+ },
+ },
+ methods: {
+ numberToMetricPrefix,
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+ <gl-avatar-labeled
+ class="gl-flex-grow-1"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="48"
+ >
+ <template #meta>
+ <gl-icon
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary gl-ml-3"
+ />
+ <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </template>
+ </gl-avatar-labeled>
+ <div
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
+ <gl-link
+ v-gl-tooltip="$options.i18n.stars"
+ :href="starsHref"
+ :aria-label="$options.i18n.stars"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="star-o" />
+ <span>{{ numberToMetricPrefix(project.starCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isForkingEnabled"
+ v-gl-tooltip="$options.i18n.forks"
+ :href="forksHref"
+ :aria-label="$options.i18n.forks"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="fork" />
+ <span>{{ numberToMetricPrefix(project.forksCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isIssuesEnabled"
+ v-gl-tooltip="$options.i18n.issues"
+ :href="issuesHref"
+ :aria-label="$options.i18n.issues"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="issues" />
+ <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
+ </gl-link>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
index 384b084ce09..d7d62df78f5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
@@ -19,7 +19,9 @@ export default {
<template>
<timeline-entry-item class="system-note note-wrapper">
- <div class="timeline-icon">
+ <div
+ class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left"
+ >
<gl-icon :name="icon" />
</div>
<div class="timeline-content">
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 092e8ba6c15..d77061d4b31 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -1,6 +1,5 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
@@ -21,7 +20,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
isHighlighted: {
type: Boolean,
@@ -69,7 +67,6 @@ export default {
return this.content.split('\n');
},
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -106,7 +103,6 @@ export default {
class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
class="gl-user-select-none gl-shadow-none! file-line-blame"
:href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
></a>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
index ce6741f33b1..f121e84e1de 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -1,13 +1,11 @@
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
number: {
type: Number,
@@ -28,7 +26,6 @@ export default {
},
computed: {
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -41,8 +38,7 @@ export default {
class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
- class="gl-user-select-none gl-shadow-none! file-line-blame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame gl-mx-n2 gl-flex-grow-1"
:href="`${blamePath}${pageSearchString}#L${number}`"
></a>
<a
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 dd9d2ce66cd..e09f193310b 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
@@ -1,5 +1,6 @@
<script>
import {
+ GlBadge,
GlPopover,
GlLink,
GlSkeletonLoader,
@@ -35,6 +36,7 @@ export default {
I18N_USER_LEARN,
USER_POPOVER_DELAY,
components: {
+ GlBadge,
GlIcon,
GlLink,
GlPopover,
@@ -226,9 +228,9 @@ export default {
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_USER_BUSY }})</span
- >
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-1">
+ {{ $options.I18N_USER_BUSY }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</div>
@@ -269,7 +271,7 @@ export default {
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="$options.I18N_USER_LEARN">
<template #name>{{ user.name }}</template>
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 4ef9bc07b1c..9665e188469 100644
--- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -5,7 +5,9 @@ export default {
// We can't use this.vuexModule due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- vuexModule: this.$options.propsData.vuexModule,
+
+ // @vue-compat does not care to normalize propsData fields
+ vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'],
};
},
props: {
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
index 388e7c92f03..4211b9578a2 100644
--- a/app/assets/javascripts/vue_shared/global_search/constants.js
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -27,6 +27,10 @@ export const KBD_HELP = sprintf(
{ kbdOpen: '<kbd>', kbdClose: '</kbd>' },
false,
);
+export const MIN_SEARCH_TERM = s__(
+ 'GlobalSearch|The search term must be at least 3 characters long.',
+);
+
export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}');
export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
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 5b303b9a314..a68c577bff6 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
@@ -2,6 +2,7 @@
import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { STATUS_CLOSED } from '~/issues/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
@@ -93,13 +94,13 @@ export default {
return getTimeago().format(this.issuable.createdAt);
},
timestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return this.issuable.closedAt;
}
return this.issuable.updatedAt;
},
formattedTimestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return sprintf(__('closed %{timeago}'), {
timeago: getTimeago().format(this.issuable.closedAt),
});
@@ -226,7 +227,7 @@ export default {
</gl-link>
<span
v-if="taskStatus"
- class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm"
data-testid="task-status"
>
{{ taskStatus }}
@@ -265,7 +266,7 @@ export default {
:data-avatar-url="author.avatarUrl"
:href="author.webUrl"
data-testid="issuable-author"
- class="author-link js-user-link"
+ class="author-link js-user-link gl-font-sm"
>
<span class="author">{{ author.name }}</span>
</gl-link>
@@ -285,8 +286,7 @@ export default {
</span>
<slot name="timeframe"></slot>
</span>
- &nbsp;
- <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <p v-if="labels.length" role="group" :aria-label="__('Labels')" class="gl-mt-1 gl-mb-0">
<gl-label
v-for="(label, index) in labels"
:key="index"
@@ -295,10 +295,10 @@ export default {
:description="label.description"
:scoped="scopedLabel(label)"
:target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
+ class="gl-mr-2"
size="sm"
/>
- </span>
+ </p>
</div>
</div>
<div class="issuable-meta">
@@ -312,7 +312,7 @@ export default {
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
+ class="gl-align-items-center gl-display-flex"
/>
</li>
<slot name="statistics"></slot>
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 5b6c5bf6e03..3ac6aaf8b86 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,5 +1,5 @@
<script>
-import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
+import { GlAlert, GlBadge, 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';
@@ -24,6 +24,7 @@ export default {
},
components: {
GlAlert,
+ GlBadge,
GlKeysetPagination,
GlSkeletonLoader,
IssuableTabs,
@@ -371,7 +372,9 @@ export default {
<slot name="timeframe" :issuable="issuable"></slot>
</template>
<template #status>
- <slot name="status" :issuable="issuable"></slot>
+ <gl-badge size="sm" variant="info">
+ <slot name="status" :issuable="issuable"></slot>
+ </gl-badge>
</template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index a8d5f72373c..45fde45f516 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -139,7 +139,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description js-detail-page-description content-block gl-pt-2">
+ <div class="detail-page-description js-detail-page-description content-block gl-pt-4">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index 26309a25f07..08e52442311 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -20,7 +20,9 @@ export default {
};
</script>
<template>
- <div class="container gl-display-flex gl-flex-direction-column">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-mt-4 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid"
+ >
<h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
{{ title }}
</h2>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index 2533b3b5489..31fd9e0a0ec 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -130,17 +130,19 @@ export default {
<template>
<credit-card-verification v-if="shouldVerify" @verified="onVerified" />
- <div v-else-if="!activePanelName">
- <gl-breadcrumb :items="breadcrumbs" />
+ <div v-else-if="!activePanelName" class="gl-mt-4">
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
<welcome-page :panels="panels" :title="title">
<template #footer>
<slot name="welcome-footer"> </slot>
</template>
</welcome-page>
</div>
- <div v-else>
- <gl-breadcrumb :items="breadcrumbs" />
- <div class="gl-display-flex gl-py-5 gl-align-items-center">
+ <div v-else class="gl-pt-4">
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mt-4 gl-py-5 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid"
+ >
<div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
<div class="col">
<h4>{{ activePanel.title }}</h4>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 3afd1f9410b..c2ff2eec9fa 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import { parseErrorMessage } from '~/lib/utils/error_message';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../provider';
@@ -9,6 +10,16 @@ function mutationSettingsForFeatureType(type) {
return featureToMutationMap[type];
}
+export const i18n = {
+ buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
+ noSuccessPathError: s__(
+ 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
+ ),
+ genericErrorText: s__(
+ `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
+ ),
+};
+
export default {
apolloProvider,
components: {
@@ -55,15 +66,20 @@ export default {
throw new Error(errors[0]);
}
+ // Sending window.gon.uf_error_prefix prefixed messages should happen only in
+ // the backend. Hence the code below is an anti-pattern.
+ // The issue to refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/397714
if (!successPath) {
throw new Error(
- sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
+ `${window.gon.uf_error_prefix} ${sprintf(this.$options.i18n.noSuccessPathError, {
+ featureName: this.feature.name,
+ })}`,
);
}
redirectTo(successPath);
} catch (e) {
- this.$emit('error', e.message);
+ this.$emit('error', parseErrorMessage(e, this.$options.i18n.genericErrorText));
this.isLoading = false;
}
},
@@ -84,12 +100,7 @@ export default {
Boolean(mutationSettingsForFeatureType(type))
);
},
- i18n: {
- buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
- noSuccessPathError: s__(
- 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
- ),
- },
+ i18n,
};
</script>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index fafbd02634f..8b523645973 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -20,6 +20,7 @@ export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_SAST_IAC = 'sast_iac';
export const REPORT_TYPE_DAST = 'dast';
export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
+export const REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION = 'breach_and_attack_simulation';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
@@ -28,6 +29,7 @@ export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
+export const REPORT_TYPE_MANUALLY_ADDED = 'generic';
/**
* SecurityReportTypeEnum values for use with GraphQL.
diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue
index 78e5dff6f59..90a8a7aa3e6 100644
--- a/app/assets/javascripts/webhooks/components/test_dropdown.vue
+++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue
@@ -19,45 +19,16 @@ export default {
},
},
computed: {
- itemsWithAction() {
- return this.items.map((item) => ({
- text: item.text,
- action: () => this.testHook(item.href),
+ webhookTriggers() {
+ return this.items.map(({ text, href }) => ({
+ text,
+ href,
+ extraAttrs: {
+ 'data-method': 'post',
+ },
}));
},
},
- methods: {
- testHook(href) {
- // HACK: Trigger @rails/ujs's data-method handling.
- //
- // The more obvious approaches of (1) declaratively rendering the
- // links using GlDisclosureDropdown's list-item slot and (2) using
- // item.extraAttrs to set the data-method attributes on the links
- // do not work for reasons laid out in
- // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134.
- //
- // Sending the POST with axios also doesn't work, since the
- // endpoints return 302 redirects. Since axios uses XMLHTTPRequest,
- // it transparently follows redirects, meaning the Location header
- // of the first response cannot be inspected/acted upon by JS. We
- // could manually trigger a reload afterwards, but that would mean
- // a duplicate fetch of the current page: one by the XHR, and one
- // by the explicit reload. It would also mean losing the flash
- // alert set by the backend, making the feature useless for the
- // user.
- //
- // The ideal fix here would be to refactor the test endpoint to
- // return a JSON response, removing the need for a redirect/page
- // reload to show the result.
- const a = document.createElement('a');
- a.setAttribute('hidden', '');
- a.href = href;
- a.dataset.method = 'post';
- document.body.appendChild(a);
- a.click();
- a.remove();
- },
- },
i18n: {
test: __('Test'),
},
@@ -65,5 +36,5 @@ export default {
</script>
<template>
- <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" />
+ <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="webhookTriggers" :size="size" />
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue
index ab4691a4a4e..f8dfa1c7f01 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -127,7 +127,11 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index 1762344ea9e..6c27d5a87f0 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -1,9 +1,9 @@
<script>
-import { GlAvatar, GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { clearDraft } from '~/lib/utils/autosave';
import Tracking from '~/tracking';
import { ASC } from '~/notes/constants';
+import { __ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getWorkItemQuery } from '../../utils';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
@@ -17,8 +17,6 @@ export default {
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
- GlAvatar,
- GlButton,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
@@ -66,11 +64,25 @@ export default {
required: false,
default: ASC,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
workItem: {},
- isEditing: false,
+ isEditing: this.isNewDiscussion,
isSubmitting: false,
isSubmittingWithKeydown: false,
};
@@ -109,28 +121,9 @@ export default {
property: `type_${this.workItemType}`,
};
},
- markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
- },
- isLockedOutOrSignedOut() {
- return !this.signedIn || !this.canUpdate;
- },
- lockedOutUserWarningInReplies() {
- return this.addPadding && this.isLockedOutOrSignedOut;
- },
- timelineEntryClass() {
- return {
- 'timeline-entry gl-mb-3 note note-wrapper note-comment': true,
- 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this
- .lockedOutUserWarningInReplies,
- };
- },
timelineEntryInnerClass() {
return {
- 'timeline-entry-inner': true,
- 'gl-pb-3': this.addPadding,
+ 'timeline-entry-inner': this.isNewDiscussion,
};
},
timelineContentClass() {
@@ -141,8 +134,7 @@ export default {
},
parentClass() {
return {
- 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap': !this
- .isEditing,
+ 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing,
};
},
isProjectArchived() {
@@ -151,6 +143,18 @@ export default {
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
+ workItemState() {
+ return this.workItem?.state;
+ },
+ commentButtonText() {
+ return this.isNewDiscussion ? __('Comment') : __('Reply');
+ },
+ timelineEntryClass() {
+ return this.isNewDiscussion
+ ? 'timeline-entry note-form'
+ : // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix';
+ },
},
watch: {
autofocus: {
@@ -166,7 +170,6 @@ export default {
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', commentText);
-
try {
this.track('add_work_item_comment');
@@ -180,25 +183,56 @@ export default {
},
},
update(store, createNoteData) {
- if (createNoteData.data?.createNote?.errors?.length) {
+ const numErrors = createNoteData.data?.createNote?.errors?.length;
+
+ if (numErrors) {
+ const { errors } = createNoteData.data.createNote;
+
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557
+ // When a note only contains quick actions,
+ // additional "helpful" messages are embedded in the errors field.
+ // For instance, a note solely composed of "/assign @foobar" would
+ // return a message "Commands only Assigned @root." as an error on creation
+ // even though the quick action successfully executed.
+ if (
+ numErrors === 2 &&
+ errors[0].includes('Commands only') &&
+ errors[1].includes('Command names')
+ ) {
+ return;
+ }
+
throw new Error(createNoteData.data?.createNote?.errors[0]);
}
},
});
- clearDraft(this.autosaveKey);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * Once form is successfully submitted, emit replied event,
+ * mark isSubmitting to false and clear storage before hiding the form.
+ * This will restrict comment form to restore the value while textarea
+ * input triggered due to keyboard event meta+enter.
+ *
+ */
this.$emit('replied');
+ clearDraft(this.autosaveKey);
this.cancelEditing();
} catch (error) {
this.$emit('error', error.message);
Sentry.captureException(error);
+ } finally {
+ this.isSubmitting = false;
}
-
- this.isSubmitting = false;
},
cancelEditing() {
- this.isEditing = false;
+ this.isEditing = this.isNewDiscussion;
this.$emit('cancelEditing');
},
+ showReplyForm() {
+ this.isEditing = true;
+ this.$emit('startReplying');
+ },
},
};
</script>
@@ -212,9 +246,6 @@ export default {
:is-project-archived="isProjectArchived"
/>
<div v-else :class="timelineEntryInnerClass">
- <div class="timeline-avatar gl-float-left">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
- </div>
<div :class="timelineContentClass">
<div :class="parentClass">
<work-item-comment-form
@@ -223,15 +254,27 @@ export default {
:aria-label="__('Add a reply')"
:is-submitting="isSubmitting"
:autosave-key="autosaveKey"
+ :is-new-discussion="isNewDiscussion"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-state="workItemState"
+ :work-item-id="workItemId"
+ :autofocus="autofocus"
+ :comment-button-text="commentButtonText"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
/>
- <gl-button
+ <textarea
v-else
- class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
- @click="isEditing = true"
- >{{ __('Add a reply') }}</gl-button
- >
+ ref="textarea"
+ rows="1"
+ class="reply-placeholder-text-field gl-font-regular!"
+ data-testid="note-reply-textarea"
+ :placeholder="__('Reply')"
+ :aria-label="__('Reply to comment')"
+ @focus="showReplyForm"
+ @click="showReplyForm"
+ ></textarea>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index a3ebd51f76d..f9f24366725 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -1,11 +1,22 @@
<script>
import { GlButton } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__, __ } from '~/locale';
-import { joinPaths } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ STATE_OPEN,
+ STATE_EVENT_REOPEN,
+ STATE_EVENT_CLOSE,
+ TRACKING_CATEGORY_SHOW,
+ i18n,
+} from '~/work_items/constants';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
export default {
constantOptions: {
@@ -15,8 +26,13 @@ export default {
GlButton,
MarkdownEditor,
},
+ mixins: [Tracking.mixin()],
inject: ['fullPath'],
props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
workItemType: {
type: String,
required: true,
@@ -44,20 +60,44 @@ export default {
required: false,
default: __('Comment'),
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemState: {
+ type: String,
+ required: false,
+ default: STATE_OPEN,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
+ updateInProgress: false,
};
},
computed: {
- markdownPreviewPath() {
- return joinPaths(
- '/',
- gon.relative_url_root || '',
- this.fullPath,
- `/preview_markdown?target_type=${this.workItemType}`,
- );
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_task_status',
+ property: `type_${this.workItemType}`,
+ };
},
formFieldProps() {
return {
@@ -67,11 +107,30 @@ export default {
name: 'work-item-add-or-edit-comment',
};
},
+ isWorkItemOpen() {
+ return this.workItemState === STATE_OPEN;
+ },
+ toggleWorkItemStateText() {
+ return this.isWorkItemOpen
+ ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() })
+ : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() });
+ },
+ cancelButtonText() {
+ return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel');
+ },
},
methods: {
setCommentText(newText) {
- this.commentText = newText;
- updateDraft(this.autosaveKey, this.commentText);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * While the form is saving using meta+enter,
+ * avoid updating the data which is cleared after form submission.
+ */
+ if (!this.isSubmitting) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ }
},
async cancelEditing() {
if (this.commentText && this.commentText !== this.initialValue) {
@@ -91,23 +150,68 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
+ async toggleWorkItemState() {
+ const input = {
+ id: this.workItemId,
+ stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN,
+ };
+
+ this.updateInProgress = true;
+
+ try {
+ this.track('updated_state');
+
+ const { mutation, variables } = getUpdateWorkItemMutation({
+ workItemParentId: this.workItemParentId,
+ input,
+ });
+
+ const { data } = await this.$apollo.mutate({
+ mutation,
+ variables,
+ });
+
+ const errors = data.workItemUpdate?.errors;
+
+ if (errors?.length) {
+ this.$emit('error', i18n.updateError);
+ }
+ } catch (error) {
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+
+ this.$emit('error', msg);
+ Sentry.captureException(error);
+ }
+
+ this.updateInProgress = false;
+ },
+ cancelButtonAction() {
+ if (this.isNewDiscussion) {
+ this.toggleWorkItemState();
+ } else {
+ this.cancelEditing();
+ }
+ },
},
};
</script>
<template>
- <div class="timeline-discussion-body">
- <div class="note-body">
+ <div class="timeline-discussion-body gl-overflow-visible!">
+ <div class="note-body gl-p-0! gl-overflow-visible!">
<form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
<markdown-editor
:value="commentText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
:form-field-props="formFieldProps"
+ :add-spacing-classes="false"
data-testid="work-item-add-comment"
class="gl-mb-3"
- autofocus
use-bottom-toolbar
+ supports-quick-actions
+ :autofocus="autofocus"
@input="setCommentText"
@keydown.meta.enter="$emit('submitForm', commentText)"
@keydown.ctrl.enter="$emit('submitForm', commentText)"
@@ -117,6 +221,7 @@ export default {
category="primary"
variant="confirm"
data-testid="confirm-button"
+ :disabled="!commentText.length"
:loading="isSubmitting"
@click="$emit('submitForm', commentText)"
>{{ commentButtonText }}
@@ -125,8 +230,9 @@ export default {
data-testid="cancel-button"
category="primary"
class="gl-ml-3"
- @click="cancelEditing"
- >{{ __('Cancel') }}
+ :loading="updateInProgress"
+ @click="cancelButtonAction"
+ >{{ cancelButtonText }}
</gl-button>
</form>
</div>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
index 1e08fecaf3d..21fc8f99366 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -54,6 +54,25 @@ export default {
required: false,
default: false,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -117,8 +136,7 @@ export default {
this.isExpanded = !this.isExpanded;
},
threadKey(note) {
- /* eslint-disable @gitlab/require-i18n-strings */
- return `${note.id}-thread`;
+ return `${note.id}-thread`; // eslint-disable-line @gitlab/require-i18n-strings
},
onReplied() {
this.isExpanded = true;
@@ -142,7 +160,15 @@ export default {
:has-replies="hasReplies"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
:class="{ 'gl-mb-4': hasReplies }"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :work-item-id="workItemId"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@error="$emit('error', $event)"
@@ -167,6 +193,14 @@ export default {
:work-item-type="workItemType"
:is-modal="isModal"
:class="{ 'gl-mb-4': hasReplies }"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@error="$emit('error', $event)"
@@ -186,6 +220,14 @@ export default {
:note="reply"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', reply)"
@error="$emit('error', $event)"
@@ -204,6 +246,9 @@ export default {
:work-item-type="workItemType"
:sort-order="sortOrder"
:add-padding="true"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ @startReplying="showReplyForm"
@cancelEditing="hideReplyForm"
@replied="onReplied"
@replying="onReplying"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
index 07e25312f87..e7a80bf39fb 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
@@ -30,7 +30,9 @@ export default {
<template>
<li class="timeline-entry note note-wrapper discussion-filter-note">
- <div class="timeline-icon gl-display-none gl-lg-display-flex">
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
<gl-icon name="comment" />
</div>
<div class="timeline-content gl-pl-8">
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index dcb6557600e..b8911592f5d 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -1,27 +1,26 @@
<script>
-import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants';
+import Tracking from '~/tracking';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getWorkItemQuery } from '~/work_items/utils';
import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
name: 'WorkItemNoteThread',
- i18n: {
- moreActionsText: __('More actions'),
- deleteNoteText: __('Delete comment'),
- copyLinkText: __('Copy link'),
- },
components: {
TimelineEntryItem,
NoteBody,
@@ -29,15 +28,28 @@ export default {
NoteActions,
GlAvatar,
GlAvatarLink,
- GlDropdown,
- GlDropdownItem,
WorkItemCommentForm,
EditedAt,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
+ mixins: [Tracking.mixin()],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
note: {
type: Object,
required: true,
@@ -61,6 +73,25 @@ export default {
required: false,
default: false,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -68,6 +99,13 @@ export default {
};
},
computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_note_actions',
+ property: `type_${this.workItemType}`,
+ };
+ },
author() {
return this.note.author;
},
@@ -111,6 +149,28 @@ export default {
hasAwardEmojiPermission() {
return this.note.userPermissions.awardEmoji;
},
+ isAuthorAnAssignee() {
+ return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length);
+ },
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ skip() {
+ return !this.queryVariables.id && !this.queryVariables.iid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
},
methods: {
showReplyForm() {
@@ -121,8 +181,8 @@ export default {
updateDraft(this.autosaveKey, this.note.body);
},
async updateNote(newText) {
- this.isEditing = false;
try {
+ this.isEditing = false;
await this.$apollo.mutate({
mutation: updateWorkItemNoteMutation,
variables: {
@@ -149,12 +209,68 @@ export default {
Sentry.captureException(error);
}
},
+ getNewAssigneesAndWidget() {
+ let newAssignees = [];
+ if (this.isAuthorAnAssignee) {
+ newAssignees = this.assignees.filter(({ id }) => id !== this.author.id);
+ } else {
+ newAssignees = [...this.assignees, this.author];
+ }
+
+ const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
+
+ const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget);
+
+ const editedWorkItemWidgets = [...this.workItem.widgets];
+
+ editedWorkItemWidgets[assigneesWidgetIndex] = {
+ ...editedWorkItemWidgets[assigneesWidgetIndex],
+ assignees: {
+ nodes: newAssignees,
+ },
+ };
+
+ return {
+ newAssignees,
+ editedWorkItemWidgets,
+ };
+ },
notifyCopyDone() {
if (this.isModal) {
navigator.clipboard.writeText(this.noteUrl);
}
toast(__('Link copied to clipboard.'));
},
+ async assignUserAction() {
+ const { newAssignees, editedWorkItemWidgets } = this.getNewAssigneesAndWidget();
+
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds: newAssignees.map(({ id }) => id),
+ },
+ },
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...this.workItem,
+ widgets: editedWorkItemWidgets,
+ },
+ },
+ },
+ });
+ this.track(`${this.isAuthorAnAssignee ? 'unassigned_user' : 'assigned_user'}`);
+ } catch (error) {
+ this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
@@ -179,6 +295,11 @@ export default {
:autosave-key="autosaveKey"
:initial-value="note.body"
:comment-button-text="__('Save comment')"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-id="workItemId"
+ :autofocus="isEditing"
+ class="gl-pl-3 gl-mt-3"
@cancelEditing="isEditing = false"
@submitForm="updateNote"
/>
@@ -199,32 +320,15 @@ export default {
:show-reply="showReply"
:show-edit="hasAdminPermission"
:note-id="note.id"
+ :is-author-an-assignee="isAuthorAnAssignee"
+ :show-assign-unassign="canSetWorkItemMetadata"
@startReplying="showReplyForm"
@startEditing="startEditing"
@error="($event) => $emit('error', $event)"
+ @notifyCopyDone="notifyCopyDone"
+ @deleteNote="$emit('deleteNote')"
+ @assignUser="assignUserAction"
/>
- <gl-dropdown
- v-gl-tooltip
- icon="ellipsis_v"
- text-sr-only
- right
- :text="$options.i18n.moreActionsText"
- :title="$options.i18n.moreActionsText"
- category="tertiary"
- no-caret
- >
- <gl-dropdown-item :data-clipboard-text="noteUrl" @click="notifyCopyDone">
- <span>{{ $options.i18n.copyLinkText }}</span>
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="hasAdminPermission"
- variant="danger"
- data-testid="delete-note-action"
- @click="$emit('deleteNote')"
- >
- {{ $options.i18n.deleteNoteText }}
- </gl-dropdown-item>
- </gl-dropdown>
</div>
</div>
<div class="timeline-discussion-body">
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index 6bea7953698..624a532c2aa 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
@@ -10,11 +10,18 @@ export default {
name: 'WorkItemNoteActions',
i18n: {
editButtonText: __('Edit comment'),
+ moreActionsText: __('More actions'),
+ deleteNoteText: __('Delete comment'),
+ copyLinkText: __('Copy link'),
+ assignUserText: __('Assign to commenting user'),
+ unassignUserText: __('Unassign from commenting user'),
},
components: {
GlButton,
GlIcon,
ReplyButton,
+ GlDropdown,
+ GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
@@ -39,6 +46,28 @@ export default {
required: false,
default: false,
},
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isAuthorAnAssignee: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showAssignUnassign: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assignUserActionText() {
+ return this.isAuthorAnAssignee
+ ? this.$options.i18n.unassignUserText
+ : this.$options.i18n.assignUserText;
+ },
},
methods: {
async setAwardEmoji(name) {
@@ -100,5 +129,39 @@ export default {
:aria-label="$options.i18n.editButtonText"
@click="$emit('startEditing')"
/>
+ <gl-dropdown
+ v-gl-tooltip
+ data-testid="work-item-note-actions"
+ icon="ellipsis_v"
+ text-sr-only
+ right
+ :text="$options.i18n.moreActionsText"
+ :title="$options.i18n.moreActionsText"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item
+ data-testid="copy-link-action"
+ :data-clipboard-text="noteUrl"
+ @click="$emit('notifyCopyDone')"
+ >
+ <span>{{ $options.i18n.copyLinkText }}</span>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showAssignUnassign"
+ data-testid="assign-note-action"
+ @click="$emit('assignUser')"
+ >
+ {{ assignUserActionText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showEdit"
+ variant="danger"
+ data-testid="delete-note-action"
+ @click="$emit('deleteNote')"
+ >
+ {{ $options.i18n.deleteNoteText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index db36b4e1bbe..3f5ff526e91 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -1,11 +1,12 @@
<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlAlert,
GlButton,
+ GlLink,
},
props: {
error: {
@@ -42,7 +43,10 @@ export default {
</script>
<template>
- <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4">
+ <div
+ id="tasks"
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
+ >
<div
class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base"
:class="{
@@ -50,9 +54,15 @@ export default {
}"
>
<div class="gl-display-flex gl-flex-grow-1">
- <h5 class="gl-m-0 gl-line-height-24">
+ <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24">
+ <gl-link
+ id="user-content-tasks-links"
+ class="anchor position-absolute gl-text-decoration-none"
+ href="#tasks"
+ aria-hidden="true"
+ />
<slot name="header"></slot>
- </h5>
+ </h3>
<slot name="header-suffix"></slot>
</div>
<slot name="header-right"></slot>
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 3c56b627673..0e0c6bca802 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -2,33 +2,53 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
GlModalDirective,
+ GlToggle,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import {
sprintfWorkItem,
I18N_WORK_ITEM_DELETE,
I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+ WIDGET_TYPE_NOTIFICATIONS,
} from '../constants';
+import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
export default {
i18n: {
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
+ notifications: s__('WorkItem|Notifications'),
+ notificationOn: s__('WorkItem|Notifications turned on.'),
+ notificationOff: s__('WorkItem|Notifications turned off.'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
+ GlToggle,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({ label: 'actions_menu' })],
+ isLoggedIn: isLoggedIn(),
+ notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ deleteActionTestId: TEST_ID_DELETE_ACTION,
props: {
workItemId: {
type: String,
@@ -60,8 +80,17 @@ export default {
required: false,
default: false,
},
+ subscribedToNotifications: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ initialSubscribed: this.subscribedToNotifications,
+ };
},
- emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
computed: {
i18n() {
return {
@@ -70,6 +99,16 @@ export default {
};
},
},
+ watch: {
+ subscribedToNotifications() {
+ /**
+ * To toggle the value if mutation fails, assign the
+ * subscribedToNotifications boolean value directly
+ * to data prop.
+ */
+ this.initialSubscribed = this.subscribedToNotifications;
+ },
+ },
methods: {
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
@@ -84,6 +123,56 @@ export default {
this.track('cancel_delete_work_item');
}
},
+ toggleNotifications(subscribed) {
+ const inputVariables = {
+ id: this.workItemId,
+ notificationsWidget: {
+ subscribed,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemNotificationsMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ id: this.workItemId,
+ widgets: [
+ {
+ type: WIDGET_TYPE_NOTIFICATIONS,
+ subscribed,
+ __typename: 'WorkItemWidgetNotifications',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ toast(
+ subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
+ );
+ },
+ )
+ .catch((error) => {
+ this.updateError = error.message;
+ this.$emit('error', error.message);
+ });
+ },
},
};
</script>
@@ -99,9 +188,27 @@ export default {
no-caret
right
>
+ <template v-if="$options.isLoggedIn">
+ <gl-dropdown-form
+ class="work-item-notifications-form"
+ :data-testid="$options.notificationsToggleFormTestId"
+ >
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ v-model="initialSubscribed"
+ :label="$options.i18n.notifications"
+ :data-testid="$options.notificationsToggleTestId"
+ label-position="left"
+ label-id="notifications-toggle"
+ @change="toggleNotifications($event)"
+ />
+ </div>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ </template>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
- data-testid="confidentiality-toggle-action"
+ :data-testid="$options.confidentialityTestId"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
@@ -114,7 +221,7 @@ export default {
<gl-dropdown-item
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
- data-testid="delete-action"
+ :data-testid="$options.deleteActionTestId"
variant="danger"
>{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
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 ddfaa376028..141dac9573c 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -55,7 +55,6 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
- descriptionHtml: '',
conflictedDescription: '',
formFieldProps: {
'aria-label': __('Description'),
@@ -81,12 +80,7 @@ export default {
},
result() {
if (this.isEditing) {
- if (this.descriptionText !== this.workItemDescription?.description) {
- this.conflictedDescription = this.workItemDescription?.description;
- }
- } else {
- this.descriptionText = this.workItemDescription?.description;
- this.descriptionHtml = this.workItemDescription?.descriptionHtml;
+ this.checkForConflicts();
}
},
error() {
@@ -148,6 +142,11 @@ export default {
},
},
methods: {
+ checkForConflicts() {
+ if (this.descriptionText !== this.workItemDescription?.description) {
+ this.conflictedDescription = this.workItemDescription?.description;
+ }
+ },
async startEditing() {
this.isEditing = true;
@@ -254,7 +253,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
enable-autocomplete
supports-quick-actions
- init-on-autofocus
+ autofocus
@input="setDescriptionText"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
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 ad7a54aaf16..06e8a65ecf7 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -26,6 +26,7 @@ import {
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_NOTIFICATIONS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
@@ -39,7 +40,7 @@ import {
WIDGET_TYPE_NOTES,
} from '../constants';
-import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
+import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
@@ -224,15 +225,18 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ canSetWorkItemMetadata() {
+ return this.workItem?.userPermissions?.setWorkItemMetadata;
+ },
+ canAssignUnassignUser() {
+ return this.workItemAssignees && this.canSetWorkItemMetadata;
+ },
confidentialTooltip() {
return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
},
fullPath() {
return this.workItem?.project.fullPath;
},
- workItemsMvcEnabled() {
- return this.glFeatures.workItemsMvc;
- },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -268,6 +272,9 @@ export default {
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
+ workItemNotificationsSubscribed() {
+ return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed);
+ },
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
@@ -534,14 +541,13 @@ export default {
{{ workItemBreadcrumbReference }}
</li>
</ul>
- <work-item-type-icon
- v-else-if="!error"
- :work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
- show-text
- class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
- data-testid="work-item-type"
- />
+ <div v-else-if="!error" class="gl-mr-auto" data-testid="work-item-type">
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
+ {{ workItemBreadcrumbReference }}
+ </div>
<gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
<gl-badge
v-if="workItem.confidential"
@@ -555,6 +561,7 @@ export default {
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
:can-delete="canDelete"
:can-update="canUpdate"
@@ -705,12 +712,17 @@ export default {
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
:query-variables="queryVariables"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
+ :is-modal="isModal"
+ :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
+ :can-set-work-item-metadata="canAssignUnassignUser"
class="gl-pt-5"
@error="updateError = $event"
+ @has-notes="updateHasNotes"
/>
<gl-empty-state
v-if="error"
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 730bdb4e7c7..51b957bb852 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
@@ -188,7 +188,7 @@ export default {
:work-item-parent-id="issueGid"
:work-item-id="displayedWorkItemId"
:work-item-iid="displayedWorkItemIid"
- class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
+ class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
index d119cdc2785..d1866110fd4 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -1,11 +1,13 @@
<script>
-import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
+import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
+import { isScopedLabel } from '~/lib/utils/common_utils';
import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
-
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import {
STATE_OPEN,
TASK_TYPE_NAME,
@@ -24,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
+ GlLabel,
GlLink,
GlButton,
GlIcon,
@@ -68,9 +71,18 @@ export default {
isExpanded: false,
children: [],
isLoadingChildren: false,
+ activeToast: null,
+ childrenBeforeRemoval: [],
+ hasChildren: false,
};
},
computed: {
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
@@ -113,9 +125,6 @@ export default {
this.childItem.iid
}?iid_path=true`;
},
- hasChildren() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren;
- },
chevronType() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
@@ -135,6 +144,17 @@ export default {
return false;
},
},
+ watch: {
+ childItem: {
+ handler(val) {
+ this.hasChildren = this.getWidgetByType(val, WIDGET_TYPE_HIERARCHY)?.hasChildren;
+ },
+ immediate: true,
+ },
+ children(val) {
+ this.hasChildren = val?.length > 0;
+ },
+ },
methods: {
toggleItem() {
this.isExpanded = !this.isExpanded;
@@ -166,6 +186,72 @@ export default {
this.isLoadingChildren = false;
}
},
+ showScopedLabel(label) {
+ return isScopedLabel(label) && this.allowsScopedLabels;
+ },
+ async removeChild(childId) {
+ this.cloneChildren();
+ this.isLoadingChildren = true;
+
+ try {
+ const { data } = await this.updateWorkItem(childId, null);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.filterRemovedChild(childId);
+
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, childId),
+ },
+ });
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while removing child.'), error);
+ Sentry.captureException(error);
+ this.restoreChildren();
+ } finally {
+ this.isLoadingChildren = false;
+ }
+ },
+ async undoChildRemoval(childId) {
+ this.isLoadingChildren = true;
+ try {
+ const { data } = await this.updateWorkItem(childId, this.childItem.id);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.activeToast?.hide();
+ this.restoreChildren();
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while undoing child removal.'), error);
+ Sentry.captureException(error);
+ } finally {
+ this.activeToast?.hide();
+ this.childrenBeforeRemoval = [];
+ this.isLoadingChildren = false;
+ }
+ },
+ async updateWorkItem(childId, parentId) {
+ return this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ });
+ },
+ cloneChildren() {
+ this.childrenBeforeRemoval = cloneDeep(this.children);
+ },
+ filterRemovedChild(childId) {
+ this.children = this.children.filter(({ id }) => id !== childId);
+ },
+ restoreChildren() {
+ this.children = [...this.childrenBeforeRemoval];
+ },
+ showAlert(message, error) {
+ createAlert({
+ message,
+ captureError: true,
+ error,
+ });
+ },
},
};
</script>
@@ -190,66 +276,72 @@ export default {
@click="toggleItem"
/>
<div
- class="work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-rounded-base"
- :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']"
+ class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base"
data-testid="links-child"
>
- <span
- :id="`stateIcon-${childItem.id}`"
- class="gl-cursor-help gl-mr-3 gl-line-height-32"
- :class="{ 'gl-display-flex': hasMetadata }"
- data-testid="item-status-icon"
- >
- <gl-icon
- class="gl-text-secondary"
- :class="iconClass"
- :name="iconName"
- :aria-label="stateTimestampTypeText"
- />
- </span>
- <div
- class="gl-display-flex gl-flex-grow-1"
- :class="{
- 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata,
- 'gl-align-items-center': !hasMetadata,
- }"
- >
- <div class="gl-display-flex">
- <rich-timestamp-tooltip
- :target="`stateIcon-${childItem.id}`"
- :raw-timestamp="stateTimestamp"
- :timestamp-type-text="stateTimestampTypeText"
+ <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
+ <div
+ class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
+ >
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <span
+ :id="`stateIcon-${childItem.id}`"
+ class="gl-cursor-help"
+ data-testid="item-status-icon"
+ >
+ <gl-icon
+ class="gl-text-secondary"
+ :class="iconClass"
+ :name="iconName"
+ :aria-label="stateTimestampTypeText"
+ />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <span v-if="childItem.confidential">
+ <gl-icon
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="__('Confidential')"
+ :title="__('Confidential')"
+ />
+ </span>
+ <gl-link
+ :href="childPath"
+ class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
+ data-testid="item-title"
+ @click="$emit('click', $event)"
+ @mouseover="$emit('mouseover')"
+ @mouseout="$emit('mouseout')"
+ >
+ {{ childItem.title }}
+ </gl-link>
+ </div>
+ <work-item-link-child-metadata
+ v-if="hasMetadata"
+ :metadata-widgets="metadataWidgets"
+ class="gl-ml-6 ml-xl-0"
/>
- <gl-icon
- v-if="childItem.confidential"
- v-gl-tooltip.top
- name="eye-slash"
- class="gl-mr-2 gl-text-orange-500"
- data-testid="confidential-icon"
- :aria-label="__('Confidential')"
- :title="__('Confidential')"
+ </div>
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
+ <gl-label
+ v-for="label in labels"
+ :key="label.id"
+ :title="label.title"
+ :background-color="label.color"
+ :description="label.description"
+ :scoped="showScopedLabel(label)"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
+ tooltip-placement="top"
/>
- <gl-link
- :href="childPath"
- class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold"
- data-testid="item-title"
- @click="$emit('click', $event)"
- @mouseover="$emit('mouseover')"
- @mouseout="$emit('mouseout')"
- >
- {{ childItem.title }}
- </gl-link>
</div>
- <work-item-link-child-metadata
- v-if="hasMetadata"
- :metadata-widgets="metadataWidgets"
- class="gl-mt-1"
- />
</div>
- <div
- v-if="canUpdate"
- class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"
- >
+ <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
<work-item-links-menu
:work-item-id="childItem.id"
:parent-work-item-id="issuableGid"
@@ -266,7 +358,7 @@ export default {
:work-item-id="issuableGid"
:work-item-type="workItemType"
:children="children"
- @removeChild="fetchChildren"
+ @removeChild="removeChild"
@click="$emit('click', $event)"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 80802cb3858..ddeac2b92ae 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -1,16 +1,14 @@
<script>
-import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
-import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants';
+import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants';
export default {
components: {
- GlLabel,
GlAvatar,
GlAvatarLink,
GlAvatarsInline,
@@ -33,12 +31,6 @@ export default {
assignees() {
return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || [];
},
- labels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
- },
- allowsScopedLabels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
- },
assigneesCollapsedTooltip() {
if (this.assignees.length > 2) {
return sprintf(s__('WorkItem|%{count} more assignees'), {
@@ -56,21 +48,16 @@ export default {
return '';
},
},
- methods: {
- showScopedLabel(label) {
- return isScopedLabel(label) && this.allowsScopedLabels;
- },
- },
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center">
+ <div class="gl-display-flex gl-md-justify-content-end gl-gap-3">
<slot></slot>
<item-milestone
v-if="milestone"
:milestone="milestone"
- class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
+ class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
/>
<gl-avatars-inline
v-if="assignees.length"
@@ -81,7 +68,6 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
- class="gl-mr-5"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
@@ -89,18 +75,6 @@ export default {
</gl-avatar-link>
</template>
</gl-avatars-inline>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap">
- <gl-label
- v-for="label in labels"
- :key="label.id"
- :title="label.title"
- :background-color="label.color"
- :description="label.description"
- :scoped="showScopedLabel(label)"
- class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
- tooltip-placement="top"
- />
- </div>
</div>
</template>
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
index fb3ed7af736..53e8eedf060 100644
--- 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
@@ -11,8 +11,13 @@ export default {
</script>
<template>
- <span class="gl-ml-5">
- <gl-dropdown category="tertiary" toggle-class="btn-icon btn-sm" :right="true">
+ <div class="gl-ml-5">
+ <gl-dropdown
+ category="tertiary"
+ toggle-class="btn-icon btn-sm"
+ :right="true"
+ data-testid="work_items_links_menu"
+ >
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
@@ -20,5 +25,5 @@ export default {
{{ s__('WorkItem|Remove') }}
</gl-dropdown-item>
</gl-dropdown>
- </span>
+ </div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 97eaf2c0422..b72de98199e 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -186,7 +186,7 @@ export default {
</template>
<template #body>
<div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty">
- <p class="gl-mb-3">
+ <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500">
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
</p>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
index e233a2219fa..ba5c0794395 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
@@ -1,9 +1,4 @@
<script>
-import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
-
-import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-
export default {
components: {
WorkItemLinkChild: () => import('./work_item_link_child.vue'),
@@ -32,28 +27,11 @@ export default {
required: true,
},
},
- methods: {
- async updateWorkItem(childId) {
- try {
- await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
- });
- this.$emit('removeChild');
- } catch (error) {
- createAlert({
- message: s__('Hierarchy|Something went wrong while removing a child item.'),
- captureError: true,
- error,
- });
- }
- },
- },
};
</script>
<template>
- <div class="gl-ml-6">
+ <div class="gl-ml-6" data-testid="tree-children">
<work-item-link-child
v-for="child in children"
:key="child.id"
@@ -62,7 +40,7 @@ export default {
:issuable-gid="workItemId"
:child-item="child"
:work-item-type="workItemType"
- @removeChild="updateWorkItem"
+ @removeChild="$emit('removeChild', child.id)"
@click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 4ca8054fa5f..00cdc224320 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -1,6 +1,7 @@
<script>
import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants';
@@ -14,7 +15,11 @@ import {
WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
} from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
-import { getWorkItemNotesQuery } from '~/work_items/utils';
+import {
+ getWorkItemNotesQuery,
+ autocompleteDataSources,
+ markdownPreviewPath,
+} from '~/work_items/utils';
import {
updateCacheAfterCreatingNote,
updateCacheAfterDeletingNote,
@@ -48,6 +53,10 @@ export default {
type: String,
required: true,
},
+ workItemIid: {
+ type: String,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
@@ -70,6 +79,16 @@ export default {
required: false,
default: false,
},
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -78,6 +97,7 @@ export default {
sortOrder: ASC,
noteToDelete: null,
discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`),
};
},
computed: {
@@ -102,6 +122,12 @@ export default {
formAtTop() {
return this.sortOrder === DESC;
},
+ markdownPreviewPath() {
+ return markdownPreviewPath(this.fullPath, this.workItemIid);
+ },
+ autocompleteDataSources() {
+ return autocompleteDataSources(this.fullPath, this.workItemIid);
+ },
workItemCommentFormProps() {
return {
queryVariables: this.queryVariables,
@@ -110,6 +136,9 @@ export default {
fetchByIid: this.fetchByIid,
workItemType: this.workItemType,
sortOrder: this.sortOrder,
+ isNewDiscussion: true,
+ markdownPreviewPath: this.markdownPreviewPath,
+ autocompleteDataSources: this.autocompleteDataSources,
};
},
notesArray() {
@@ -252,6 +281,9 @@ export default {
filterDiscussions(filterValue) {
this.discussionFilter = filterValue;
},
+ updateKey() {
+ this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`);
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
// copied from discussions batch logic - every fetchMore call has a higher
@@ -335,12 +367,17 @@ export default {
</div>
<div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
<template v-if="!initialLoading">
- <ul class="notes main-notes-list timeline gl-clearfix!">
- <work-item-add-note
- v-if="formAtTop && !commentsDisabled"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
- />
+ <div v-if="formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
+ <ul class="notes main-notes-list timeline">
<template v-for="discussion in notesArray">
<system-note
v-if="isSystemNote(discussion)"
@@ -357,23 +394,31 @@ export default {
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
@deleteNote="showDeleteNoteModal($event, discussion)"
@error="$emit('error', $event)"
/>
</template>
</template>
- <work-item-add-note
- v-if="!formAtTop && !commentsDisabled"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
- />
-
<work-item-history-only-filter-note
v-if="commentsDisabled"
@changeFilter="filterDiscussions"
/>
</ul>
+ <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
</template>
<template v-if="showLoadingMoreSkeleton">
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index bbcf78e23aa..6af4f0fe790 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
@@ -205,3 +206,8 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [
{ key: DESC, text: __('Newest first'), testid: 'newest-first' },
{ key: ASC, text: __('Oldest first') },
];
+
+export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form';
+export const TEST_ID_DELETE_ACTION = 'delete-action';
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index fda71fabe22..40fb0fbc91d 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -15,6 +15,10 @@ extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
+extend type WorkItemPermissions {
+ setWorkItemMetadata: Boolean
+}
+
input LocalUserInput {
id: ID!
name: String
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
new file mode 100644
index 00000000000..f8952b62f28
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -0,0 +1,13 @@
+mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ workItem {
+ id
+ widgets {
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ }
+ }
+ }
+}
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 ada9f737e6e..86640a6d994 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -27,6 +27,7 @@ fragment WorkItem on WorkItem {
userPermissions {
deleteWorkItem
updateWorkItem
+ setWorkItemMetadata @client
}
widgets {
...WorkItemWidgets
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index b5d27231bef..44fda3ee894 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
+
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index bf8eafe3211..8039ef53f98 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 95709b36594..7c47e72e170 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -18,7 +18,7 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
- savedRepliesNewPath,
+ newCommentTemplatePath,
} = el.dataset;
return new Vue({
@@ -36,7 +36,7 @@ export const initWorkItemsRoot = () => {
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
- newSavedRepliesPath: savedRepliesNewPath,
+ newCommentTemplatePath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 1aa3baca165..ccb9d05bc90 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
// Zen Mode (full screen) textarea
//
/*= provides zen_mode:enter */
@@ -55,6 +53,7 @@ export default class ZenMode {
$(document).on('zen_mode:leave', () => {
this.exit();
});
+ // eslint-disable-next-line consistent-return
$(document).on('keydown', (e) => {
// Esc
if (e.keyCode === 27) {
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 1a998f89c68..483c4dc226b 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,12 +1,10 @@
@import './pages/colors';
@import './pages/commits';
-@import './pages/detail_page';
@import './pages/events';
@import './pages/groups';
@import './pages/hierarchy';
@import './pages/issues';
@import './pages/labels';
-@import './pages/login';
@import './pages/merge_requests';
@import './pages/note_form';
@import './pages/notes';
@@ -15,4 +13,3 @@
@import './pages/projects';
@import './pages/registry';
@import './pages/settings';
-@import './pages/storage_quota';
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss
index 7736f1012a5..de8142924f9 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/components/detail_page.scss
@@ -1,4 +1,5 @@
.detail-page-header {
+ padding-top: $gl-spacing-scale-4;
color: $gl-text-color;
line-height: 34px;
display: flex;
@@ -59,7 +60,7 @@
.detail-page-description {
.title {
- margin: 0 0 16px;
+ margin: 0 0 $gl-spacing-scale-4;
color: $gl-text-color;
padding: 0 0 0.3em;
border-bottom: 1px solid $white-dark;
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 0b30b4c3ef0..04a7590d531 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -55,16 +55,16 @@ $item-remove-button-space: 42px;
.item-weight .board-card-info-icon {
min-width: $gl-padding;
cursor: help;
+
+ &:focus {
+ @include gl-focus;
+ }
}
.confidential-icon {
color: $orange-500;
}
- .item-title-wrapper {
- max-width: calc(100% - #{$item-remove-button-space});
- }
-
.item-title {
flex-basis: 100%;
font-size: $gl-font-size-small;
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index c1c68f64d86..35c619a2e2f 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,5 +1,5 @@
.whats-new-drawer {
- margin-top: $header-height;
+ margin-top: calc(#{$header-height} + #{$calc-application-bars-height});
@include gl-shadow-none;
overflow-y: hidden;
width: 500px;
@@ -35,18 +35,6 @@
}
}
-.with-performance-bar .whats-new-drawer {
- margin-top: calc(#{$performance-bar-height} + #{$header-height});
-}
-
-.with-system-header .whats-new-drawer {
- margin-top: calc(#{$system-header-height} + #{$header-height});
-}
-
-.with-performance-bar.with-system-header .whats-new-drawer {
- margin-top: calc(#{$performance-bar-height} + #{$system-header-height} + #{$header-height});
-}
-
.whats-new-item-title-link {
&:hover,
&:focus,
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 799777977ed..cbdc55d66c1 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -29,10 +29,6 @@
border-bottom: 1px solid $white-dark;
color: $gl-text-color;
- &.oneline-block {
- line-height: 42px;
- }
-
&.white {
background-color: $white;
}
@@ -89,10 +85,6 @@
padding: $gl-padding 0;
border-bottom: 1px solid $white-dark;
- &.oneline-block {
- line-height: 36px;
- }
-
> .controls {
float: right;
}
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 27e9a041145..65e378a79f3 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -20,56 +20,3 @@
}
}
}
-
-.pika-single.gitlab-theme {
- .pika-label {
- color: $gl-text-color-secondary;
- font-size: 14px;
- font-weight: $gl-font-weight-normal;
- }
-
- th {
- padding: 2px 0;
- color: $note-disabled-comment-color;
- font-weight: $gl-font-weight-normal;
- text-transform: lowercase;
- border-top: 1px solid $calendar-border-color;
- }
-
- abbr {
- cursor: default;
- }
-
- td {
- border: 1px solid $calendar-border-color;
-
- &:first-child {
- border-left: 0;
- }
-
- &:last-child {
- border-right: 0;
- }
- }
-
- .pika-day {
- border-radius: 0;
- background-color: $white;
- text-align: center;
- }
-
- .is-today {
- .pika-day {
- color: inherit;
- font-weight: $gl-font-weight-normal;
- }
- }
-
- .is-selected .pika-day,
- .pika-day:hover,
- .is-today .pika-day {
- background: $gray-darker;
- color: $gl-text-color;
- box-shadow: none;
- }
-}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index cc7a45e1c82..d033a076832 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -1,3 +1,32 @@
+// stylelint-disable length-zero-no-unit
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+
+.with-performance-bar {
+ --performance-bar-height: #{$performance-bar-height};
+}
+
+.with-system-header {
+ --system-header-height: #{$system-header-height};
+}
+
+.with-top-bar {
+ --top-bar-height: #{$top-bar-height};
+}
+
+.with-system-footer {
+ --system-footer-height: #{$system-footer-height};
+}
+
+.review-bar-visible {
+ --mr-review-bar-height: #{$mr-review-bar-height};
+}
+
/** COLORS **/
.cgray { color: $gl-text-color; }
.clgray { color: $gray-200; }
@@ -253,12 +282,6 @@ li.note {
}
}
-img.emoji {
- height: 16px;
- vertical-align: top;
- width: 20px;
-}
-
.chart {
overflow: hidden;
height: 220px;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 1e05441c731..fb9816d1402 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -229,13 +229,13 @@
//
.nav-sidebar {
- @include gl-fixed;
- @include gl-bottom-0;
- @include gl-left-0;
+ position: fixed;
+ bottom: $calc-application-footer-height;
+ left: 0;
transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium;
z-index: 600;
width: $contextual-sidebar-width;
- top: $header-height;
+ top: $calc-application-header-height;
background-color: $contextual-sidebar-bg-color;
border-right: 1px solid $contextual-sidebar-border-color;
transform: translate3d(0, 0, 0);
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index cd0ea84cff4..ad09740583b 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -32,30 +32,12 @@
}
@media (min-width: map-get($grid-breakpoints, md)) {
- // The `+11` is to ensure the file header border shows when scrolled -
- // the bottom of the compare-versions header and the top of the file header
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
- --top: var(--initial-top);
-
- position: -webkit-sticky;
position: sticky;
- top: var(--top);
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height});
z-index: 120;
&.is-sidebar-moved {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px});
- }
-
- .with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar & {
- --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
- }
-
- .with-performance-bar & {
- top: calc(var(--initial-top) + #{$performance-bar-height});
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + 24px);
}
&::before {
@@ -70,19 +52,11 @@
}
&.is-commit {
- top: calc(#{$header-height} + #{$commit-stat-summary-height});
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$commit-stat-summary-height} + #{$performance-bar-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$commit-stat-summary-height});
}
&.is-compare {
- top: calc(#{$header-height} + #{$compare-branches-sticky-header-height});
-
- .with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$compare-branches-sticky-header-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$compare-branches-sticky-header-height});
}
}
@@ -99,22 +73,7 @@
@media (min-width: map-get($grid-breakpoints, md)) {
&.conflict .file-title,
&.conflict .file-title-flex-parent {
- top: $header-height;
- }
-
- .with-performance-bar &.conflict .file-title,
- .with-performance-bar &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-
- .with-system-header &.conflict .file-title,
- .with-system-header &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar &.conflict .file-title,
- .with-system-header.with-performance-bar &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
+ top: $calc-application-header-height;
}
}
@@ -733,13 +692,9 @@ table.code {
@include media-breakpoint-up(sm) {
@include gl-sticky;
- top: $header-height;
+ top: $calc-application-header-height;
z-index: 200;
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-
&.is-stuck {
@include gl-py-0;
border-top: 1px solid $white-dark;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index b292adf9eac..e4025eb8b8d 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -2,14 +2,6 @@
* File content holder
*
*/
-.container-fluid.container-limited.limit-container-width {
- .file-holder.readme-holder.limited-width-container .file-content {
- max-width: $limited-layout-width;
- margin-left: auto;
- margin-right: auto;
- }
-}
-
.file-holder {
border: 1px solid $border-color;
border-top: 0;
@@ -484,18 +476,24 @@ span.idiff {
@include gl-display-none;
}
-.tree-list-scroll:not(.tree-list-blobs) {
+.mr-tree-list:not(.tree-list-blobs) {
.tree-list-parent::before {
@include gl-content-empty;
@include gl-absolute;
@include gl-z-index-1;
@include gl-pointer-events-none;
- top: 28px;
- left: calc(14px + (var(--level) * 16px));
- width: 1px;
- height: calc(100% - 24px);
- background-color: var(--gray-100, $gray-100);
+ top: -4px;
+ left: 0;
+ width: 100%;
+ bottom: -4px;
+ // The virtual scroller has a flat HTML structure so instead of the ::before
+ // element stretching over multiple rows we instead create a repeating background image
+ // for the line
+ background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px);
+ background-size: calc(var(--level) * 14px) 100%;
+ background-repeat: no-repeat;
+ background-position: 14px;
}
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 16c0a67f137..104cdf5544d 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -87,8 +87,8 @@
.filtered-search-term {
display: flex;
flex-shrink: 0;
- margin-top: 4px;
- margin-bottom: 4px;
+ margin-top: 2px;
+ margin-bottom: 2px;
.selectable {
display: flex;
@@ -195,7 +195,7 @@
display: flex;
width: 100%;
min-width: 0;
- border: 1px solid $border-color;
+ border: 1px solid $gray-400;
background-color: $white;
border-radius: $border-radius-default;
@@ -206,8 +206,7 @@
&.focus,
&.focus:hover {
- border-color: $blue-300;
- box-shadow: 0 0 4px $dropdown-input-focus-shadow;
+ @include gl-focus;
}
gl-emoji {
@@ -227,7 +226,7 @@
min-width: 200px;
padding-right: 25px;
padding-left: 0;
- height: $input-height;
+ height: #{$input-height - 2px};
line-height: inherit;
&,
@@ -261,7 +260,7 @@
flex: 1;
position: relative;
min-width: 0;
- height: 2rem;
+ height: #{$input-height - 2px};
background-color: $input-bg;
border-radius: $border-radius-default;
}
@@ -292,10 +291,11 @@
}
.filtered-search-history-dropdown-toggle-button.gl-button {
- border-radius: $border-radius-default 0 0 $border-radius-default;
- border-right: 1px solid $border-color;
- box-shadow: none;
+ $inner-border: #{$border-radius-default - 1px};
+ border-radius: $inner-border 0 0 $inner-border;
color: $gl-text-color-secondary;
+ margin: -1px 0 -1px -1px;
+ box-shadow: inset 0 0 0 1px $gray-400;
flex: 1;
transition: color 0.1s linear;
width: auto;
@@ -303,7 +303,6 @@
&:hover,
&:focus {
color: $gl-text-color;
- border-color: $border-color;
}
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index b63365e8159..6b4f1478978 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -9,7 +9,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
&.sticky {
position: sticky;
- top: $flash-container-top;
+ top: $calc-application-header-height;
z-index: 251;
.flash-alert,
@@ -114,17 +114,3 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
left: -50%;
}
}
-
-.with-system-header .flash-container.sticky {
- top: $flash-container-top + $system-header-height;
-}
-
-.with-performance-bar {
- .flash-container.sticky {
- top: $flash-container-top + $performance-bar-height;
- }
-
- &.with-system-header .flash-container.sticky {
- top: $flash-container-top + $performance-bar-height + $system-header-height;
- }
-}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index d1231da83d4..a5ff3c9c980 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px;
min-height: $header-height;
border: 0;
position: fixed;
- top: 0;
+ top: $calc-application-bars-height;
left: 0;
right: 0;
border-radius: 0;
@@ -312,21 +312,52 @@ $search-input-field-x-min-width: 200px;
margin-top: $dropdown-vertical-offset;
}
-.breadcrumbs {
- display: flex;
- min-height: $breadcrumb-min-height;
- color: $gl-text-color;
+.top-bar-container {
+ min-height: $top-bar-height;
}
-.breadcrumbs-container {
- display: flex;
- width: 100%;
- padding-top: $gl-padding / 2;
- padding-bottom: $gl-padding / 2;
- align-items: center;
+.top-bar-fixed {
+ background-color: $body-bg;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: $calc-application-bars-height;
+ width: auto;
+ z-index: $top-bar-z-index;
+ @include gl-inset-border-b-1-gray-100;
+
+ .breadcrumbs-list {
+ @include media-breakpoint-down(xs) {
+ flex-wrap: nowrap;
+ }
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium;
+ }
+
+ @include media-breakpoint-up(md) {
+ .right-sidebar-collapsed & {
+ right: $gutter-collapsed-width;
+ }
+
+ .right-sidebar-expanded & {
+ right: $gutter-width;
+ }
+ }
+
+ @include media-breakpoint-up(xl) {
+ .page-with-super-sidebar & {
+ left: $super-sidebar-width;
+ }
+
+ .page-with-super-sidebar-collapsed & {
+ left: 0;
+ }
+ }
}
-.breadcrumbs-links {
+.breadcrumbs {
flex: 1;
min-width: 0;
align-self: center;
@@ -348,16 +379,6 @@ $search-input-field-x-min-width: 200px;
top: 1px;
}
}
-
- .dropdown-menu li a .identicon {
- width: 17px;
- height: 17px;
- font-size: $gl-font-size-xs;
- vertical-align: middle;
- text-indent: 0;
- line-height: $gl-font-size-xs + 2px;
- display: inline-block;
- }
}
.breadcrumbs-list {
@@ -498,11 +519,6 @@ $search-input-field-x-min-width: 200px;
visibility: visible;
}
-.with-performance-bar .navbar-gitlab,
-.with-performance-bar .fixed-top {
- top: $performance-bar-height;
-}
-
.navbar-empty {
justify-content: center;
height: $header-height;
@@ -558,7 +574,7 @@ $search-input-field-x-min-width: 200px;
@include media-breakpoint-down(sm) {
@include gl-display-block;
- + .breadcrumbs-links {
+ + .breadcrumbs {
@include gl-pl-4;
@include gl-border-l-1;
@include gl-border-l-solid;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index f27a36d1966..37a2264122d 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -74,7 +74,6 @@
}
.user-avatar-link {
- display: inline-block;
text-decoration: none;
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 7a92adf7b7b..23dbe440d33 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -35,8 +35,9 @@ body {
}
}
-.content-wrapper-margin {
- margin-top: $header-height;
+.layout-page {
+ padding-top: $calc-application-header-height;
+ padding-bottom: $calc-application-footer-height;
}
.content-wrapper {
@@ -142,11 +143,6 @@ body {
@include gl-overflow-hidden;
}
-
-.with-performance-bar .layout-page {
- margin-top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
.fullscreen-layout {
padding-top: 0;
height: 100vh;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index c40cadafb9c..48aacc9606e 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -120,9 +120,10 @@
}
.referenced-commands {
+ $radius: $border-radius-default - 1px;
background: $blue-50;
padding: $gl-padding-8 $gl-padding;
- border-radius: $border-radius-default;
+ border-radius: 0 0 $radius $radius;
p {
margin: 0;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index b20ec1dc50a..aefac300839 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -223,18 +223,6 @@
}
/*
-* Mixin that handles the position of sticky alerts at the top. It accounts for the performance bar
-*/
-// stylelint-disable-next-line length-zero-no-unit
-@mixin sticky-top-positioning($extra: 0px) {
- top: calc(#{$header-height} + #{$extra});
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$extra});
- }
-}
-
-/*
* Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs)
*/
@mixin build-log($background: $black) {
@@ -268,14 +256,8 @@
@mixin build-log-top-bar($height) {
@include build-log-bar($height);
-
- position: -webkit-sticky;
position: sticky;
- top: $header-height;
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
+ top: $calc-application-header-height;
}
/*
diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss
index f11864f14af..84a34f12649 100644
--- a/app/assets/stylesheets/framework/page_title.scss
+++ b/app/assets/stylesheets/framework/page_title.scss
@@ -1,6 +1,6 @@
.page-title-holder {
.page-title {
- margin: $gl-padding 0;
+ margin: $gl-spacing-scale-4 0;
color: $gl-text-color;
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 7c3e346f4e6..946f2b28859 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -83,7 +83,7 @@
}
.right-sidebar {
- border-left: 1px solid $gray-100;
+ border-left: 1px solid $gray-50;
&.right-sidebar-merge-requests {
@include media-breakpoint-up(lg) {
@@ -271,6 +271,24 @@
}
}
+.merge-request-approved-icon {
+ animation: approval-animate 350ms ease-in;
+}
+
+@include keyframes(approval-animate) {
+ 0% {
+ transform: scale(0);
+ }
+
+ 75% {
+ transform: scale(1.4);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
.assignee-grid,
.reviewer-grid {
[data-css-area='attention'] {
@@ -288,21 +306,22 @@
@mixin right-sidebar {
position: fixed;
- top: $header-height;
- // Default value for CSS var must contain a unit
- // stylelint-disable-next-line length-zero-no-unit
- bottom: var(--review-bar-height, 0px);
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+ bottom: calc(#{$calc-application-footer-height} + var(--mr-review-bar-height));
right: 0;
transition: width $gl-transition-duration-medium;
background-color: $white;
z-index: 200;
overflow: hidden;
-
}
.right-sidebar {
&:not(.right-sidebar-merge-requests) {
@include right-sidebar;
+
+ @include media-breakpoint-down(sm) {
+ z-index: 251;
+ }
}
&.right-sidebar-merge-requests {
@@ -312,10 +331,6 @@
}
}
- @include media-breakpoint-down(sm) {
- z-index: 251;
- }
-
a:not(.btn) {
color: inherit;
@@ -469,28 +484,14 @@
padding: 0;
.issuable-context-form {
- --initial-top: calc(#{$header-height} + 76px);
- --top: var(--initial-top);
-
- @include gl-sticky;
- @include gl-overflow-auto;
+ $issue-sticky-header-height: 76px;
- top: var(--top);
- height: calc(100vh - var(--top));
+ top: calc(#{$calc-application-header-height} + #{$issue-sticky-header-height});
+ height: calc(#{$calc-application-viewport-height} - #{$issue-sticky-header-height} - var(--mr-review-bar-height));
+ position: sticky;
+ overflow: auto;
padding: 0 15px;
- margin-bottom: calc(var(--top) * -1);
-
- .with-performance-bar & {
- --top: calc(var(--initial-top) + #{$performance-bar-height});
- }
-
- .with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height});
- }
-
- .with-performance-bar.with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
- }
+ margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1);
}
}
}
@@ -742,10 +743,6 @@
}
}
-.with-performance-bar .right-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
.issuable-show-labels {
.gl-label {
margin-bottom: 5px;
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
index f9e95d16f63..91781bfe539 100644
--- a/app/assets/stylesheets/framework/sortable.scss
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -51,3 +51,12 @@
cursor: no-drop !important;
}
}
+
+.tree-item.is-dragging {
+ border-top: 0;
+
+ .item-body {
+ background-color: $white;
+ border: 2px solid $gray-200;
+ }
+}
diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss
index 046b8636f65..f1ee4c94942 100644
--- a/app/assets/stylesheets/framework/source_editor.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -41,6 +41,29 @@
}
.monaco-editor.gl-source-editor {
+ // Fix unreadable headings in tooltips for syntax highlighting themes that don't match general theme
+ &.vs-dark .markdown-hover {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ color: $source-editor-hover-light-text-color;
+ }
+ }
+
+ &.vs .markdown-hover {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ color: $source-editor-hover-dark-text-color;
+ }
+ }
+
.margin-view-overlays {
.line-numbers {
@include gl-display-flex;
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index 6b339f857cb..14eec335169 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -27,8 +27,8 @@
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+ bottom: $calc-application-footer-height;
left: 0;
background-color: var(--gray-10, $gray-10);
border-right: 1px solid $t-gray-a-08;
@@ -36,10 +36,6 @@
width: $super-sidebar-width;
z-index: $super-sidebar-z-index;
- &:focus {
- @include gl-focus;
- }
-
&.super-sidebar-loading {
transform: translate3d(-100%, 0, 0);
@@ -49,7 +45,9 @@
}
&:not(.super-sidebar-loading) {
- transition: transform $gl-transition-duration-medium;
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform $gl-transition-duration-medium;
+ }
}
.user-bar {
@@ -78,8 +76,9 @@
}
}
- .counter .gl-icon {
- color: var(--gray-500, $gray-500);
+ .counter .gl-icon,
+ .item-icon {
+ color: var(--gray-600, $gray-500);
}
.counter:hover,
@@ -107,6 +106,9 @@
&[aria-expanded='true'] {
background-color: $t-gray-a-08;
}
+
+ &:focus {
+ @include gl-focus($inset: true); }
}
.btn-with-notification {
@@ -141,6 +143,29 @@
@include active-toggle;
}
}
+
+ .nav-item-link {
+ button,
+ .draggable-icon {
+ opacity: 0;
+ }
+
+ .draggable-icon {
+ cursor: grab;
+ }
+
+ &:hover {
+ button,
+ .draggable-icon {
+ opacity: 1;
+ }
+ }
+
+ &:focus button,
+ button:focus {
+ opacity: 1;
+ }
+ }
}
.super-sidebar-skip-to {
@@ -151,9 +176,25 @@
display: none;
}
+.super-sidebar-peek {
+ @include gl-shadow;
+ border-right: 0;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform 100ms !important;
+ }
+}
+
+.super-sidebar-hover-area {
+ z-index: $super-sidebar-z-index;
+}
+
.page-with-super-sidebar {
padding-left: 0;
- transition: padding-left $gl-transition-duration-medium;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: padding-left $gl-transition-duration-medium;
+ }
&:not(.page-with-super-sidebar-collapsed) {
.super-sidebar-overlay {
@@ -184,6 +225,10 @@
.page-with-super-sidebar-collapsed {
.super-sidebar {
transform: translate3d(-100%, 0, 0);
+
+ &.super-sidebar-peek {
+ transform: translate3d(0, 0, 0);
+ }
}
@include media-breakpoint-up(xl) {
@@ -195,19 +240,6 @@
}
}
-.container-limited .super-sidebar-toggle {
- @media (min-width: $super-sidebar-toggle-position-breakpoint) {
- position: absolute;
- left: $gl-spacing-scale-3;
- top: $gl-spacing-scale-3;
- margin: 0;
- }
-}
-
-.with-performance-bar .super-sidebar {
- top: $performance-bar-height;
-}
-
.gl-dark {
.super-sidebar {
.gl-new-dropdown-custom-toggle {
@@ -217,3 +249,38 @@
}
}
}
+
+.global-search-modal {
+ padding: 3rem 0.5rem 0;
+
+ &.gl-modal .modal-dialog {
+ align-items: flex-start;
+ }
+
+ @include gl-media-breakpoint-up(sm) {
+ padding: 5rem 1rem 0;
+ }
+
+ // 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;
+ }
+
+ .is-searching {
+ .in-search-scope-help {
+ position: absolute;
+ top: 0.625rem;
+ right: 2.5rem;
+ }
+ }
+
+ .gl-search-box-by-type-input-borderless {
+ @include gl-rounded-base;
+ }
+
+ .global-search-results {
+ max-height: 30rem;
+ }
+}
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 590a66ff28e..946a241e6dd 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -36,57 +36,8 @@
}
}
-// System Header
-.with-system-header {
- // main navigation
- // login page
- .navbar-gitlab,
- .fixed-top {
- top: $system-header-height;
- }
-
- // left sidebar eg: project page
- // right sidebar eg: MR page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar {
- top: calc(#{$system-header-height} + #{$header-height});
- }
-
- .content-wrapper-margin {
- margin-top: calc(#{$system-header-height} + #{$header-height});
- }
-
- // Performance Bar
- // System Header
- &.with-performance-bar {
- // main navigation
- header.navbar-gitlab,
- .fixed-top {
- top: $performance-bar-height + $system-header-height;
- }
-
- .layout-page {
- margin-top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
- }
-
- // left sidebar eg: project page
- // right sidebar eg: MR page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
- }
- }
-}
-
// System Footer
.with-system-footer {
- // left sidebar eg: project page
- // right sidebar eg: mr page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar,
// navless pages' footer eg: login page
// navless pages' footer border eg: login page
&.devise-layout-html body .footer-container,
@@ -94,13 +45,9 @@
bottom: $system-footer-height;
}
- .content-wrapper-margin {
- margin-bottom: 16px;
- }
-
.boards-list,
.board-swimlanes {
- height: calc(100vh - (#{$header-height} + #{$breadcrumb-min-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32}));
+ height: calc(100vh - (#{$header-height} + #{$top-bar-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32}));
}
}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index a288701595e..b28a93749d1 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -160,3 +160,7 @@ table {
border-top: 0;
}
}
+
+.gl-table-no-top-border th {
+ border-top: 0;
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8edf5fc834a..88f990d2320 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -632,7 +632,7 @@ body {
}
.page-title {
- margin: 0 0 #{2 * $grid-size};
+ margin: $gl-spacing-scale-4 0;
line-height: 1.3;
&.with-button {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 0bc2e0583bb..dba9cafbd71 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -11,9 +11,9 @@ $contextual-sidebar-width: 256px;
$contextual-sidebar-collapsed-width: 56px;
$toggle-sidebar-height: 48px;
$super-sidebar-width: 256px;
-$super-sidebar-toggle-position-breakpoint: 1360px;
$super-sidebar-z-index: 600;
$super-sidebar-overlay-z-index: 599;
+$top-bar-z-index: 210;
/**
🚨 Do not use this spacing scale — it is deprecated and being removed. 🚨
@@ -390,7 +390,6 @@ $nav-active-bg: $t-gray-a-08;
* Text
*/
$gl-font-size: 14px;
-$gl-font-size-xs: 11px;
$gl-font-size-small: 12px;
$gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
@@ -481,10 +480,10 @@ $highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
$system-header-height: 16px;
$system-footer-height: $system-header-height;
+$mr-review-bar-height: calc(2rem + 13px);
$flash-height: 52px;
-$flash-container-top: 48px;
$context-header-height: 60px;
-$breadcrumb-min-height: 48px;
+$top-bar-height: 48px;
$home-panel-title-row-height: 64px;
$home-panel-avatar-mobile-size: 24px;
$issuable-title-max-width: 350px;
@@ -498,6 +497,14 @@ $gl-line-height-14: 14px;
$pages-group-name-color: #4c4e54;
/*
+ * Calculated heights
+ */
+$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height));
+$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height));
+$calc-application-footer-height: var(--system-footer-height);
+$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height});
+
+/*
* Common component specific colors
*/
$user-mention-bg: rgba($blue-500, 0.044);
@@ -669,8 +676,6 @@ $note-targe3-inside: #ffffd3;
/*
* Calendar
*/
-$calendar-hover-bg: #ecf3fe;
-$calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
@@ -688,7 +693,7 @@ $issue-boards-filter-height: 68px;
The following heights are used in environment_logs.scss and are used for calculation of the log viewer height.
*/
$environment-logs-breadcrumbs-height: 63px;
-$environment-logs-breadcrumbs-height-md: $breadcrumb-min-height;
+$environment-logs-breadcrumbs-height-md: $top-bar-height;
$environment-logs-difference-xs-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height});
$environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height-md});
@@ -734,7 +739,7 @@ $calendar-activity-colors: (
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
-$commit-stat-summary-height: 36px;
+$commit-stat-summary-height: 32px;
/*
* Files
@@ -908,13 +913,19 @@ $mr-tabs-height: 48px;
/*
Compare Branches
*/
-$compare-branches-sticky-header-height: 68px;
+$compare-branches-sticky-header-height: 32px;
/*
Board Swimlanes
*/
$board-swimlanes-headers-height: 64px;
+/*
+Source Editor theme overrides
+*/
+$source-editor-hover-light-text-color: #ececef;
+$source-editor-hover-dark-text-color: #333238;
+
/**
Bootstrap 4.2.0 introduced new icons for validating forms.
Our design system does not use those, so we are disabling them for now:
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index ccb5d96e966..969a6665634 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -192,8 +192,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
@@ -247,8 +247,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
@@ -269,8 +269,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 99e7f7ae0a4..f7ab78c1bcc 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -208,15 +208,11 @@
}
.boards-sidebar {
- top: $header-height !important;
+ top: $calc-application-header-height !important;
height: auto;
- bottom: 0;
+ bottom: $calc-application-footer-height;
padding-bottom: 0.5rem;
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height}) !important;
- }
-
.sidebar-collapsed-icon {
@include gl-display-none;
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index d40c03b7fd1..5114f484e53 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -6,30 +6,22 @@
}
.archived-job {
- top: $header-height;
+ top: $calc-application-header-height;
border-radius: 2px 2px 0 0;
color: var(--orange-600, $orange-600);
background-color: var(--orange-50, $orange-50);
border: 1px solid var(--border-color, $border-color);
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
}
.top-bar {
@include build-log-top-bar(50px);
&.has-archived-block {
- top: calc(#{$header-height} + 28px);
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + 28px);
- }
+ top: calc(#{$calc-application-header-height} + 28px);
}
&.affix {
- top: $header-height;
+ top: $calc-application-header-height;
// with sidebar
&.sidebar-expanded {
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
index 143682e1cd7..f56eb4ae6fb 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -20,10 +20,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-detail {
background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity);
-
- .with-performance-bar & {
- top: 35px;
- }
+ bottom: $calc-application-footer-height;
.comment-indicator {
border-radius: 50%;
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index 79595fa3a98..e0fb95a1359 100644
--- a/app/assets/stylesheets/page_bundles/issuable.scss
+++ b/app/assets/stylesheets/page_bundles/issuable.scss
@@ -66,7 +66,7 @@
.title {
padding: 0;
- margin-bottom: $gl-padding;
+ margin-bottom: $gl-spacing-scale-4;
border-bottom: 0;
word-wrap: break-word;
overflow-wrap: break-word;
diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss
index b08e129a805..1ca0c5e7ce6 100644
--- a/app/assets/stylesheets/page_bundles/issuable_list.scss
+++ b/app/assets/stylesheets/page_bundles/issuable_list.scss
@@ -18,6 +18,11 @@
}
}
+ .issuable-info,
+ .issuable-meta {
+ font-size: $gl-font-size-sm;
+ }
+
.issuable-meta {
display: flex;
flex-direction: column;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index 360ea20733d..495b7d58788 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/page_bundles/login.scss
@@ -1,4 +1,5 @@
-@import 'framework/variables';
+@import 'mixins_and_variables_and_functions';
+
/* Login Page */
.login-page {
.container {
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 396c590d912..97df87458ab 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -1,6 +1,5 @@
@import 'mixins_and_variables_and_functions';
-$mr-review-bar-height: calc(2rem + 13px);
$mr-widget-margin-left: 40px;
$mr-widget-min-height: 69px;
$tabs-holder-z-index: 250;
@@ -181,6 +180,10 @@ $tabs-holder-z-index: 250;
.content + .content {
@include gl-border-t;
}
+
+ .notes-content {
+ border: 0;
+ }
}
&.inline-diff-view {
@@ -242,18 +245,6 @@ $tabs-holder-z-index: 250;
}
}
-.with-system-header {
- --system-header-height: #{$system-header-height};
-}
-
-.with-performance-bar {
- --performance-bar-height: #{$performance-bar-height};
-}
-
-.review-bar-visible {
- --review-bar-height: #{$mr-review-bar-height};
-}
-
.diff-tree-list {
// This 11px value should match the additional value found in
// /assets/stylesheets/framework/diffs.scss
@@ -264,21 +255,19 @@ $tabs-holder-z-index: 250;
// If they don't match, the file tree and the diff files stick
// to the top at different heights, which is a bad-looking defect
$diff-file-header-top: 11px;
- --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
- --top-pos: var(--initial-pos);
- position: -webkit-sticky;
position: sticky;
- top: calc(var(--top-pos) + var(--performance-bar-height, 0px));
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
min-height: 300px;
- height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
+ height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top}));
.drag-handle {
bottom: 16px;
}
&.is-sidebar-moved {
- --top-pos: calc(var(--initial-pos) + 26px);
+ height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top} + 26px));
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top} + 26px);
}
}
@@ -915,15 +904,6 @@ $tabs-holder-z-index: 250;
&:not(:first-child) {
margin-top: $gl-padding;
}
-
- &:not(:last-child)::before {
- content: '';
- border-left: 2px solid var(--border-color, $border-color);
- position: absolute;
- bottom: -17px;
- left: 26px;
- height: 16px;
- }
}
.mr-version-controls {
@@ -992,8 +972,8 @@ $tabs-holder-z-index: 250;
.merge-request-overview {
@include media-breakpoint-up(lg) {
display: grid;
- grid-template-columns: calc(95% - 285px) auto;
- grid-gap: 5%;
+ grid-template-columns: calc(97% - 285px) auto;
+ grid-gap: 3%;
}
}
@@ -1156,7 +1136,7 @@ $tabs-holder-z-index: 250;
.review-bar-component {
position: fixed;
- bottom: 0;
+ bottom: $calc-application-footer-height;
left: 0;
z-index: $zindex-dropdown-menu;
display: flex;
@@ -1241,3 +1221,70 @@ $tabs-holder-z-index: 250;
}
}
}
+
+.mr-state-loader {
+ svg {
+ vertical-align: middle;
+ }
+
+ .gl-skeleton-loader {
+ max-width: 334px;
+ }
+}
+
+.mr-system-note-icon {
+ width: 20px;
+ height: 20px;
+ margin-left: 6px;
+
+ &.gl-bg-green-100 {
+ --bg-color: var(--green-100, #{$green-100});
+ }
+
+ &.gl-bg-red-100 {
+ --bg-color: var(--red-100, #{$red-100});
+ }
+
+ &.gl-bg-blue-100 {
+ --bg-color: var(--blue-100, #{$blue-100});
+ }
+}
+
+.mr-system-note-icon:not(.mr-system-note-empty)::before {
+ content: '';
+ display: block;
+ position: absolute;
+ left: calc(50% - 1px);
+ bottom: 100%;
+ width: 2px;
+ height: 20px;
+ background: linear-gradient(to bottom, transparent, var(--bg-color));
+
+ .system-note:first-child & {
+ display: none;
+ }
+}
+
+.mr-system-note-icon:not(.mr-system-note-empty)::after {
+ content: '';
+ display: block;
+ position: absolute;
+ left: calc(50% - 1px);
+ top: 100%;
+ width: 2px;
+ height: 20px;
+ background: linear-gradient(to bottom, var(--bg-color), transparent);
+
+ .system-note:last-child & {
+ display: none;
+ }
+}
+
+.mr-system-note-empty {
+ width: 8px;
+ height: 8px;
+ margin-top: 6px;
+ margin-left: 12px;
+ margin-right: 8px;
+ border: 2px solid var(--gray-50, $gray-50);
+}
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 708d1a2895e..8dc07715989 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -131,42 +131,6 @@
}
}
-.milestone-page-header {
- display: flex;
- flex-flow: row;
- align-items: center;
- flex-wrap: wrap;
-
- .milestone-buttons {
- margin-left: auto;
- order: 2;
-
- .verbose {
- display: none;
- }
- }
-
- .header-text-content {
- order: 3;
- width: 100%;
- }
-
- @include media-breakpoint-up(xs) {
- .milestone-buttons .verbose {
- display: inline;
- }
-
- .header-text-content {
- order: 2;
- width: auto;
- }
-
- .milestone-buttons {
- order: 3;
- }
- }
-}
-
.issuable-row {
background-color: var(--white, $white);
}
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index f08d6e3ca95..51bffd99dd0 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -57,10 +57,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-h-full;
@include gl-w-full;
@include gl-overflow-x-auto;
- @include gl-border-gray-100;
- @include gl-border-1;
- @include gl-border-solid;
- @include gl-rounded-base;
}
.timeline-section {
@@ -68,15 +64,12 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-top-0;
z-index: 20;
- .timeline-header-blank,
+ .timeline-header-label,
.timeline-header-item {
@include gl-float-left;
- height: $header-item-height;
- border-bottom: $border-style;
- background-color: var(--white, $white);
}
- .timeline-header-blank {
+ .timeline-header-label {
@include gl-sticky;
@include gl-top-0;
@include gl-left-0;
@@ -85,13 +78,8 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
.timeline-header-item {
- &:last-of-type .item-label {
- @include gl-border-r-0;
- }
-
- .item-label,
.item-sublabel .sublabel-value {
- color: var(--gray-400, $gray-400);
+ color: var(--gray-700, $gray-700);
@include gl-font-weight-normal;
&.label-dark {
@@ -103,11 +91,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
}
- .item-label {
- border-right: $border-style;
- border-bottom: $border-style;
- }
-
.item-sublabel {
@include gl-relative;
@include gl-display-flex;
@@ -118,7 +101,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
text-align: center;
@include gl-font-base;
- padding: 2px 0;
}
}
@@ -131,10 +113,15 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-rounded-full;
transform: translate(-50%, 50%);
}
+
+ &:first-of-type {
+ .week-item-sublabel .sublabel-value:nth-of-type(7) {
+ @include gl-border-r;
+ }
+ }
}
}
-.timeline-section .timeline-header-blank,
.list-section .details-cell {
&::after {
@include gl-h-full;
@@ -159,7 +146,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-left-0;
width: $details-cell-width;
@include gl-font-base;
- background-color: var(--white, $white);
z-index: 10;
}
@@ -182,3 +168,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%);
}
}
+
+.rotation-asignee-container {
+ overflow-x: clip;
+}
diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss
index 347bd1316c0..8f2cbc402c9 100644
--- a/app/assets/stylesheets/pages/storage_quota.scss
+++ b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.storage-type-usage {
&:first-child {
@include gl-rounded-top-left-base;
@@ -12,6 +14,6 @@
&:not(:last-child) {
@include gl-border-r-2;
@include gl-border-r-solid;
- @include gl-border-white;
+ border-right-color: var(--white, $white);
}
}
diff --git a/app/assets/stylesheets/page_bundles/releases.scss b/app/assets/stylesheets/page_bundles/releases.scss
index 24ffbf9b90c..c011ec3fe4c 100644
--- a/app/assets/stylesheets/page_bundles/releases.scss
+++ b/app/assets/stylesheets/page_bundles/releases.scss
@@ -10,3 +10,17 @@
min-height: 46px;
}
}
+
+.release-tag-selector {
+ .popover-body {
+ padding-left: 0;
+ padding-right: 0;
+ padding-bottom: 0;
+ min-width: $gl-dropdown-width;
+ max-width: $gl-dropdown-width;
+ }
+
+ .release-tag-list {
+ max-height: $dropdown-max-height;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss
index cde570cfb0f..d37e87b5cd5 100644
--- a/app/assets/stylesheets/page_bundles/search.scss
+++ b/app/assets/stylesheets/page_bundles/search.scss
@@ -21,18 +21,18 @@ $border-radius-medium: 3px;
}
}
+.language-filter-checkbox {
+ .custom-control-label {
+ flex-grow: 1;
+ }
+}
+
.search-sidebar {
@include media-breakpoint-up(md) {
min-width: $search-sidebar-min-width;
max-width: $search-sidebar-max-width;
}
- .language-filter-checkbox {
- .custom-control-label {
- flex-grow: 1;
- }
- }
-
.language-filter-max-height {
max-height: $language-filter-max-height;
}
diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index d7d454bde45..69a3ec94fda 100644
--- a/app/assets/stylesheets/page_bundles/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -102,24 +102,42 @@
}
}
- .active > a {
- color: var(--black, $black);
- }
-
.active > .wiki-list {
a,
.wiki-list-expand-button,
.wiki-list-collapse-button {
- color: var(--black, $black);
+ color: $black;
+ }
+ }
+
+ .wiki-list {
+ height: $gl-spacing-scale-8;
+
+ &:hover {
+ background: $gray-10;
+
+ .wiki-list-create-child-button {
+ display: block;
+ box-shadow: none;
+
+ &:focus {
+ box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400;
+ }
+
+ &:active {
+ background: $gray-100 !important;
+ box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400;
+ }
+ }
}
}
.wiki-list-expand-button,
.wiki-list-collapse-button {
- color: var(--gray-400, $gray-400);
+ color: $gray-400;
&:hover {
- color: var(--black, $black);
+ color: $black;
}
}
@@ -130,10 +148,6 @@
margin: 0;
}
- ul.wiki-pages li {
- margin: 5px 0 10px;
- }
-
ul.wiki-pages ul {
padding-left: 20px;
}
@@ -172,6 +186,10 @@ ul.wiki-pages-list.content-list {
}
.wiki-list {
+ .wiki-list-create-child-button {
+ display: none;
+ }
+
.wiki-list-expand-button,
.wiki-list-collapse-button {
left: -$gl-spacing-scale-5;
@@ -198,17 +216,12 @@ ul.wiki-pages-list.content-list {
.drawio-editor {
position: fixed;
- top: calc(var(--header-height, 48px));
+ top: 0;
left: 0;
bottom: 0;
- width: 100%;
- height: calc(100% - var(--header-height, 48px));
+ width: 100vw;
+ height: 100vh;
border: 0;
z-index: 1100;
visibility: hidden;
}
-
-.with-performance-bar .drawio-editor {
- top: calc(var(--header-height, 48px) + 35px);
- height: calc(100% - var(--header-height, 48px) - 35px);
-}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 00c86c46ac8..ecbb872e1df 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -87,22 +87,20 @@
}
}
-.work-item-link-child {
- @include gl-border-1;
- @include gl-border-solid;
- @include gl-border-transparent;
- @include gl-rounded-base;
-
- &:hover,
- &:focus-within {
- @include gl-bg-white;
- @include gl-border-gray-50;
- }
-}
-
// sticky error placement for errors in modals , by default it is 83px for full view
#work-item-detail-modal {
.flash-container.flash-container-page.sticky {
top: -8px;
}
}
+
+
+.work-item-notifications-form {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 225c32c1989..83f51588f43 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -332,3 +332,18 @@
height: 100%;
}
}
+
+.add-review-item-modal {
+ .modal-content {
+ position: absolute;
+ top: 5%;
+ }
+
+ .title-hint-text {
+ color: $gl-text-color-secondary;
+ }
+
+ .gl-filtered-search-suggestion-list.dropdown-menu {
+ width: $gl-max-dropdown-max-height;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 0151446321a..9b6a3362e71 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -77,8 +77,7 @@ ul.related-merge-requests > li gl-emoji {
.issue {
&.closed,
&.merged {
- background: $gray-light;
- border-color: $border-color;
+ background: $gray-10;
}
}
@@ -243,6 +242,8 @@ ul.related-merge-requests > li gl-emoji {
}
&:hover > a.anchor::after {
+ position: relative;
+ top: -3px;
visibility: visible;
}
}
@@ -253,7 +254,7 @@ ul.related-merge-requests > li gl-emoji {
@include gl-left-0;
width: var(--width);
- top: $header-height;
+ top: $calc-application-header-height;
// collapsed right sidebar
@include media-breakpoint-up(sm) {
@@ -267,10 +268,6 @@ ul.related-merge-requests > li gl-emoji {
}
}
-.with-performance-bar .issue-sticky-header {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
@include media-breakpoint-up(md) {
// collapsed left sidebar + collapsed right sidebar
.page-with-contextual-sidebar .issue-sticky-header {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 5b8b850ba35..0a17b2c47a4 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -209,19 +209,11 @@ $comparison-empty-state-height: 62px;
}
.merge-request-tabs-holder {
- top: $header-height;
+ top: $calc-application-header-height;
z-index: $tabs-holder-z-index;
background-color: $body-bg;
border-bottom: 1px solid $border-color;
- .with-system-header & {
- top: calc(#{$header-height} + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar & {
- top: calc(#{$header-height} + #{$system-header-height} + #{$performance-bar-height});
- }
-
@include media-breakpoint-up(md) {
position: sticky;
}
@@ -240,12 +232,6 @@ $comparison-empty-state-height: 62px;
}
}
-.with-performance-bar {
- .merge-request-tabs-holder {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-}
-
.limit-container-width {
.merge-request-tabs-container {
max-width: $limited-layout-width;
@@ -336,11 +322,7 @@ $comparison-empty-state-height: 62px;
.mr-compare {
.diff-file .file-title-flex-parent {
- top: calc(#{$header-height} + #{$mr-tabs-height});
-
- .with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height});
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 68a5176ad4b..b31ee069236 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -7,7 +7,7 @@ $system-note-icon-size: 1.5rem;
$system-note-svg-size: 1rem;
$icon-size-diff: $avatar-icon-size - $system-note-icon-size;
-$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 0.1rem;
+$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 1.3rem;
$system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
@mixin vertical-line($left) {
@@ -15,10 +15,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
content: '';
border-left: 2px solid var(--gray-50, $gray-50);
position: absolute;
- top: $gl-padding-6;
+ top: 16px;
bottom: 0;
left: calc(#{$left} - 1px);
- height: calc(100% + 1.5rem);
+ height: calc(100% + 20px);
}
}
@@ -30,8 +30,30 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.issuable-discussion:not(.incident-timeline-events),
.limited-width-notes {
- .main-notes-list > li.timeline-entry:not(:last-of-type) {
- @include vertical-line(1rem);
+ .main-notes-list::before,
+ .timeline-entry:last-child::before {
+ content: '';
+ position: absolute;
+ width: 2px;
+ left: 15px;
+ top: 15px;
+ height: calc(100% - 15px);
+ }
+
+ .main-notes-list::before {
+ background: var(--gray-50, $gray-50);
+ }
+
+ .timeline-entry:last-child::before {
+ background: var(--white);
+
+ .gl-dark & {
+ background: var(--gray-10);
+ }
+
+ &.note-comment {
+ top: 30px;
+ }
}
}
@@ -63,6 +85,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
height: 2rem;
}
+ .gl-avatar {
+ border-color: var(--gray-50, $gray-50);
+ }
+
&.note-comment,
&.note-skeleton,
.draft-note {
@@ -265,7 +291,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
&.being-posted {
pointer-events: none;
- opacity: 0.5;
+
+ .timeline-entry-inner {
+ opacity: 0.5;
+ }
.dummy-avatar {
background-color: $gray-100;
@@ -343,6 +372,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.note-header-info {
padding-bottom: 0;
+ padding-top: 0;
}
&.timeline-entry::after {
@@ -369,9 +399,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
.timeline-content {
- @include notes-media('min', map-get($grid-breakpoints, sm)) {
- margin-left: 30px;
- }
+ margin-left: 30px;
}
.note-header {
@@ -450,40 +478,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
}
-
- .timeline-icon {
- float: left;
- }
-
- .system-note,
- .discussion-filter-note {
- .timeline-icon {
- display: flex;
- align-items: center;
- background-color: $gray-50;
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- border: 1px solid $gray-50;
- border-radius: $system-note-icon-size;
- margin: -$gl-spacing-scale-1 0 0 $gl-spacing-scale-2;
-
- svg {
- width: $system-note-svg-size;
- height: $system-note-svg-size;
- fill: $gray-600;
- display: block;
- margin: 0 auto;
- }
- }
- }
-
- .discussion-filter-note {
- .timeline-icon {
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- margin-top: -8px;
- }
- }
}
.card .notes {
@@ -493,7 +487,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
.timeline-icon {
- margin: 8px 0 0 14px;
+ margin: 20px 0 0 28px;
}
}
@@ -506,18 +500,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
border-radius: 0;
margin-left: 2.5rem;
- @media (min-width: map-get($grid-breakpoints, md)) {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
-
- &.is-sidebar-moved {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px});
- }
-
- .with-performance-bar & {
- --top: 123px;
- }
- }
-
&:hover {
background-color: $gray-light;
}
@@ -603,15 +585,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.system-note {
background-color: transparent;
padding: 0;
-
- .timeline-icon {
- margin-top: -2px;
- }
-
- .timeline-entry-inner .timeline-icon {
- margin-top: $system-note-icon-m-top;
- margin-left: $system-note-icon-m-left;
- }
}
}
@@ -643,10 +616,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
padding: 0;
vertical-align: top;
white-space: normal;
-
- // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-foss/issues/53973
- // background-color is needed for dark code preference
- padding-bottom: 1px;
background-color: $white;
&.parallel {
@@ -673,6 +642,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
}
+
+ .diff-grid-comments:last-child {
+ .notes-content {
+ border-bottom-width: 0;
+ border-bottom-left-radius: #{$border-radius-default - 1px};
+ border-bottom-right-radius: #{$border-radius-default - 1px};
+ }
+ }
}
.diffs {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ee91d955019..e6c7e265cdb 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -291,9 +291,9 @@
.project-cell {
@include gl-display-table-cell;
- @include gl-border-b;
@include gl-vertical-align-top;
@include gl-py-4;
+ border-bottom: 1px solid $gray-50;
}
.project-row:last-of-type {
diff --git a/app/assets/stylesheets/pages/registry.scss b/app/assets/stylesheets/pages/registry.scss
index 31c6dbd2970..36b86771295 100644
--- a/app/assets/stylesheets/pages/registry.scss
+++ b/app/assets/stylesheets/pages/registry.scss
@@ -2,7 +2,7 @@
// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
//
// See app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue when this is changed.
-.breadcrumbs-container .gl-breadcrumbs {
+.breadcrumbs .gl-breadcrumbs {
padding: 0;
box-shadow: none;
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 2d78ab82b7d..012ae4bb86a 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,23 +1,4 @@
-.integration-settings-form {
- .card.card-body,
- .info-well {
- padding: $gl-padding / 2;
- box-shadow: none;
- }
-
- .svg-container {
- max-width: 150px;
- }
-}
-
.visibility-level-setting {
- .option-title {
- font-weight: $gl-font-weight-normal;
- display: inline-block;
- color: var(--gl-text-color, $gl-text-color);
- vertical-align: top;
- }
-
.option-description,
.option-disabled-reason {
color: var(--gray-700, $gray-700);
@@ -69,30 +50,16 @@
}
}
-.push-pull-table {
- margin-top: 1em;
-}
-
.ci-variable-table,
.deploy-freeze-table,
.ci-secure-files-table {
table {
- thead {
- border-bottom: 1px solid var(--gray-50, $gray-50);
- }
-
tr {
td,
th {
padding-left: 0;
}
- th {
- background-color: transparent;
- font-weight: $gl-font-weight-bold;
- border: 0;
- }
-
// When tables are "stacked", restore td padding
@media(max-width: map-get($grid-breakpoints, lg)) {
td {
@@ -109,8 +76,8 @@
}
}
-.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap {
+.gl-md-flex-nowrap.gl-md-flex-nowrap {
@include gl-media-breakpoint-up(md) {
- @include gl-flex-wrap-nowrap;
+ @include gl-flex-nowrap;
}
}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 5024b082b99..cb153122767 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -128,6 +128,3 @@
color: $black;
}
-html.with-performance-bar .nav-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 265f27f21fa..84181a00f34 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -63,9 +63,5 @@ a[href]::after {
}
.with-performance-bar .layout-page {
- margin-top: 0;
-}
-
-.content-wrapper-margin {
- margin-top: 0;
+ padding-top: 0;
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index b4e896325d6..ef75c650853 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -140,18 +140,6 @@ kbd kbd {
background-color: #24232a;
opacity: 1;
}
-.form-inline {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
-}
-@media (min-width: 576px) {
- .form-inline .form-control {
- display: inline-block;
- width: auto;
- vertical-align: middle;
- }
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -562,17 +550,13 @@ strong {
svg {
vertical-align: baseline;
}
-.form-control,
-.search form {
+.form-control {
font-size: 0.875rem;
}
.hidden {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -593,6 +577,14 @@ svg {
html {
overflow-y: scroll;
}
+.layout-page {
+ padding-top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
+ padding-bottom: var(--system-footer-height);
+}
@media (min-width: 576px) {
.logged-out-marketing-header {
--header-height: 72px;
@@ -632,43 +624,22 @@ html {
color: #bfbfc3;
vertical-align: baseline;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+.with-top-bar {
+ --top-bar-height: 48px;
+}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb;
- outline: none;
-}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
-}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #333238;
- color: #ececef;
- font-size: 14px;
- text-align: left;
- border: 1px solid #535158;
- border-radius: 0.25rem;
- white-space: nowrap;
-}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
-}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-top: 7px;
- padding-bottom: 7px;
- padding-right: 25px;
- position: relative;
- text-overflow: ellipsis;
- line-height: 16px;
- width: 160px;
-}
.dropdown-menu {
display: none;
position: absolute;
@@ -750,11 +721,6 @@ html {
min-width: 100%;
}
}
-@media (max-width: 767.98px) {
- .dropdown-menu-toggle.dropdown-menu-toggle {
- width: 100%;
- }
-}
input {
border-radius: 0.25rem;
color: #ececef;
@@ -793,7 +759,7 @@ kbd {
min-height: var(--header-height, 48px);
border: 0;
position: fixed;
- top: 0;
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
left: 0;
right: 0;
border-radius: 0;
@@ -1075,11 +1041,15 @@ kbd {
}
.nav-sidebar {
position: fixed;
- bottom: 0;
+ bottom: var(--system-footer-height);
left: 0;
z-index: 600;
width: 256px;
- top: var(--header-height, 48px);
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
background-color: #1f1e24;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
@@ -1496,8 +1466,11 @@ kbd {
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height))
+ );
+ bottom: var(--system-footer-height);
left: 0;
background-color: var(--gray-10, #1f1e24);
border-right: 1px solid rgba(251, 250, 253, 0.08);
@@ -1513,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
@@ -1667,7 +1644,6 @@ svg.s16 {
--gray-200: #535158;
--gray-700: #bfbfc3;
--gray-900: #ececef;
- --gl-text-color: #ececef;
--border-color: #434248;
--white: #333238;
--black: #fff;
@@ -1779,16 +1755,6 @@ body.gl-dark .header-search .keyboard-shortcut-helper {
color: #ececef;
background-color: rgba(236, 236, 239, 0.2);
}
-body.gl-dark .search form {
- background-color: rgba(236, 236, 239, 0.2);
-}
-body.gl-dark .search .search-input::placeholder {
- color: rgba(236, 236, 239, 0.8);
-}
-body.gl-dark .search .search-input-wrap .search-icon,
-body.gl-dark .search .search-input-wrap .clear-icon {
- fill: rgba(236, 236, 239, 0.8);
-}
body.gl-dark .nav-sidebar li.active > a {
color: #ececef;
}
@@ -1817,17 +1783,6 @@ body.gl-dark .navbar-gitlab .header-search:active {
background-color: var(--gray-100) !important;
box-shadow: inset 0 0 0 1px var(--blue-200) !important;
}
-body.gl-dark .navbar-gitlab .search form {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--border-color);
-}
-body.gl-dark .navbar-gitlab .search form:active {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--blue-200);
-}
-body.gl-dark .navbar-gitlab .search form .search-input {
- color: var(--gl-text-color);
-}
.tab-width-8 {
tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 0a0fa83ff67..0dfc6be356f 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -140,18 +140,6 @@ kbd kbd {
background-color: #fbfafd;
opacity: 1;
}
-.form-inline {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
-}
-@media (min-width: 576px) {
- .form-inline .form-control {
- display: inline-block;
- width: auto;
- vertical-align: middle;
- }
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -562,17 +550,13 @@ strong {
svg {
vertical-align: baseline;
}
-.form-control,
-.search form {
+.form-control {
font-size: 0.875rem;
}
.hidden {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -593,6 +577,14 @@ svg {
html {
overflow-y: scroll;
}
+.layout-page {
+ padding-top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
+ padding-bottom: var(--system-footer-height);
+}
@media (min-width: 576px) {
.logged-out-marketing-header {
--header-height: 72px;
@@ -632,43 +624,22 @@ html {
color: #535158;
vertical-align: baseline;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+.with-top-bar {
+ --top-bar-height: 48px;
+}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc;
- outline: none;
-}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
-}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #fff;
- color: #333238;
- font-size: 14px;
- text-align: left;
- border: 1px solid #bfbfc3;
- border-radius: 0.25rem;
- white-space: nowrap;
-}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
-}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-top: 7px;
- padding-bottom: 7px;
- padding-right: 25px;
- position: relative;
- text-overflow: ellipsis;
- line-height: 16px;
- width: 160px;
-}
.dropdown-menu {
display: none;
position: absolute;
@@ -750,11 +721,6 @@ html {
min-width: 100%;
}
}
-@media (max-width: 767.98px) {
- .dropdown-menu-toggle.dropdown-menu-toggle {
- width: 100%;
- }
-}
input {
border-radius: 0.25rem;
color: #333238;
@@ -793,7 +759,7 @@ kbd {
min-height: var(--header-height, 48px);
border: 0;
position: fixed;
- top: 0;
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
left: 0;
right: 0;
border-radius: 0;
@@ -1075,11 +1041,15 @@ kbd {
}
.nav-sidebar {
position: fixed;
- bottom: 0;
+ bottom: var(--system-footer-height);
left: 0;
z-index: 600;
width: 256px;
- top: var(--header-height, 48px);
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
background-color: #fbfafd;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
@@ -1496,8 +1466,11 @@ kbd {
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height))
+ );
+ bottom: var(--system-footer-height);
left: 0;
background-color: var(--gray-10, #fbfafd);
border-right: 1px solid rgba(31, 30, 36, 0.08);
@@ -1513,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 57f61508178..cd768c3bbc0 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -430,8 +430,13 @@ input.btn-block[type="button"] {
cursor: not-allowed;
color: #89888d;
}
+.gl-form-checkbox.custom-control {
+ padding-left: 1rem;
+}
.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label {
cursor: pointer;
+ padding-left: 0.5rem;
+ margin-bottom: 0.5rem;
}
.gl-form-checkbox.custom-control
.custom-control-input
@@ -440,6 +445,7 @@ input.btn-block[type="button"] {
.custom-control-input
~ .custom-control-label::after {
top: 0;
+ left: -1rem;
}
.gl-form-checkbox.custom-control
.custom-control-input
@@ -663,6 +669,13 @@ body.navless {
.btn-block.btn {
padding: 6px 0;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
.tab-content {
overflow: visible;
}
@@ -690,7 +703,11 @@ hr {
}
.flash-container.sticky {
position: sticky;
- top: 48px;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
z-index: 251;
}
.flash-container.flash-container-page {
@@ -756,223 +773,10 @@ input:-ms-input-placeholder {
svg {
fill: currentColor;
}
-.login-page .container {
- max-width: 960px;
-}
-.login-page .navbar-gitlab .container {
- max-width: none;
-}
-.login-page .flash-container {
- margin-bottom: 16px;
- position: relative;
- top: 8px;
-}
-.login-page .brand-holder {
- font-size: 18px;
- line-height: 1.5;
-}
-.login-page .brand-holder p {
- font-size: 16px;
- color: #888;
-}
-.login-page .brand-holder h3 {
- font-size: 22px;
-}
-.login-page .brand-holder img {
- max-width: 100%;
- margin-bottom: 30px;
-}
-.login-page .brand-holder a {
- font-weight: 600;
-}
-.login-page p {
- font-size: 13px;
-}
-.login-page .signin-text p {
- margin-bottom: 0;
- line-height: 1.5;
-}
-.login-page .borderless .login-box,
-.login-page .borderless .omniauth-container {
- box-shadow: none;
-}
-.login-page .borderless .g-recaptcha > div {
- margin-left: auto;
- margin-right: auto;
-}
-.login-page .login-box,
-.login-page .omniauth-container {
- box-shadow: 0 0 0 1px #dcdcde;
- border-radius: 0.25rem;
-}
-.login-page .login-box .login-heading h3,
-.login-page .omniauth-container .login-heading h3 {
- font-weight: 400;
- line-height: 1.5;
- margin: 0 0 10px;
-}
-.login-page .login-box .login-footer,
-.login-page .omniauth-container .login-footer {
- margin-top: 10px;
-}
-.login-page .login-box .login-footer p:last-child,
-.login-page .omniauth-container .login-footer p:last-child {
- margin-bottom: 0;
-}
-.login-page .login-box a.forgot,
-.login-page .omniauth-container a.forgot {
- float: right;
- padding-top: 6px;
-}
-.login-page .login-box .nav .active a,
-.login-page .omniauth-container .nav .active a {
- background: transparent;
-}
-.login-page .login-box .login-body,
-.login-page .omniauth-container .login-body {
- font-size: 13px;
-}
-.login-page .login-box .login-body input + p,
-.login-page .login-box .login-body input ~ p.field-validation,
-.login-page .omniauth-container .login-body input + p,
-.login-page .omniauth-container .login-body input ~ p.field-validation {
- margin-top: 5px;
-}
-.login-page .login-box .login-body .username .validation-success,
-.login-page .omniauth-container .login-body .username .validation-success {
- color: #217645;
-}
-.login-page .login-box .login-body .username .validation-error,
-.login-page .omniauth-container .login-body .username .validation-error {
- color: #dd2b0e;
-}
-.login-page .omniauth-container {
- border-radius: 0.25rem;
- font-size: 13px;
-}
-.login-page .omniauth-container p {
- margin: 0;
-}
-.login-page .omniauth-container form {
- padding: 0;
- border: 0;
- background: none;
-}
-.login-page .new-session-tabs {
- display: flex;
- box-shadow: 0 0 0 1px #dcdcde;
- border-top-right-radius: 4px;
- border-top-left-radius: 4px;
-}
-.login-page .new-session-tabs.nav-links-unboxed {
- border-color: transparent;
- box-shadow: none;
-}
-.login-page .new-session-tabs.nav-links-unboxed .nav-item {
- border-left: 0;
- border-right: 0;
- border-bottom: 1px solid #dcdcde;
- background-color: transparent;
-}
-.login-page .new-session-tabs.custom-provider-tabs {
- flex-wrap: wrap;
-}
-.login-page .new-session-tabs.custom-provider-tabs li {
- min-width: 85px;
- flex-basis: auto;
-}
-.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) {
- border-top: 1px solid #dcdcde;
-}
-.login-page .new-session-tabs.custom-provider-tabs a {
- font-size: 16px;
-}
-.login-page .new-session-tabs li {
- flex: 1;
- text-align: center;
- border-left: 1px solid #dcdcde;
-}
-.login-page .new-session-tabs li:first-of-type {
- border-left: 0;
- border-top-left-radius: 4px;
-}
-.login-page .new-session-tabs li:last-of-type {
- border-top-right-radius: 4px;
-}
-.login-page .new-session-tabs li:not(.active) {
- background-color: #fbfafd;
-}
-.login-page .new-session-tabs li a {
- width: 100%;
- font-size: 18px;
-}
-.login-page .new-session-tabs li.active > a {
- cursor: default;
-}
-.login-page .form-control:active,
-.login-page .form-control:focus {
- background-color: #fff;
-}
-.login-page .submit-container {
- margin-top: 16px;
-}
-.login-page input[type="submit"] {
- margin-bottom: 0;
- display: block;
- width: 100%;
-}
-.login-page .devise-errors h2 {
- margin-top: 0;
- font-size: 14px;
- color: #ae1800;
-}
-@media (max-width: 575.98px) {
- .login-page .col-md-5.float-right {
- float: none !important;
- margin-bottom: 45px;
- }
-}
-.devise-layout-html {
- margin: 0;
- padding: 0;
- height: 100%;
-}
-.devise-layout-html body {
- height: calc(100% - 51px);
- margin: 0;
- padding: 0;
-}
-.devise-layout-html body.navless {
- height: calc(100% - 11px);
-}
-.devise-layout-html body .page-wrap {
- min-height: 100%;
- position: relative;
-}
-.devise-layout-html body .footer-container,
-.devise-layout-html body hr.footer-fixed {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: 40px;
- background: #fff;
-}
-.devise-layout-html body .login-page-broadcast {
- margin-top: 40px;
-}
-.devise-layout-html body .navless-container {
- padding: 0 15px 65px;
-}
-.devise-layout-html body .flash-container {
- padding-bottom: 65px;
-}
-@media (max-width: 575.98px) {
- .devise-layout-html body .flash-container {
- padding-bottom: 0;
- }
-}
+.fixed-top {
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
+}
.gl-display-flex {
display: flex;
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index f37b426cd91..6e46100dbb3 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -159,6 +159,22 @@
background-color: $search-and-nav-links-a30 !important;
}
+ &.is-focused {
+ input {
+ background-color: $white;
+ color: $gl-text-color !important;
+ box-shadow: inset 0 0 0 1px $gray-900;
+
+ &:focus {
+ box-shadow: inset 0 0 0 1px $gray-900, 0 0 0 1px $white, 0 0 0 3px $blue-400;
+ }
+
+ &::placeholder {
+ color: $gray-400;
+ }
+ }
+ }
+
svg.gl-search-box-by-type-search-icon {
color: $search-and-nav-links-a80;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 11f73b592fc..e5f99879166 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -39,6 +39,12 @@
.border-radius-small { border-radius: $border-radius-small; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
+// Override Bootstrap class with offset for system-header and
+// performance bar when present
+.fixed-top {
+ top: $calc-application-bars-height;
+}
+
.gl-children-ml-sm-3 > * {
@include media-breakpoint-up(sm) {
@include gl-ml-3;
@@ -71,58 +77,11 @@
// https://gitlab.com/groups/gitlab-org/-/epics/2882
.gl-h-200\! { height: px-to-rem($grid-size * 25) !important; }
-.gl-bg-purple-light { background-color: $purple-light; }
-
-// move this to GitLab UI once onboarding experiment is considered a success
-.gl-py-8 {
- padding-top: $gl-spacing-scale-8;
- padding-bottom: $gl-spacing-scale-8;
-}
-
-.gl-transition-property-stroke-opacity {
- transition-property: stroke-opacity;
-}
-
-.gl-transition-property-stroke {
- transition-property: stroke;
-}
-
-.gl-top-66vh {
- top: 66vh;
-}
-
-.gl-shadow-x0-y0-b3-s1-blue-500 {
- box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500;
-}
-
// This utility is used to force the z-index to match that of dropdown menu's
.gl-z-dropdown-menu\! {
z-index: $zindex-dropdown-menu !important;
}
-.gl-flex-basis-quarter {
- flex-basis: 25%;
-}
-
-// Will be moved to @gitlab/ui (without the !important) in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1462
-// We only need the bang (!) version until the non-bang version is added to
-// @gitlab/ui utitlities.scss. Once there, it will get loaded in the correct
-// order to properly override `.gl-mt-6` which is used for narrower screen
-// widths (currently that style gets added to the application.css stylesheet
-// after this one, so it takes precedence).
-.gl-md-mt-11\! {
- @media (min-width: $breakpoint-md) {
- margin-top: $gl-spacing-scale-11 !important;
- }
-}
-
-// Same as above (also without the !important) but for overriding `.gl-pt-6`
-.gl-md-pt-11\! {
- @media (min-width: $breakpoint-md) {
- padding-top: $gl-spacing-scale-11 !important;
- }
-}
-
// This is used to help prevent issues with margin collapsing.
// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing.
.gl-force-block-formatting-context::after {
@@ -130,34 +89,6 @@
display: flex;
}
-.gl-sm-mr-3 {
- @include media-breakpoint-up(sm) {
- margin-right: $gl-spacing-scale-3;
- }
-}
-
-.gl-xl-ml-3 {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-spacing-scale-3;
- }
-}
-
-.gl-mr-n2 {
- margin-right: -$gl-spacing-scale-2;
-}
-
-.gl-w-grid-size-30 {
- width: $grid-size * 30;
-}
-
-.gl-w-grid-size-40 {
- width: $grid-size * 40;
-}
-
-.gl-max-w-50p {
- max-width: 50%;
-}
-
/**
Note: ::-webkit-scrollbar is a non-standard rule only
supported by webkit browsers.
@@ -181,59 +112,6 @@
@include gl-focus($gl-border-size-1, $gray-900, true);
}
-/*
-All of the following (up until the "End gitlab-ui#1709" comment) will be moved
-to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
-*/
-.gl-md-grid-template-columns-3 {
- @include media-breakpoint-up(md) {
- grid-template-columns: repeat(3, 1fr);
- }
-}
-
-.gl-lg-grid-template-columns-4 {
- @include media-breakpoint-up(lg) {
- grid-template-columns: repeat(4, 1fr);
- }
-}
-
-.gl-max-w-48 {
- max-width: $gl-spacing-scale-48;
-}
-
-.gl-max-w-75 {
- max-width: $gl-spacing-scale-75;
-}
-
-.gl-md-pt-11 {
- @include media-breakpoint-up(md) {
- padding-top: $gl-spacing-scale-11 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-md-mb-6 {
- @include media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-6 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-md-mb-12 {
- @include media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-mt-n5 {
- margin-top: -$gl-spacing-scale-5;
-}
-
-// Utils below are very specific so cannot be part of GitLab UI
-.gl-md-mt-5 {
- @include gl-media-breakpoint-up(md) {
- margin-top: $gl-spacing-scale-5;
- }
-}
-
.gl-sm-mr-0\! {
@include gl-media-breakpoint-down(md) {
margin-right: 0 !important;
@@ -246,66 +124,20 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
}
}
-.gl-md-mb-3\! {
+.gl-md-w-15 {
@include gl-media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-3 !important;
+ width: $gl-spacing-scale-15;
}
}
-.gl-font-xs {
- font-size: px-to-rem(10px);
-}
-
-.gl-line-height-12 {
- line-height: px-to-rem(12px);
-}
-
-.gl-letter-spacing-06em {
- letter-spacing: 0.06em;
-}
-
-.gl-flex-flow-row-wrap {
- flex-flow: row wrap;
-}
-
-.gl-isolate {
- isolation: isolate;
-}
-
-.gl-text-transform-uppercase {
- text-transform: uppercase;
-}
-
-/*
- * The below style will be moved to @gitlab/ui by
- * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2177
- */
-.gl-gap-2 {
- gap: $gl-spacing-scale-2;
-}
-
-.gl-bg-t-gray-a-08 {
- background-color: $t-gray-a-08;
-}
-
-.gl-hover-bg-t-gray-a-08:hover {
- background-color: $t-gray-a-08;
-}
-
-.gl-inset-border-1-gray-a-08 {
- box-shadow: inset 0 0 0 $gl-border-size-1 $t-gray-a-08;
-}
-
-.gl-line-height-1 {
- line-height: 1;
-}
-
-.gl-focus:focus {
- @include gl-focus;
+.gl-md-w-20 {
+ @include gl-media-breakpoint-up(md) {
+ width: $gl-spacing-scale-20;
+ }
}
-.gl-md-justify-content-space-between {
+.gl-md-w-30 {
@include gl-media-breakpoint-up(md) {
- justify-content: space-between;
+ width: $gl-spacing-scale-30;
}
}
diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb
deleted file mode 100644
index cf7ba0e5aaf..00000000000
--- a/app/channels/awareness_channel.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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: 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,
- username: user.username,
- 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/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml
index 551d995cb22..bfc2f3f23a7 100644
--- a/app/components/diffs/overflow_warning_component.html.haml
+++ b/app/components/diffs/overflow_warning_component.html.haml
@@ -1,4 +1,4 @@
-= render Pajamas::AlertComponent.new(title: _('Too many changes to show.'),
+= render Pajamas::AlertComponent.new(title: _('Some changes are not shown.'),
variant: :warning,
alert_options: { class: 'gl-mb-5', data: { testid: "too-many-changes-alert" } }) do |c|
= c.body do
diff --git a/app/components/diffs/overflow_warning_component.rb b/app/components/diffs/overflow_warning_component.rb
index 5123809cfdc..34882885027 100644
--- a/app/components/diffs/overflow_warning_component.rb
+++ b/app/components/diffs/overflow_warning_component.rb
@@ -54,8 +54,8 @@ module Diffs
def message_text
_(
- "To preserve performance only %{strong_open}%{display_size} " \
- "of %{real_size}%{strong_close} files are displayed."
+ "For a faster browsing experience, only %{strong_open}%{display_size} of %{real_size}%{strong_close} " \
+ "files are shown. Download one of the files below to see all changes."
)
end
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index f6b4fbac8d5..edeac57bc42 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -55,7 +55,7 @@ class AbuseReportsController < ApplicationController
private
def report_params
- params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url, links_to_spam: [])
+ params.require(:abuse_report).permit(:message, :user_id, :category, :reported_from_url, :screenshot, links_to_spam: [])
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 0bbfeae6656..96d78034ad6 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -234,6 +234,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params[:application_setting][:valid_runner_registrars]&.delete("")
params[:application_setting][:restricted_visibility_levels]&.delete("")
+ params[:application_setting][:package_metadata_purl_types]&.delete("")
+ params[:application_setting][:package_metadata_purl_types]&.map!(&:to_i)
+
if params[:application_setting].key?(:required_instance_ci_template)
if params[:application_setting][:required_instance_ci_template].empty?
params[:application_setting][:required_instance_ci_template] = nil
@@ -276,6 +279,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:default_branch_name,
disabled_oauth_sign_in_sources: [],
import_sources: [],
+ package_metadata_purl_types: [],
restricted_visibility_levels: [],
repository_storages_weighted: {},
valid_runner_registrars: []
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 76564981c9b..d97fcc5df74 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -47,10 +47,9 @@ class Admin::ApplicationsController < Admin::ApplicationController
@application.renew_secret
if @application.save
- flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
- render :show
+ render json: { secret: @application.plaintext_secret }
else
- redirect_to admin_application_url(@application)
+ render json: { errors: @application.errors }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb
index b904196c5ab..a5211961d81 100644
--- a/app/controllers/admin/background_migrations_controller.rb
+++ b/app/controllers/admin/background_migrations_controller.rb
@@ -10,6 +10,7 @@ module Admin
def index
@relations_by_tab = {
'queued' => batched_migration_class.queued.queue_order,
+ 'finalizing' => batched_migration_class.finalizing.queue_order,
'failed' => batched_migration_class.with_status(:failed).queue_order,
'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order
}
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index c811de12914..4ab67e54766 100644
--- a/app/controllers/admin/ci/variables_controller.rb
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -3,7 +3,7 @@
module Admin
module Ci
class VariablesController < ApplicationController
- feature_category :pipeline_composition
+ feature_category :secrets_management
def show
respond_to do |format|
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index ef45eaac437..0f9ecc60648 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController
before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update]
- feature_category :subgroups
+ feature_category :subgroups, [:create, :destroy, :edit, :index, :members_update, :new, :show, :update]
def index
@groups = groups.sort_by_attribute(@sort = params[:sort])
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 70c2d262b72..84eb90ce334 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -68,6 +68,10 @@ class Admin::ProjectsController < Admin::ApplicationController
result = ::Projects::UpdateService.new(@project, current_user, project_params).execute
if result[:status] == :success
+ unless Gitlab::Utils.to_boolean(project_params['runner_registration_enabled'])
+ Ci::Runners::ResetRegistrationTokenService.new(@project, current_user).execute
+ end
+
redirect_to [:admin, @project], notice: format(_("Project '%{project_name}' was successfully updated."), project_name: @project.name)
else
render "edit"
@@ -103,7 +107,8 @@ class Admin::ProjectsController < Admin::ApplicationController
def allowed_project_params
[
:description,
- :name
+ :name,
+ :runner_registration_enabled
]
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 00b17bf381f..7fc534be253 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -114,10 +114,16 @@ class Admin::UsersController < Admin::ApplicationController
def block
result = Users::BlockService.new(current_user).execute(user)
- if result[:status] == :success
- redirect_back_or_admin_user(notice: _("Successfully blocked"))
- else
- redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked"))
+ respond_to do |format|
+ if result[:status] == :success
+ notice = _("Successfully blocked")
+ format.json { render json: { notice: notice } }
+ format.html { redirect_back_or_admin_user(notice: notice) }
+ else
+ alert = _("Error occurred. User was not blocked")
+ format.json { render json: { error: alert } }
+ format.html { redirect_back_or_admin_user(alert: alert) }
+ end
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ff888cf9d72..dbdf4a3055f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -89,7 +89,7 @@ class ApplicationController < ActionController::Base
render_403
end
- rescue_from Gitlab::Auth::IpBlacklisted do
+ rescue_from Gitlab::Auth::IpBlocked do
Gitlab::AuthLogger.error(
message: 'Rack_Attack',
env: :blocklist,
diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb
index 2401d8b1044..dd5be596ad1 100644
--- a/app/controllers/clusters/base_controller.rb
+++ b/app/controllers/clusters/base_controller.rb
@@ -8,7 +8,7 @@ class Clusters::BaseController < ApplicationController
helper_method :clusterable
- feature_category :kubernetes_management
+ feature_category :deployment_management
urgency :low, [
:index, :show, :environments, :cluster_status, :prometheus_proxy,
:destroy, :new_cluster_docs, :connect, :new, :create_user
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 51150700860..873aa5e18dc 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -10,7 +10,6 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :authorize_read_cluster!, only: [:show, :index]
before_action :authorize_create_cluster!, only: [:connect]
before_action :authorize_update_cluster!, only: [:update]
- before_action :update_applications_status, only: [:cluster_status]
before_action :ensure_feature_enabled!, except: [:index, :new_cluster_docs]
helper_method :token_in_session
@@ -223,10 +222,6 @@ class Clusters::ClustersController < Clusters::BaseController
@expires_at_in_session ||=
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
-
- def update_applications_status
- @cluster.applications.each(&:schedule_status_update)
- end
end
Clusters::ClustersController.prepend_mod_with('Clusters::ClustersController')
diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb
index 6a84c436aae..84cbdda1581 100644
--- a/app/controllers/concerns/access_tokens_actions.rb
+++ b/app/controllers/concerns/access_tokens_actions.rb
@@ -68,7 +68,7 @@ module AccessTokensActions
# user in the resource without multiple queries.
resource.members.load
- @scopes = Gitlab::Auth.resource_bot_scopes
+ @scopes = Gitlab::Auth.available_scopes_for(resource)
@active_access_tokens = active_access_tokens
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 7e1ba49d442..2e6b21e41cb 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -53,6 +53,8 @@ module Integrations
:issues_events,
:issues_url,
:jenkins_url,
+ :jira_issue_prefix,
+ :jira_issue_regex,
:jira_issue_transition_automatic,
:jira_issue_transition_id,
:manual_configuration,
@@ -61,6 +63,7 @@ module Integrations
:namespace,
:new_issue_url,
:notify_only_broken_pipelines,
+ :package_name,
:password,
:priority,
:project_key,
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index d364daf93c3..a86a8a0415a 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -76,7 +76,7 @@ module IssuableActions
title_text: issuable.title,
description: view_context.markdown_field(issuable, :description),
description_text: issuable.description,
- task_status: issuable.task_status,
+ task_completion_status: issuable.task_completion_status,
lock_version: issuable.lock_version
}
@@ -190,7 +190,7 @@ module IssuableActions
end
def discussion_cache_context
- [current_user&.cache_key, project.team.human_max_access(current_user&.id)].join(':')
+ [current_user&.cache_key, project.team.human_max_access(current_user&.id), 'v2'].join(':')
end
def discussion_serializer
diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb
index ef58ab1972b..c66bf7c9e8c 100644
--- a/app/controllers/concerns/kas_cookie.rb
+++ b/app/controllers/concerns/kas_cookie.rb
@@ -3,6 +3,18 @@
module KasCookie
extend ActiveSupport::Concern
+ included do
+ content_security_policy_with_context do |p|
+ next unless ::Gitlab::Kas::UserAccess.enabled?
+
+ kas_url = ::Gitlab::Kas.tunnel_url
+ next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception
+
+ kas_url += '/' unless kas_url.end_with?('/')
+ p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url)
+ end
+ end
+
def set_kas_cookie
return unless ::Gitlab::Kas::UserAccess.enabled?
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 5ed2b2a82eb..fc33770b4d8 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -5,30 +5,6 @@ module ProductAnalyticsTracking
include RedisTracking
extend ActiveSupport::Concern
- MIGRATED_EVENTS = %w[
- g_analytics_valuestream
- i_search_paid
- i_search_total
- i_search_advanced
- i_ecosystem_jira_service_list_issues
- users_viewing_analytics_group_devops_adoption
- i_analytics_dev_ops_adoption
- i_analytics_dev_ops_score
- p_analytics_merge_request
- i_analytics_instance_statistics
- g_analytics_contribution
- p_analytics_pipelines
- p_analytics_code_reviews
- p_analytics_valuestream
- p_analytics_insights
- p_analytics_issues
- p_analytics_repo
- g_analytics_insights
- g_analytics_issues
- g_analytics_productivity
- i_analytics_cohorts
- ].freeze
-
class_methods do
def track_event(*controller_actions, name:, action: nil, label: nil, conditions: nil, destinations: [:redis_hll], &block)
custom_conditions = [:trackable_html_request?, *conditions]
@@ -44,7 +20,7 @@ module ProductAnalyticsTracking
def route_events_to(destinations, name, action, label, &block)
track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll)
- return unless destinations.include?(:snowplow) && event_enabled?(name)
+ return unless destinations.include?(:snowplow)
raise "action is required when destination is snowplow" unless action
raise "label is required when destination is snowplow" unless label
@@ -63,18 +39,4 @@ module ProductAnalyticsTracking
**optional_arguments
)
end
-
- def event_enabled?(event)
- return true if MIGRATED_EVENTS.include?(event)
-
- events_to_ff = {
- g_edit_by_sfe: :_phase4,
- g_compliance_dashboard: :_phase4,
- g_compliance_audit_events: :_phase4,
- i_compliance_audit_events: :_phase4,
- i_compliance_credential_inventory: :_phase4
- }
-
- Feature.enabled?("route_hll_to_snowplow#{events_to_ff[event.to_sym]}", tracking_namespace_source)
- end
end
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index f1f5a1179c9..fca394f9fe1 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -33,6 +33,6 @@ module RendersCommits
def valid_ref?(ref_name)
return true unless ref_name.present?
- Gitlab::GitRefValidator.validate(ref_name)
+ Gitlab::GitRefValidator.validate(ref_name, skip_head_ref_check: true)
end
end
diff --git a/app/controllers/concerns/renders_member_access.rb b/app/controllers/concerns/renders_member_access.rb
index 745830181c1..133d797c8ac 100644
--- a/app/controllers/concerns/renders_member_access.rb
+++ b/app/controllers/concerns/renders_member_access.rb
@@ -15,7 +15,8 @@ module RendersMemberAccess
method_name = "max_member_access_for_#{klass.name.underscore}_ids"
- current_user.public_send(method_name, collection.ids) # rubocop:disable GitlabSecurity/PublicSend
+ collection_ids = collection.try(:map, &:id) || collection.ids
+ current_user.public_send(method_name, collection_ids) # rubocop:disable GitlabSecurity/PublicSend
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb
index 739b2be3fe9..2d37bc3f9a5 100644
--- a/app/controllers/concerns/renders_projects_list.rb
+++ b/app/controllers/concerns/renders_projects_list.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
module RendersProjectsList
+ include RendersMemberAccess
+
def prepare_projects_for_rendering(projects)
preload_max_member_access_for_collection(Project, projects)
+ current_user.preloaded_member_roles_for_projects(projects) if current_user
# Call the count methods on every project, so the BatchLoader would load them all at
# once when the entities are rendered
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index e53d0bc65a0..db756ae336f 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -5,7 +5,7 @@ module UploadsActions
include Gitlab::Utils::StrongMemoize
include SendFileUpload
- UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon].freeze
+ UPLOAD_MOUNTS = %w[avatar attachment file logo pwa_icon header_logo favicon screenshot].freeze
included do
prepend_before_action :set_request_format_from_path_extension
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index 95deacdc5b9..80c65948fff 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -14,3 +14,5 @@ class Dashboard::ApplicationController < ApplicationController
@projects ||= current_user.authorized_projects.sorted_by_updated_desc.non_archived
end
end
+
+Dashboard::ApplicationController.prepend_mod
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 645b3eb9eb5..e26ac083622 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -66,8 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def load_projects(finder_params)
- @total_user_projects_count = ProjectsFinder.new(params: { non_public: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute
- @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute
+ @all_user_projects = ProjectsFinder.new(params: { non_public: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute
+ @all_starred_projects = ProjectsFinder.new(params: { starred: true, archived: false, not_aimed_for_deletion: true }, current_user: current_user).execute
finder_params[:use_cte] = true if use_cte_for_finder?
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 34745815f3d..eebcbe88ebf 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -88,8 +88,8 @@ class Explore::ProjectsController < Explore::ApplicationController
private
def load_project_counts
- @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
- @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
+ @all_user_projects = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
+ @all_starred_projects = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
end
def load_projects
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index 536c5e347e7..8a3183ba615 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -6,7 +6,7 @@ module GoogleApi
before_action :validate_session_key!
- feature_category :kubernetes_management
+ feature_category :deployment_management
urgency :low
##
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 685c8292787..d614cc1cb24 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -16,6 +16,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
before_action :authorize_read_group_member!, only: :index
+ before_action only: [:index] do
+ push_frontend_feature_flag(:service_accounts_crud, @group)
+ end
+
skip_before_action :check_two_factor_requirement, only: :leave
skip_cross_project_access_check :index, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite, :override
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 859bb0adb4e..f267c1cb857 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -2,14 +2,20 @@
class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_read_group_runners!, only: [:index, :show]
+ before_action :authorize_create_group_runners!, only: [:new, :register]
before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume]
- before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
+ before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register]
+
+ before_action only: [:index] do
+ push_frontend_feature_flag(:create_runner_workflow_for_namespace, group)
+ end
feature_category :runner
urgency :low
def index
@group_runner_registration_token = @group.runners_token if can?(current_user, :register_group_runners, group)
+ @group_new_runner_path = new_group_runner_path(@group) if can?(current_user, :create_runner, group)
Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group)
end
@@ -28,6 +34,16 @@ class Groups::RunnersController < Groups::ApplicationController
end
end
+ def new
+ render_404 unless create_runner_workflow_for_namespace_enabled?
+
+ @group_runner_registration_token = @group.runners_token
+ end
+
+ def register
+ render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available?
+ end
+
private
def runner
@@ -47,6 +63,16 @@ class Groups::RunnersController < Groups::ApplicationController
render_404
end
+
+ def authorize_create_group_runners!
+ return if can?(current_user, :create_runner, group)
+
+ render_404
+ end
+
+ def create_runner_workflow_for_namespace_enabled?
+ Feature.enabled?(:create_runner_workflow_for_namespace, group)
+ end
end
Groups::RunnersController.prepend_mod
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index 2bf5c95937b..3ae1ae824a0 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -46,10 +46,9 @@ module Groups
@application.renew_secret
if @application.save
- flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
- render :show
+ render json: { secret: @application.plaintext_secret }
else
- redirect_to group_settings_application_url(@group, @application)
+ render json: { errors: @application.errors }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb
index 4f858cd130a..125c8fde004 100644
--- a/app/controllers/groups/usage_quotas_controller.rb
+++ b/app/controllers/groups/usage_quotas_controller.rb
@@ -6,7 +6,7 @@ module Groups
before_action :verify_usage_quotas_enabled!
before_action :push_frontend_feature_flags
- feature_category :subscription_cost_management
+ feature_category :consumables_cost_management
urgency :low
def index
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 7aea5e1a5c9..fad3a6ab9f5 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -6,7 +6,7 @@ module Groups
skip_cross_project_access_check :show, :update
- feature_category :pipeline_composition
+ feature_category :secrets_management
urgency :low, [:show]
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index a0c82998108..b7578bcf465 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -32,18 +32,12 @@ class GroupsController < Groups::ApplicationController
before_action :check_export_rate_limit!, only: [:export, :download_export]
- before_action :track_experiment_event, only: [:new]
-
before_action only: :issues do
push_frontend_feature_flag(:or_issuable_queries, group)
push_frontend_feature_flag(:frontend_caching, group)
push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?)
end
- before_action only: :show do
- push_frontend_feature_flag(:show_group_readme, group)
- end
-
helper_method :captcha_required?
skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects
@@ -402,12 +396,6 @@ class GroupsController < Groups::ApplicationController
captcha_enabled? && !params[:parent_id]
end
- def track_experiment_event
- return if params[:parent_id]
-
- experiment(:require_verification_for_namespace_creation, user: current_user).track(:start_create_group)
- end
-
def group_feature_attributes
[]
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index f0a80593926..bd0c0976729 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -11,6 +11,10 @@ class Import::GithubController < Import::BaseController
before_action :provider_auth, only: [:status, :realtime_changes, :create]
before_action :expire_etag_cache, only: [:status, :create]
+ before_action only: [:status] do
+ push_frontend_feature_flag(:import_details_page)
+ end
+
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded
@@ -67,6 +71,10 @@ class Import::GithubController < Import::BaseController
end
end
+ def details
+ render_404 unless Feature.enabled?(:import_details_page)
+ end
+
def create
result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider_name)
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 0a2c98af8ec..8a8ae38c6f3 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -83,7 +83,7 @@ class InvitesController < ApplicationController
def authenticate_user!
return if current_user
- store_location_for(:user, invite_landing_url) if member
+ store_location_for(:user, invite_details[:path]) if member
if user_sign_up?
set_session_invite_params
@@ -120,10 +120,6 @@ class InvitesController < ApplicationController
end
end
- def invite_landing_url
- root_url + invite_details[:path]
- end
-
def invite_details
@invite_details ||= case member.source
when Project
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 7a31738188a..2d5421f9f74 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -45,10 +45,9 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
@application.renew_secret
if @application.save
- flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
- render :show
+ render json: { secret: @application.plaintext_secret }
else
- redirect_to oauth_application_url(@application)
+ render json: { errors: @application.errors }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index daed4023d02..b9964e8ca01 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -183,7 +183,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
persist_accepted_terms_if_required(user) if new_user
store_after_sign_up_path_for_user if intent_to_register?
- sign_in_and_redirect_or_confirm_identity(user, auth_user, new_user)
+ sign_in_and_redirect_or_verify_identity(user, auth_user, new_user)
end
else
fail_login(user)
@@ -315,7 +315,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
# overridden in EE
- def sign_in_and_redirect_or_confirm_identity(user, _, _)
+ def sign_in_and_redirect_or_verify_identity(user, _, _)
sign_in_and_redirect(user, event: :authentication)
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index ae757c30d1c..564a84a0829 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -11,6 +11,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
end
def new
+ @integration_name = integration_name
end
def create
@@ -65,4 +66,10 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
def chat_names
@chat_names ||= current_user.chat_names
end
+
+ def integration_name
+ s_('Integrations|Mattermost slash commands')
+ end
end
+
+Profiles::ChatNamesController.prepend_mod
diff --git a/app/controllers/profiles/saved_replies_controller.rb b/app/controllers/profiles/comment_templates_controller.rb
index 5ac5d645efb..d6725c27f76 100644
--- a/app/controllers/profiles/saved_replies_controller.rb
+++ b/app/controllers/profiles/comment_templates_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Profiles
- class SavedRepliesController < Profiles::ApplicationController
+ class CommentTemplatesController < Profiles::ApplicationController
feature_category :user_profile
before_action do
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 8f482cf6e2f..bc6e67a3a7d 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -169,18 +169,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
gon.push(webauthn: { options: options, app_id: u2f_app_id })
end
- # Adds delete path to u2f registrations
- # to reduce logic in view template
- def u2f_registrations
- current_user.u2f_registrations.map do |u2f_registration|
- {
- name: u2f_registration.name,
- created_at: u2f_registration.created_at,
- delete_path: profile_u2f_registration_path(u2f_registration)
- }
- end
- end
-
def webauthn_registrations
current_user.webauthn_registrations.map do |webauthn_registration|
{
@@ -235,10 +223,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@qr_code = build_qr_code
@account_string = account_string
- if Feature.enabled?(:webauthn)
- setup_webauthn_registration
- else
- setup_u2f_registration
- end
+ setup_webauthn_registration
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 70487915707..da15b393e6c 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -10,9 +10,6 @@ class ProfilesController < Profiles::ApplicationController
check_rate_limit!(:profile_update_username, scope: current_user)
end
skip_before_action :require_email, only: [:show, :update]
- before_action do
- push_frontend_feature_flag(:webauthn)
- end
feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token,
:reset_static_object_token, :update_username]
@@ -133,6 +130,7 @@ class ProfilesController < Profiles::ApplicationController
:organization,
:private_profile,
:include_private_contributions,
+ :achievements_enabled,
:timezone,
:job_title,
:pronouns,
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index d41b347dc5a..dbc82f5b314 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -8,62 +8,49 @@ class Projects::BlameController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_read_code!
+ before_action :load_blob
feature_category :source_code_management
urgency :low, [:show]
def show
- @blob = @repository.blob_at(@commit.id, @path)
-
- unless @blob
- return redirect_to_tree_root_for_missing_path(@project, @ref, @path)
- end
-
- environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
- environment_params[:find_latest] = true
- @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
+ load_environment
+ load_blame
+ end
- permitted_params = params.permit(:page, :no_pagination, :streaming)
- blame_service = Projects::BlameService.new(@blob, @commit, permitted_params)
+ def page
+ load_environment
+ load_blame
- @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
+ render partial: 'page'
+ end
- @entire_blame_path = full_blame_path(no_pagination: true)
- @blame_pages_url = blame_pages_url(permitted_params)
- if blame_service.streaming_possible
- @entire_blame_path = full_blame_path(streaming: true)
- end
+ private
- @streaming_enabled = blame_service.streaming_enabled
- @blame_pagination = blame_service.pagination unless @streaming_enabled
+ def load_blob
+ @blob = @repository.blob_at(@commit.id, @path)
- @blame_per_page = blame_service.per_page
+ return if @blob
- render locals: { total_extra_pages: blame_service.total_extra_pages }
+ redirect_to_tree_root_for_missing_path(@project, @ref, @path)
end
- def page
- @blob = @repository.blob_at(@commit.id, @path)
-
+ def load_environment
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
environment_params[:find_latest] = true
@environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
-
- blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :streaming))
-
- @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
-
- render partial: 'page'
end
- private
+ def load_blame
+ @blame_mode = Gitlab::Git::BlameMode.new(@commit.project, blame_params)
+ @blame_pagination = Gitlab::Git::BlamePagination.new(@blob, @blame_mode, blame_params)
- def full_blame_path(params)
- namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, **params)
+ blame = Gitlab::Blame.new(@blob, @commit, range: @blame_pagination.blame_range)
+ @blame = Gitlab::View::Presenter::Factory.new(blame, project: @project, path: @path, page: @blame_pagination.page).fabricate!
end
- def blame_pages_url(params)
- namespace_project_blame_page_url(namespace_id: @project.namespace, project_id: @project, id: @id, **params)
+ def blame_params
+ params.permit(:page, :no_pagination, :streaming)
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 2d0c4a0a6c1..53c6676b62b 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -49,7 +49,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:file_line_blame, @project)
+ push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -101,6 +101,7 @@ class Projects::BlobController < Projects::ApplicationController
)
rescue Files::UpdateService::FileChangedError
@conflict = true
+ @different_project = different_project?
render :edit
end
diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb
index e0c9763abb6..bd58bd3e470 100644
--- a/app/controllers/projects/cluster_agents_controller.rb
+++ b/app/controllers/projects/cluster_agents_controller.rb
@@ -3,23 +3,15 @@
class Projects::ClusterAgentsController < Projects::ApplicationController
include KasCookie
- before_action :authorize_can_read_cluster_agent!
+ before_action :authorize_read_cluster_agent!
before_action :set_kas_cookie, only: [:show], if: -> { current_user }
- feature_category :kubernetes_management
+ feature_category :deployment_management
urgency :low
def show
@agent_name = params[:name]
end
-
- private
-
- def authorize_can_read_cluster_agent!
- return if can?(current_user, :read_cluster, project)
-
- access_denied!
- end
end
Projects::ClusterAgentsController.prepend_mod_with('Projects::ClusterAgentsController')
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index a86a0fb3bd2..8aca6a3fd5b 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -48,7 +48,11 @@ class Projects::CommitController < Projects::ApplicationController
end
def diff_files
- render template: 'projects/commit/diff_files', layout: false, locals: { diffs: @diffs, environment: @environment }
+ respond_to do |format|
+ format.html do
+ render template: 'projects/commit/diff_files', layout: false, locals: { diffs: @diffs, environment: @environment }
+ end
+ end
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index dbed5adf2e8..da0bda19602 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -6,10 +6,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include CycleAnalyticsParams
include GracefulTimeoutHandling
include ProductAnalyticsTracking
+ include Gitlab::Utils::StrongMemoize
extend ::Gitlab::Utils::Override
before_action :authorize_read_cycle_analytics!
- before_action :load_value_stream, only: :show
track_event :show,
name: 'p_analytics_valuestream',
@@ -24,6 +24,11 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups)
push_licensed_feature(:group_level_analytics_dashboard) if project.licensed_feature_available?(:group_level_analytics_dashboard)
push_frontend_feature_flag(:group_analytics_dashboards_page, @project.namespace)
+
+ if project.licensed_feature_available?(:cycle_analytics_for_projects)
+ push_licensed_feature(:cycle_analytics_for_projects)
+ push_frontend_feature_flag(:vsa_group_and_project_parity, @project)
+ end
end
def show
@@ -46,12 +51,13 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
override :all_cycle_analytics_params
def all_cycle_analytics_params
- super.merge({ namespace: @project.project_namespace, value_stream: @value_stream })
+ super.merge({ namespace: @project.project_namespace, value_stream: value_stream })
end
- def load_value_stream
- @value_stream = Analytics::CycleAnalytics::ValueStream.build_default_value_stream(@project.project_namespace)
+ def value_stream
+ Analytics::CycleAnalytics::ValueStream.build_default_value_stream(@project.project_namespace)
end
+ strong_memoize_attr :value_stream
def cycle_analytics_json
{
@@ -69,3 +75,5 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
project
end
end
+
+Projects::CycleAnalyticsController.prepend_mod
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 22a42d22914..9cdbd2a30f6 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -82,7 +82,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
def create_params
create_params = params.require(:deploy_key)
- .permit(:key, :title, deploy_keys_projects_attributes: [:can_push])
+ .permit(:key, :title, :expires_at, deploy_keys_projects_attributes: [:can_push])
create_params.dig(:deploy_keys_projects_attributes, '0')&.merge!(project_id: @project.id)
create_params
end
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 8e4fbf24ca2..3842a88d15b 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -10,7 +10,6 @@ class Projects::IncidentsController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:incident_event_tags, project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 6e38de8b0ea..efe88d17cab 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -46,7 +46,8 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:preserve_unchanged_markdown, project)
- push_frontend_feature_flag(:content_editor_on_issues, project)
+ push_frontend_feature_flag(:content_editor_on_issues, project&.group)
+ push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project)
push_frontend_feature_flag(:saved_replies, current_user)
end
@@ -66,7 +67,6 @@ class Projects::IssuesController < Projects::ApplicationController
push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
push_frontend_feature_flag(:epic_widget_edit_confirmation, project)
- push_frontend_feature_flag(:incident_event_tags, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index be44c78ac9d..6d1b1ced4eb 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -7,6 +7,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
feature_category :code_review_workflow
+ before_action do
+ push_frontend_feature_flag(:content_editor_on_issues, project&.group)
+ push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
+ end
+
private
def merge_request
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index a204023e34d..ef944dad6e9 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -33,15 +33,20 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show, :diffs] do
- push_frontend_feature_flag(:content_editor_on_issues, project)
+ push_frontend_feature_flag(:content_editor_on_issues, project&.group)
+ push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:refactor_security_extension, @project)
- push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
+ push_frontend_feature_flag(:deprecate_vulnerabilities_feedback, @project)
push_frontend_feature_flag(:moved_mr_sidebar, project)
+ push_frontend_feature_flag(:single_file_file_by_file, project)
push_frontend_feature_flag(:mr_experience_survey, project)
push_frontend_feature_flag(:realtime_mr_status_change, project)
push_frontend_feature_flag(:saved_replies, current_user)
+ push_frontend_feature_flag(:code_quality_inline_drawer, project)
+ push_frontend_feature_flag(:hide_create_issue_resolve_all, project)
+ push_frontend_feature_flag(:auto_merge_labels_mr_widget, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
@@ -264,6 +269,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
status = merge!
+ Gitlab::ApplicationContext.push(merge_action_status: status.to_s)
+
if @merge_request.merge_error
render json: { status: status, merge_error: @merge_request.merge_error }
else
diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb
index b702edb858e..e534000f494 100644
--- a/app/controllers/projects/ml/candidates_controller.rb
+++ b/app/controllers/projects/ml/candidates_controller.rb
@@ -3,18 +3,29 @@
module Projects
module Ml
class CandidatesController < ApplicationController
- before_action :check_feature_flag
+ before_action :check_feature_flag, :set_candidate
feature_category :mlops
- def show
- @candidate = ::Ml::Candidate.with_project_id_and_iid(@project.id, params['iid'])
+ def show; end
- render_404 unless @candidate.present?
+ def destroy
+ @experiment = @candidate.experiment
+ @candidate.destroy!
+
+ redirect_to project_ml_experiment_path(@project, @experiment.iid),
+ status: :found,
+ notice: s_("MlExperimentTracking|Candidate removed")
end
private
+ def set_candidate
+ @candidate = ::Ml::Candidate.with_project_id_and_iid(@project.id, params['iid'])
+
+ render_404 unless @candidate.present?
+ end
+
def check_feature_flag
render_404 unless Feature.enabled?(:ml_experiment_tracking, @project)
end
diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb
index 00b965542f6..dece3f98c57 100644
--- a/app/controllers/projects/ml/experiments_controller.rb
+++ b/app/controllers/projects/ml/experiments_controller.rb
@@ -6,6 +6,7 @@ module Projects
include Projects::Ml::ExperimentsHelper
before_action :check_feature_flag
+ before_action :set_experiment, only: [:show, :destroy]
feature_category :mlops
@@ -22,21 +23,34 @@ module Projects
end
def show
- @experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:id])
-
- return redirect_to project_ml_experiments_path(@project) unless @experiment.present?
-
find_params = params
.transform_keys(&:underscore)
.permit(:name, :order_by, :sort, :order_by_type)
- paginator = CandidateFinder
- .new(@experiment, find_params)
- .execute
- .keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE)
+ finder = CandidateFinder.new(@experiment, find_params)
- @candidates = paginator.records.each(&:artifact_lazy)
- @page_info = page_info(paginator)
+ respond_to do |format|
+ format.csv do
+ csv_data = ::Ml::CandidatesCsvPresenter.new(finder.execute).present
+
+ send_data(csv_data, type: 'text/csv; charset=utf-8', filename: 'candidates.csv')
+ end
+
+ format.html do
+ paginator = finder.execute.keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE)
+
+ @candidates = paginator.records
+ @page_info = page_info(paginator)
+ end
+ end
+ end
+
+ def destroy
+ @experiment.destroy
+
+ redirect_to project_ml_experiments_path(@project),
+ status: :found,
+ notice: s_("MlExperimentTracking|Experiment removed")
end
private
@@ -44,6 +58,12 @@ module Projects
def check_feature_flag
render_404 unless Feature.enabled?(:ml_experiment_tracking, @project)
end
+
+ def set_experiment
+ @experiment = ::Ml::Experiment.by_project_id_and_iid(@project.id, params[:iid])
+
+ render_404 unless @experiment
+ end
end
end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 13c2a3ab750..332d33b8e52 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -77,7 +77,7 @@ class Projects::PagesController < Projects::ApplicationController
def project_params_attributes
attributes = %i[pages_https_only]
- return attributes unless Feature.enabled?(:pages_unique_domain)
+ return attributes unless Feature.enabled?(:pages_unique_domain, @project)
attributes + [
project_setting_attributes: [
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 6fdd4906613..a8107a46b4f 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -9,20 +9,19 @@ class Projects::PipelinesController < Projects::ApplicationController
urgency :low, [
:index, :new, :builds, :show, :failures, :create,
:stage, :retry, :dag, :cancel, :test_report,
- :charts, :config_variables, :destroy, :status
+ :charts, :destroy, :status
]
before_action :disable_query_limiting, only: [:create, :retry]
- before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
+ before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :set_pipeline_path, only: [:show]
before_action :authorize_read_pipeline!
before_action :authorize_read_build!, only: [:index, :show]
before_action :authorize_read_ci_cd_analytics!, only: [:charts]
- before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables]
+ before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy]
- before_action :push_frontend_feature_flags, only: [:show]
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
@@ -46,7 +45,7 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000
feature_category :continuous_integration, [
- :charts, :show, :config_variables, :stage, :cancel, :retry,
+ :charts, :show, :stage, :cancel, :retry,
:builds, :dag, :failures, :status,
:index, :create, :new, :destroy
]
@@ -62,9 +61,7 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipelines_count = limited_pipelines_count(project)
respond_to do |format|
- format.html do
- enable_runners_availability_section_experiment
- end
+ format.html
format.json do
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
@@ -217,18 +214,6 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
- def config_variables
- respond_to do |format|
- format.json do
- # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint.
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065
- result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha])
-
- result.nil? ? head(:no_content) : render(json: result)
- end
- end
- end
-
def downloadable_artifacts
render json: Ci::DownloadableArtifactSerializer.new(
project: project,
@@ -246,7 +231,7 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipelines,
disable_coverage: true,
preload: true,
- disable_manual_and_scheduled_actions: Feature.enabled?(:lazy_load_pipeline_dropdown_actions, @project)
+ disable_manual_and_scheduled_actions: true
)
end
@@ -332,17 +317,6 @@ class Projects::PipelinesController < Projects::ApplicationController
params.permit(:scope, :username, :ref, :status, :source)
end
- def enable_runners_availability_section_experiment
- return unless current_user
- return unless can?(current_user, :create_pipeline, project)
- return if @pipelines_count.to_i > 0
- return if helpers.has_gitlab_ci?(project)
-
- experiment(:runners_availability_section, namespace: project.root_ancestor) do |e|
- e.candidate {}
- end
- end
-
def should_track_ci_cd_pipelines?
params[:chart].blank? || params[:chart] == 'pipelines'
end
@@ -370,10 +344,6 @@ class Projects::PipelinesController < Projects::ApplicationController
def tracking_project_source
project
end
-
- def push_frontend_feature_flags
- push_frontend_feature_flag(:refactor_ci_minutes_consumption, @project)
- 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 543ffa637e1..f4b96177b0f 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -47,7 +47,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
def membershipable_members
- query_members_via_project_namespace_enabled? ? project.namespace_members : project.members
+ project.namespace_members
end
def plain_source_type
@@ -67,15 +67,11 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
def members_and_requesters
- query_members_via_project_namespace_enabled? ? project.namespace_members_and_requesters : super
+ project.namespace_members_and_requesters
end
def requesters
- query_members_via_project_namespace_enabled? ? project.namespace_requesters : super
- end
-
- def query_members_via_project_namespace_enabled?
- Feature.enabled?(:project_members_index_by_project_namespace, project)
+ project.namespace_requesters
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index f5588a35ad5..28ae730eda5 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -14,6 +14,7 @@ module Projects
before_action do
push_frontend_feature_flag(:ci_variables_pages, current_user)
+ push_frontend_feature_flag(:ci_limit_environment_scope, @project)
end
helper_method :highlight_badge
@@ -131,7 +132,7 @@ module Projects
@shared_runners_count = active_shared_runners.count
@shared_runners = active_shared_runners.page(params[:shared_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags
- parent_group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id)
+ parent_group_runners = ::Ci::Runner.belonging_to_parent_groups_of_project(@project.id)
@group_runners_count = parent_group_runners.count
@group_runners = parent_group_runners.page(params[:group_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index a8b54933487..0631c02355e 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:file_line_blame, @project)
+ push_frontend_feature_flag(:synchronize_fork, @project.fork_source)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
index d3757eaf481..7037cf8811a 100644
--- a/app/controllers/projects/usage_quotas_controller.rb
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -1,14 +1,18 @@
# frozen_string_literal: true
-class Projects::UsageQuotasController < Projects::ApplicationController
- before_action :authorize_read_usage_quotas!
+module Projects
+ class UsageQuotasController < Projects::ApplicationController
+ before_action :authorize_read_usage_quotas!
- layout "project_settings"
+ layout "project_settings"
- feature_category :subscription_cost_management
- urgency :low
+ feature_category :consumables_cost_management
+ urgency :low
- def index
- @hide_search_settings = true
+ def index
+ @hide_search_settings = true
+ end
end
end
+
+Projects::UsageQuotasController.prepend_mod
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index e50ddf75183..f7542d68642 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -3,7 +3,7 @@
class Projects::VariablesController < Projects::ApplicationController
before_action :authorize_admin_build!
- feature_category :pipeline_composition
+ feature_category :secrets_management
urgency :low, [:show, :update]
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index 34a71dbbb91..7da31c199a1 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -1,6 +1,12 @@
# frozen_string_literal: true
class Projects::WorkItemsController < Projects::ApplicationController
+ include WorkhorseAuthorization
+ extend Gitlab::Utils::Override
+
+ EXTENSION_ALLOWLIST = %w[csv].map(&:downcase).freeze
+
+ before_action :authorize_import_access!, only: [:import_csv, :authorize] # rubocop:disable Rails/LexicallyScopedActionFilter
before_action do
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?)
@@ -9,7 +15,57 @@ class Projects::WorkItemsController < Projects::ApplicationController
end
feature_category :team_planning
+ urgency :high, [:authorize]
urgency :low
+
+ def import_csv
+ file = import_params[:file]
+ return render json: { errors: invalid_file_message }, status: :bad_request unless file_is_valid?(file)
+
+ result = WorkItems::PrepareImportCsvService.new(project, current_user, file: file).execute
+
+ if result.status == :error
+ render json: { errors: result.message }, status: :bad_request
+ else
+ render json: { message: result.message }, status: :ok
+ end
+ end
+
+ private
+
+ def import_params
+ params.permit(:file)
+ end
+
+ def authorize_import_access!
+ can_import = can?(current_user, :import_work_items, project)
+ import_csv_feature_available = Feature.enabled?(:import_export_work_items_csv, project)
+ return if can_import && import_csv_feature_available
+
+ if current_user || action_name == 'authorize'
+ render_404
+ else
+ authenticate_user!
+ end
+ end
+
+ def invalid_file_message
+ supported_file_extensions = ".#{EXTENSION_ALLOWLIST.join(', .')}"
+ format(_("The uploaded file was invalid. Supported file extensions are %{extensions}."),
+ { extensions: supported_file_extensions })
+ end
+
+ def uploader_class
+ FileUploader
+ end
+
+ def maximum_size
+ Gitlab::CurrentSettings.max_attachment_size.megabytes
+ end
+
+ def file_extension_allowlist
+ EXTENSION_ALLOWLIST
+ end
end
Projects::WorkItemsController.prepend_mod
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index c12caecdc23..a6bc754d09e 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -38,8 +38,7 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:file_line_blame, @project)
- push_frontend_feature_flag(:synchronize_fork, @project)
+ push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
@@ -209,7 +208,7 @@ class ProjectsController < Projects::ApplicationController
end
def new_issuable_address
- return render_404 unless Gitlab::IncomingEmail.supports_issue_creation?
+ return render_404 unless Gitlab::Email::IncomingEmail.supports_issue_creation?
current_user.reset_incoming_email_token!
render json: { new_address: @project.new_issuable_address(current_user, params[:issuable_type]) }
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index b4eee3549a0..70698c0dcb2 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -40,6 +40,7 @@ class RegistrationsController < Devise::RegistrationsController
set_resource_fields
super do |new_user|
+ record_arkose_data
accept_pending_invitations if new_user.persisted?
persist_accepted_terms_if_required(new_user)
@@ -135,8 +136,10 @@ class RegistrationsController < Devise::RegistrationsController
# after user confirms and comes back, he will be redirected
store_location_for(:redirect, after_sign_up_path)
- if custom_confirmation_enabled?
+ if identity_verification_enabled?
session[:verification_user_id] = resource.id # This is needed to find the user on the identity verification page
+ User.sticking.stick_or_unstick_request(request.env, :user, resource.id)
+
return identity_verification_redirect_path
end
@@ -290,11 +293,16 @@ class RegistrationsController < Devise::RegistrationsController
current_user
end
- def identity_verification_redirect_path
+ def record_arkose_data
# overridden by EE module
end
- def custom_confirmation_enabled?
+ def identity_verification_enabled?
+ # overridden by EE module
+ false
+ end
+
+ def identity_verification_redirect_path
# overridden by EE module
end
diff --git a/app/controllers/time_tracking/timelogs_controller.rb b/app/controllers/time_tracking/timelogs_controller.rb
new file mode 100644
index 00000000000..a2cac071796
--- /dev/null
+++ b/app/controllers/time_tracking/timelogs_controller.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module TimeTracking
+ class TimelogsController < ApplicationController
+ feature_category :team_planning
+ urgency :low
+
+ def index
+ render_404 unless Feature.enabled?(:global_time_tracking_report, current_user)
+ end
+ end
+end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index ea99aa12350..1a966739401 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -16,6 +16,7 @@ class UploadsController < ApplicationController
"projects/topic" => Projects::Topic,
'alert_management_metric_image' => ::AlertManagement::MetricImage,
"achievements/achievement" => Achievements::Achievement,
+ "abuse_report" => AbuseReport,
nil => PersonalSnippet
}.freeze
diff --git a/app/controllers/users/pins_controller.rb b/app/controllers/users/pins_controller.rb
new file mode 100644
index 00000000000..81709dd4a2b
--- /dev/null
+++ b/app/controllers/users/pins_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Users
+ class PinsController < ApplicationController
+ feature_category :navigation
+ respond_to :json
+
+ def update
+ panel = pins_params[:panel]
+ pinned_nav_items = current_user.pinned_nav_items.merge({ panel => pins_params[:menu_item_ids] })
+ if current_user.update(pinned_nav_items: pinned_nav_items)
+ render json: current_user.pinned_nav_items[panel].to_json
+ else
+ head :bad_request
+ end
+ end
+
+ private
+
+ def pins_params
+ params.permit(:panel, menu_item_ids: [])
+ end
+ end
+end
diff --git a/app/events/packages/package_created_event.rb b/app/events/packages/package_created_event.rb
new file mode 100644
index 00000000000..5818a1ad19f
--- /dev/null
+++ b/app/events/packages/package_created_event.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Packages
+ class PackageCreatedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'project_id' => { 'type' => 'integer' },
+ 'name' => { 'type' => 'string' },
+ 'version' => { 'type' => %w[string null] },
+ 'package_type' => { 'type' => 'string', 'enum' => ::Packages::Package.package_types.keys },
+ 'id' => { 'type' => 'integer' }
+ },
+ 'required' => %w[project_id id name package_type]
+ }
+ end
+
+ def generic?
+ data[:package_type] == 'generic'
+ end
+ end
+end
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
deleted file mode 100644
index 914c5c4a29e..00000000000
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
- control { false }
- candidate { true }
-
- exclude :existing_user
-
- EXPERIMENT_START_DATE = Date.new(2022, 1, 31)
-
- def candidate?
- run
- end
-
- private
-
- def existing_user
- return false unless user_or_actor
-
- user_or_actor.created_at < EXPERIMENT_START_DATE
- end
-end
diff --git a/app/experiments/security_actions_continuous_onboarding_experiment.rb b/app/experiments/security_actions_continuous_onboarding_experiment.rb
deleted file mode 100644
index 6adfbedc744..00000000000
--- a/app/experiments/security_actions_continuous_onboarding_experiment.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class SecurityActionsContinuousOnboardingExperiment < ApplicationExperiment
- def control_behavior
- end
-
- def candidate_behavior
- end
-end
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
deleted file mode 100644
index 0a5778950fa..00000000000
--- a/app/experiments/security_reports_mr_widget_prompt_experiment.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-
-class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment
- control {}
- candidate {}
-end
diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb
index c3159198261..6a6d0413194 100644
--- a/app/finders/abuse_reports_finder.rb
+++ b/app/finders/abuse_reports_finder.rb
@@ -3,6 +3,7 @@
class AbuseReportsFinder
attr_reader :params, :reports
+ DEFAULT_STATUS_FILTER = 'open'
DEFAULT_SORT = 'created_at_desc'
ALLOWED_SORT = [DEFAULT_SORT, *%w[created_at_asc updated_at_desc updated_at_asc]].freeze
@@ -30,9 +31,13 @@ class AbuseReportsFinder
end
def filter_by_status
+ return unless Feature.enabled?(:abuse_reports_list)
return unless params[:status].present?
- case params[:status]
+ status = params[:status]
+ status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys)
+
+ case status
when 'open'
@reports = @reports.open
when 'closed'
diff --git a/app/finders/access_requests_finder.rb b/app/finders/access_requests_finder.rb
index 7b98df68f29..140d68cfe91 100644
--- a/app/finders/access_requests_finder.rb
+++ b/app/finders/access_requests_finder.rb
@@ -18,11 +18,7 @@ class AccessRequestsFinder
def execute!(current_user)
raise Gitlab::Access::AccessDeniedError unless can_see_access_requests?(current_user)
- if Feature.enabled?(:project_members_index_by_project_namespace, source)
- source.namespace_requesters
- else
- source.requesters
- end
+ source.namespace_requesters
end
private
diff --git a/app/finders/achievements/achievements_finder.rb b/app/finders/achievements/achievements_finder.rb
new file mode 100644
index 00000000000..98bd12afcd4
--- /dev/null
+++ b/app/finders/achievements/achievements_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Achievements
+ class AchievementsFinder
+ attr_reader :namespace, :params
+
+ def initialize(namespace, params = {})
+ @namespace = namespace
+ @params = params
+ end
+
+ def execute
+ achievements = namespace.achievements
+ by_ids(achievements)
+ end
+
+ private
+
+ def by_ids(achievements)
+ return achievements unless ids?
+
+ achievements.id_in(params[:ids])
+ end
+
+ def ids?
+ params[:ids].present?
+ end
+ end
+end
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 99e68991836..7ecf5c98ac0 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -11,8 +11,8 @@ module Autocomplete
LIMIT = 20
attr_reader :current_user, :project, :group, :search, :skip_users,
- :author_id, :todo_filter, :todo_state_filter,
- :filter_by_current_user, :states
+ :author_id, :todo_filter, :todo_state_filter,
+ :filter_by_current_user, :states
def initialize(params:, current_user:, project:, group:)
@current_user = current_user
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index bc1dcb3ad5f..5f03ae77338 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -74,7 +74,7 @@ module Ci
end
def project_runners
- raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_project, @project)
+ raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :read_project_runners, @project)
@runners = ::Ci::Runner.owned_or_instance_wide(@project.id)
end
diff --git a/app/finders/clusters/agent_authorizations_finder.rb b/app/finders/clusters/agent_authorizations_finder.rb
deleted file mode 100644
index 70c0868cc7f..00000000000
--- a/app/finders/clusters/agent_authorizations_finder.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- class AgentAuthorizationsFinder
- def initialize(project)
- @project = project
- end
-
- def execute
- # closest, most-specific authorization for a given agent wins
- (project_authorizations + implicit_authorizations + group_authorizations)
- .uniq(&:agent_id)
- end
-
- private
-
- attr_reader :project
-
- def implicit_authorizations
- project.cluster_agents.map do |agent|
- Clusters::Agents::ImplicitAuthorization.new(agent: agent)
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def project_authorizations
- namespace_ids = project.group ? all_namespace_ids : project.namespace_id
-
- Clusters::Agents::ProjectAuthorization
- .where(project_id: project.id)
- .joins(agent: :project)
- .preload(agent: :project)
- .where(cluster_agents: { projects: { namespace_id: namespace_ids } })
- .with_available_ci_access_fields(project)
- .to_a
- end
-
- def group_authorizations
- return [] unless project.group
-
- authorizations = Clusters::Agents::GroupAuthorization.arel_table
-
- ordered_ancestors_cte = Gitlab::SQL::CTE.new(
- :ordered_ancestors,
- project.group.self_and_ancestors(hierarchy_order: :asc).reselect(:id)
- )
-
- cte_join_sources = authorizations.join(ordered_ancestors_cte.table).on(
- authorizations[:group_id].eq(ordered_ancestors_cte.table[:id])
- ).join_sources
-
- Clusters::Agents::GroupAuthorization
- .with(ordered_ancestors_cte.to_arel)
- .joins(cte_join_sources)
- .joins(agent: :project)
- .with_available_ci_access_fields(project)
- .where(projects: { namespace_id: all_namespace_ids })
- .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)'))
- .select('DISTINCT ON (agent_id) agent_group_authorizations.*')
- .preload(agent: :project)
- .to_a
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def all_namespace_ids
- project.root_ancestor.self_and_descendants.select(:id)
- end
- end
-end
diff --git a/app/finders/clusters/agent_tokens_finder.rb b/app/finders/clusters/agent_tokens_finder.rb
index 72692777bc6..0e777564db5 100644
--- a/app/finders/clusters/agent_tokens_finder.rb
+++ b/app/finders/clusters/agent_tokens_finder.rb
@@ -11,7 +11,7 @@ module Clusters
end
def execute
- return ::Clusters::AgentToken.none unless can_read_cluster_agents?
+ return ::Clusters::AgentToken.none unless can_read_cluster_agent?
agent.agent_tokens.then { |agent_tokens| by_status(agent_tokens) }
end
@@ -24,8 +24,8 @@ module Clusters
params[:status].present? ? agent_tokens.with_status(params[:status]) : agent_tokens
end
- def can_read_cluster_agents?
- current_user&.can?(:read_cluster, agent&.project)
+ def can_read_cluster_agent?
+ current_user&.can?(:read_cluster_agent, agent)
end
end
end
diff --git a/app/finders/clusters/agents/authorizations/ci_access/finder.rb b/app/finders/clusters/agents/authorizations/ci_access/finder.rb
new file mode 100644
index 00000000000..97d378669a4
--- /dev/null
+++ b/app/finders/clusters/agents/authorizations/ci_access/finder.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class Finder
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ # closest, most-specific authorization for a given agent wins
+ (project_authorizations + implicit_authorizations + group_authorizations)
+ .uniq(&:agent_id)
+ end
+
+ private
+
+ attr_reader :project
+
+ def implicit_authorizations
+ project.cluster_agents.map do |agent|
+ Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization.new(agent: agent)
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_authorizations
+ namespace_ids = project.group ? all_namespace_ids : project.namespace_id
+
+ Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization
+ .where(project_id: project.id)
+ .joins(agent: :project)
+ .preload(agent: :project)
+ .where(cluster_agents: { projects: { namespace_id: namespace_ids } })
+ .with_available_ci_access_fields(project)
+ .to_a
+ end
+
+ def group_authorizations
+ return [] unless project.group
+
+ authorizations = Clusters::Agents::Authorizations::CiAccess::GroupAuthorization.arel_table
+
+ ordered_ancestors_cte = Gitlab::SQL::CTE.new(
+ :ordered_ancestors,
+ project.group.self_and_ancestors(hierarchy_order: :asc).reselect(:id)
+ )
+
+ cte_join_sources = authorizations.join(ordered_ancestors_cte.table).on(
+ authorizations[:group_id].eq(ordered_ancestors_cte.table[:id])
+ ).join_sources
+
+ Clusters::Agents::Authorizations::CiAccess::GroupAuthorization
+ .with(ordered_ancestors_cte.to_arel)
+ .joins(cte_join_sources)
+ .joins(agent: :project)
+ .with_available_ci_access_fields(project)
+ .where(projects: { namespace_id: all_namespace_ids })
+ .order(Arel.sql('agent_id, array_position(ARRAY(SELECT id FROM ordered_ancestors)::bigint[], agent_group_authorizations.group_id)'))
+ .select('DISTINCT ON (agent_id) agent_group_authorizations.*')
+ .preload(agent: :project)
+ .to_a
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def all_namespace_ids
+ project.root_ancestor.self_and_descendants.select(:id)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/finders/clusters/agents_finder.rb b/app/finders/clusters/agents_finder.rb
index 14277db3f85..0cdebe93f32 100644
--- a/app/finders/clusters/agents_finder.rb
+++ b/app/finders/clusters/agents_finder.rb
@@ -29,7 +29,7 @@ module Clusters
end
def can_read_cluster_agents?
- current_user&.can?(:read_cluster, object)
+ current_user&.can?(:read_cluster_agent, object)
end
end
end
diff --git a/app/finders/concerns/finder_with_group_hierarchy.rb b/app/finders/concerns/finder_with_group_hierarchy.rb
index 4ced544ba2c..70c38f00f72 100644
--- a/app/finders/concerns/finder_with_group_hierarchy.rb
+++ b/app/finders/concerns/finder_with_group_hierarchy.rb
@@ -27,11 +27,8 @@ module FinderWithGroupHierarchy
# we can preset root group for all of them to optimize permission checks
Group.preset_root_ancestor_for(groups)
- # Preloading the max access level for the given groups to avoid N+1 queries
- # during the access check.
- if !skip_authorization && current_user && Feature.enabled?(:preload_max_access_levels_for_labels_finder, group)
- Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute
- end
+ preload_associations(groups) if !skip_authorization && current_user && Feature.enabled?(
+ :preload_max_access_levels_for_labels_finder, group)
groups_user_can_read_items(groups).map(&:id)
end
@@ -77,4 +74,10 @@ module FinderWithGroupHierarchy
groups.select { |group| authorized_to_read_item?(group) }
end
end
+
+ def preload_associations(groups)
+ Preloaders::UserMaxAccessLevelInGroupsPreloader.new(groups, current_user).execute
+ end
end
+
+FinderWithGroupHierarchy.prepend_mod_with('FinderWithGroupHierarchy')
diff --git a/app/finders/concerns/updated_at_filter.rb b/app/finders/concerns/updated_at_filter.rb
index 2d6bd7bf9f3..0e9a3fb5e8c 100644
--- a/app/finders/concerns/updated_at_filter.rb
+++ b/app/finders/concerns/updated_at_filter.rb
@@ -2,8 +2,12 @@
module UpdatedAtFilter
def by_updated_at(items)
- items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
- items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
+ updated_before = params[:updated_before]&.in_time_zone
+ updated_after = params[:updated_after]&.in_time_zone
+ return items.none if [updated_before, updated_after].all?(&:present?) && updated_before < updated_after
+
+ items = items.updated_before(updated_before) if updated_before.present?
+ items = items.updated_after(updated_after) if updated_after.present?
items
end
diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb
index 4a45817cc61..a186ca92c7b 100644
--- a/app/finders/context_commits_finder.rb
+++ b/app/finders/context_commits_finder.rb
@@ -21,20 +21,24 @@ class ContextCommitsFinder
attr_reader :project, :merge_request, :search, :author, :committed_before, :committed_after, :limit
def init_collection
- if search.present?
+ if search_params_present?
search_commits
else
project.repository.commits(merge_request.target_branch, { limit: limit })
end
end
+ def search_params_present?
+ [search, author, committed_before, committed_after].map(&:present?).any?
+ end
+
def filter_existing_commits(commits)
commits.select! { |commit| already_included_ids.exclude?(commit.id) }
commits
end
def search_commits
- key = search.strip
+ key = search&.strip
commits = []
if Commit.valid_hash?(key)
mr_existing_commits_ids = merge_request.commits.map(&:id)
diff --git a/app/finders/data_transfer/group_data_transfer_finder.rb b/app/finders/data_transfer/group_data_transfer_finder.rb
new file mode 100644
index 00000000000..19ab99d4477
--- /dev/null
+++ b/app/finders/data_transfer/group_data_transfer_finder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module DataTransfer
+ class GroupDataTransferFinder
+ def initialize(group:, from:, to:, user:)
+ @group = group
+ @from = from
+ @to = to
+ @user = user
+ end
+
+ def execute
+ return ::Projects::DataTransfer.none unless Ability.allowed?(user, :read_usage_quotas, group)
+
+ ::Projects::DataTransfer
+ .with_namespace_between_dates(group, from, to)
+ .select('SUM(repository_egress
+ + artifacts_egress
+ + packages_egress
+ + registry_egress
+ ) as total_egress,
+ SUM(repository_egress) as repository_egress,
+ SUM(artifacts_egress) as artifacts_egress,
+ SUM(packages_egress) as packages_egress,
+ SUM(registry_egress) as registry_egress,
+ date,
+ namespace_id')
+ end
+
+ private
+
+ attr_reader :group, :from, :to, :user
+ end
+end
diff --git a/app/finders/data_transfer/mocked_transfer_finder.rb b/app/finders/data_transfer/mocked_transfer_finder.rb
new file mode 100644
index 00000000000..9c5551005ea
--- /dev/null
+++ b/app/finders/data_transfer/mocked_transfer_finder.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# Mocked data for data transfer
+# Follow this epic for recent progress: https://gitlab.com/groups/gitlab-org/-/epics/9330
+module DataTransfer
+ class MockedTransferFinder
+ def execute
+ start_date = Date.new(2023, 0o1, 0o1)
+ date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') }
+
+ 0.upto(11).map do |i|
+ {
+ date: date_for_index.call(i),
+ repository_egress: rand(70000..550000),
+ artifacts_egress: rand(70000..550000),
+ packages_egress: rand(70000..550000),
+ registry_egress: rand(70000..550000)
+ }.tap do |hash|
+ hash[:total_egress] = hash
+ .slice(:repository_egress, :artifacts_egress, :packages_egress, :registry_egress)
+ .values
+ .sum
+ end
+ end
+ end
+ end
+end
diff --git a/app/finders/data_transfer/project_data_transfer_finder.rb b/app/finders/data_transfer/project_data_transfer_finder.rb
new file mode 100644
index 00000000000..bcabbdb00a5
--- /dev/null
+++ b/app/finders/data_transfer/project_data_transfer_finder.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module DataTransfer
+ class ProjectDataTransferFinder
+ def initialize(project:, from:, to:, user:)
+ @project = project
+ @from = from
+ @to = to
+ @user = user
+ end
+
+ def execute
+ return ::Projects::DataTransfer.none unless Ability.allowed?(user, :read_usage_quotas, project)
+
+ ::Projects::DataTransfer
+ .with_project_between_dates(project, from, to)
+ .select(:project_id, :date, :repository_egress, :artifacts_egress, :packages_egress, :registry_egress,
+ "repository_egress + artifacts_egress + packages_egress + registry_egress as total_egress")
+ end
+
+ private
+
+ attr_reader :project, :from, :to, :user
+ end
+end
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index c5f8510ca16..3d40da78dbc 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -52,10 +52,6 @@ class DeploymentsFinder
private
- def raise_for_inefficient_updated_at_query?
- params.fetch(:raise_for_inefficient_updated_at_query, Rails.env.development? || Rails.env.test?)
- end
-
def validate!
if filter_by_updated_at? && filter_by_finished_at?
raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified'
@@ -68,7 +64,7 @@ class DeploymentsFinder
Gitlab::ErrorTracking.log_exception(error)
- raise error if raise_for_inefficient_updated_at_query?
+ raise error if Feature.enabled?(:deployments_raise_updated_at_inefficient_error)
end
if filter_by_finished_at? && !order_by_finished_at?
diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb
index c1769ea28f9..a96acd5838e 100644
--- a/app/finders/fork_targets_finder.rb
+++ b/app/finders/fork_targets_finder.rb
@@ -24,7 +24,7 @@ class ForkTargetsFinder
def fork_targets(options)
if options[:only_groups]
- user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ Groups::AcceptingProjectCreationsFinder.new(user).execute # rubocop: disable CodeReuse/Finder
else
user.forkable_namespaces.sort_by_type
end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 033af0f42a6..07f39f98b12 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -64,9 +64,7 @@ class GroupDescendantsFinder
def direct_child_groups
# rubocop: disable CodeReuse/Finder
- GroupsFinder.new(current_user,
- parent: parent_group,
- all_available: true).execute
+ GroupsFinder.new(current_user, parent: parent_group, all_available: true).execute
# rubocop: enable CodeReuse/Finder
end
@@ -78,12 +76,11 @@ class GroupDescendantsFinder
.in(Gitlab::VisibilityLevel.levels_for_user(current_user))
if current_user
- authorized_groups = GroupsFinder.new(current_user,
- all_available: false)
- .execute.arel.as('authorized')
+ authorized_groups = GroupsFinder.new(current_user, all_available: false)
+ .execute.arel.as('authorized')
authorized_to_user = groups_table.project(1).from(authorized_groups)
- .where(authorized_groups[:id].eq(groups_table[:id]))
- .exists
+ .where(authorized_groups[:id].eq(groups_table[:id]))
+ .exists
visible_to_user = visible_to_user.or(authorized_to_user)
end
@@ -161,9 +158,11 @@ class GroupDescendantsFinder
projects_nested_in_group = Project.where(namespace_id: parent_group.self_and_descendants.as_ids)
params_with_search = params.merge(search: params[:filter])
- ProjectsFinder.new(params: params_with_search,
- current_user: current_user,
- project_ids_relation: projects_nested_in_group).execute
+ ProjectsFinder.new(
+ params: params_with_search,
+ current_user: current_user,
+ project_ids_relation: projects_nested_in_group
+ ).execute
# rubocop: enable CodeReuse/Finder
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 05645dacab9..1025e0ebc9b 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -30,7 +30,11 @@ class GroupMembersFinder < UnionFinder
def execute(include_relations: DEFAULT_RELATIONS)
groups = groups_by_relations(include_relations)
- members = all_group_members(groups).distinct_on_user_with_max_access_level
+ shared_from_groups = if include_relations&.include?(:shared_from_groups)
+ Group.shared_into_ancestors(group).public_or_visible_to_user(user)
+ end
+
+ members = all_group_members(groups, shared_from_groups).distinct_on_user_with_max_access_level
filter_members(members)
end
@@ -47,9 +51,8 @@ class GroupMembersFinder < UnionFinder
related_groups << Group.by_id(group.id) if include_relations&.include?(:direct)
related_groups << group.ancestors if include_relations&.include?(:inherited)
related_groups << group.descendants if include_relations&.include?(:descendants)
- related_groups << Group.shared_into_ancestors(group).public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups)
- find_union(related_groups, Group)
+ related_groups
end
def filter_members(members)
@@ -78,12 +81,49 @@ class GroupMembersFinder < UnionFinder
group.members
end
- def all_group_members(groups)
- members_of_groups(groups).non_minimal_access
+ def all_group_members(groups, shared_from_groups)
+ members_of_groups(groups, shared_from_groups).non_minimal_access
+ end
+
+ def members_of_groups(groups, shared_from_groups)
+ if Feature.disabled?(:members_with_shared_group_access, @group.root_ancestor)
+ groups << shared_from_groups unless shared_from_groups.nil?
+ return GroupMember.non_request.of_groups(find_union(groups, Group))
+ end
+
+ members = GroupMember.non_request.of_groups(find_union(groups, Group))
+ return members if shared_from_groups.nil?
+
+ shared_members = GroupMember.non_request.of_groups(shared_from_groups)
+ select_attributes = GroupMember.attribute_names
+ members_shared_with_group_access = members_shared_with_group_access(shared_members, select_attributes)
+
+ # `members` and `members_shared_with_group_access` should have even select values
+ find_union([members.select(select_attributes), members_shared_with_group_access], GroupMember)
+ end
+
+ def members_shared_with_group_access(shared_members, select_attributes)
+ group_group_link_table = GroupGroupLink.arel_table
+ group_member_table = GroupMember.arel_table
+
+ member_columns = select_attributes.map do |column_name|
+ if column_name == 'access_level'
+ args = [group_group_link_table[:group_access], group_member_table[:access_level]]
+ smallest_value_arel(args, 'access_level')
+ else
+ group_member_table[column_name]
+ end
+ end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ shared_members
+ .joins("LEFT OUTER JOIN group_group_links ON members.source_id = group_group_links.shared_with_group_id")
+ .select(member_columns)
+ # rubocop:enable CodeReuse/ActiveRecord
end
- def members_of_groups(groups)
- GroupMember.non_request.of_groups(groups)
+ def smallest_value_arel(args, column_alias)
+ Arel::Nodes::As.new(Arel::Nodes::NamedFunction.new('LEAST', args), Arel::Nodes::SqlLiteral.new(column_alias))
end
def check_relation_arguments!(include_relations)
diff --git a/app/finders/groups/accepting_project_creations_finder.rb b/app/finders/groups/accepting_project_creations_finder.rb
new file mode 100644
index 00000000000..a7057b3f672
--- /dev/null
+++ b/app/finders/groups/accepting_project_creations_finder.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Groups
+ class AcceptingProjectCreationsFinder
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute
+ if Feature.disabled?(:include_groups_from_group_shares_in_project_creation_locations)
+ return current_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ end
+
+ groups_accepting_project_creations =
+ [
+ current_user
+ .manageable_groups(include_groups_with_developer_maintainer_access: true)
+ .project_creation_allowed,
+ owner_maintainer_groups_originating_from_group_shares
+ .project_creation_allowed,
+ *developer_groups_originating_from_group_shares
+ ]
+
+ # We move the UNION query into a materialized CTE to improve query performance during text search.
+ union_query = ::Group.from_union(groups_accepting_project_creations)
+ cte = Gitlab::SQL::CTE.new(:my_union_cte, union_query)
+
+ Group.with(cte.to_arel).from(cte.alias_to(Group.arel_table)) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def owner_maintainer_groups_originating_from_group_shares
+ GroupGroupLink
+ .with_owner_or_maintainer_access
+ .groups_accessible_via(
+ groups_that_user_has_owner_or_maintainer_access_via_direct_membership
+ .select(:id)
+ )
+ end
+
+ def groups_that_user_has_owner_or_maintainer_access_via_direct_membership
+ current_user.owned_or_maintainers_groups
+ end
+
+ def developer_groups_originating_from_group_shares
+ # Example:
+ #
+ # Group A -----shared to---> Group B
+ #
+
+ # Now, there are 2 ways a user in Group A can get "Developer" access to Group B (and it's subgroups)
+ [
+ # 1. User has Developer or above access in Group A,
+ # but the group_group_link has MAX access level set to Developer
+ GroupGroupLink
+ .with_developer_access
+ .groups_accessible_via(
+ groups_that_user_has_developer_access_and_above_via_direct_membership
+ .select(:id)
+ ).with_project_creation_levels(project_creations_levels_allowing_developers_to_create_projects),
+
+ # 2. User has exactly Developer access in Group A,
+ # but the group_group_link has MAX access level set to Developer or above.
+ GroupGroupLink
+ .with_developer_maintainer_owner_access
+ .groups_accessible_via(
+ groups_that_user_has_developer_access_via_direct_membership
+ .select(:id)
+ ).with_project_creation_levels(project_creations_levels_allowing_developers_to_create_projects)
+ ]
+
+ # Lastly, we should make sure that such groups indeed allow Developers to create projects in them,
+ # based on the value of `groups.project_creation_level`,
+ # which is why we use the scope .with_project_creation_levels on each set.
+ end
+
+ def groups_that_user_has_developer_access_and_above_via_direct_membership
+ current_user.developer_maintainer_owned_groups
+ end
+
+ def groups_that_user_has_developer_access_via_direct_membership
+ current_user.developer_groups
+ end
+
+ def project_creations_levels_allowing_developers_to_create_projects
+ project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS]
+
+ # When the value of application_settings.default_project_creation is set to `DEVELOPER_MAINTAINER_PROJECT_ACCESS`,
+ # it means that a `nil` value for `groups.project_creation_level` is telling us:
+ # such groups also have `project_creation_level` implicitly set to `DEVELOPER_MAINTAINER_PROJECT_ACCESS`.
+ # ie, `nil` is a placeholder value for inheriting the value from the ApplicationSetting.
+ # So we will include `nil` in the list,
+ # when the application_setting's value is `DEVELOPER_MAINTAINER_PROJECT_ACCESS`
+
+ if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ project_creation_levels << nil
+ end
+
+ project_creation_levels
+ end
+ end
+end
diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb
index 253961b8e52..c85e5a0f538 100644
--- a/app/finders/groups/accepting_project_shares_finder.rb
+++ b/app/finders/groups/accepting_project_shares_finder.rb
@@ -25,6 +25,8 @@ module Groups
groups_with_guest_access_plus
end
+ groups = by_hierarchy(groups)
+ groups = by_ignorable(groups)
groups = by_search(groups)
sort(groups).with_route
@@ -48,5 +50,25 @@ module Groups
Ability.allowed?(current_user, :admin_project, project_to_be_shared) &&
project_to_be_shared.allowed_to_share_with_group?
end
+
+ def by_ignorable(groups)
+ # groups already linked to this project or groups above the project's
+ # current hierarchy needs to be ignored.
+ groups.id_not_in(project_to_be_shared.related_group_ids)
+ end
+
+ def by_hierarchy(groups)
+ return groups if project_to_be_shared.personal? || sharing_outside_hierarchy_allowed?
+
+ groups.id_in(root_ancestor.self_and_descendants_ids)
+ end
+
+ def sharing_outside_hierarchy_allowed?
+ !root_ancestor.prevent_sharing_groups_outside_hierarchy
+ end
+
+ def root_ancestor
+ project_to_be_shared.root_ancestor
+ end
end
end
diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb
index b58c1323b1f..83e012b3dbe 100644
--- a/app/finders/groups/user_groups_finder.rb
+++ b/app/finders/groups/user_groups_finder.rb
@@ -36,7 +36,7 @@ module Groups
def by_permission_scope
if permission_scope_create_projects?
- target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ Groups::AcceptingProjectCreationsFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder
elsif permission_scope_transfer_projects?
Groups::AcceptingProjectTransfersFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder
else
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index 9f9d0da6efd..b1387f2a104 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -11,6 +11,11 @@ class LabelsFinder < UnionFinder
def initialize(current_user, params = {})
@current_user = current_user
@params = params
+ # Preload container records (project, group) by default, in some cases we invoke
+ # the LabelsPreloader on the loaded records to prevent all N+1 queries.
+ # In that case we disable the default with_preloaded_container scope because it
+ # interferes with the LabelsPreloader.
+ @preload_parent_association = params.fetch(:preload_parent_association, true)
end
def execute(skip_authorization: false)
@@ -19,7 +24,9 @@ class LabelsFinder < UnionFinder
items = with_title(items)
items = by_subscription(items)
items = by_search(items)
- sort(items.with_preloaded_container)
+
+ items = items.with_preloaded_container if @preload_parent_association
+ sort(items)
end
private
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 1641219a14c..d2122eccab1 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -31,11 +31,7 @@ class MembersFinder
attr_reader :project, :current_user, :group
def find_members(include_relations)
- project_members = if Feature.enabled?(:project_members_index_by_project_namespace, project)
- project.namespace_members
- else
- project.project_members
- end
+ project_members = project.namespace_members
if params[:active_without_invites_and_requests].present?
project_members = project_members.active_without_invites_and_requests
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 81017290f12..3d764f67990 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -31,6 +31,7 @@ class NotesFinder
notes = since_fetch_at(notes)
notes = notes.with_notes_filter(@params[:notes_filter]) if notes_filter?
notes = redact_internal(notes)
+ notes = notes.without_hidden if without_hidden_notes?
sort(notes)
end
@@ -189,6 +190,13 @@ class NotesFinder
notes.not_internal
end
+
+ def without_hidden_notes?
+ return false unless Feature.enabled?(:hidden_notes)
+ return false if @current_user&.can_admin_all_resources?
+
+ true
+ end
end
NotesFinder.prepend_mod_with('NotesFinder')
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
index a367fda37de..953e8299138 100644
--- a/app/finders/packages/npm/package_finder.rb
+++ b/app/finders/packages/npm/package_finder.rb
@@ -21,7 +21,11 @@ module Packages
return result unless @last_of_each_version
- result.last_of_each_version
+ if Feature.enabled?(:npm_allow_packages_in_multiple_projects)
+ Packages::Package.id_in(result.last_of_each_version_ids)
+ else
+ result.last_of_each_version
+ end
end
private
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 401bc473216..57a9538db15 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -31,6 +31,7 @@
#
class ProjectsFinder < UnionFinder
include CustomAttributesFilter
+ include UpdatedAtFilter
attr_accessor :params
attr_reader :current_user, :project_ids_relation
@@ -87,6 +88,7 @@ class ProjectsFinder < UnionFinder
collection = by_last_activity_before(collection)
collection = by_language(collection)
collection = by_feature_availability(collection)
+ collection = by_updated_at(collection)
by_repository_storage(collection)
end
diff --git a/app/finders/security/security_jobs_finder.rb b/app/finders/security/security_jobs_finder.rb
index 5754492cfa7..8cfb699a62a 100644
--- a/app/finders/security/security_jobs_finder.rb
+++ b/app/finders/security/security_jobs_finder.rb
@@ -13,7 +13,7 @@
module Security
class SecurityJobsFinder < JobsFinder
def self.allowed_job_types
- [:sast, :sast_iac, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning]
+ [:sast, :sast_iac, :breach_and_attack_simulation, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning]
end
end
end
diff --git a/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb
index c1b35d3eaf7..4ce9baff8cb 100644
--- a/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb
+++ b/app/graphql/batch_loaders/award_emoji_votes_batch_loader.rb
@@ -1,21 +1,25 @@
# frozen_string_literal: true
module BatchLoaders
- module AwardEmojiVotesBatchLoader
- private
+ class AwardEmojiVotesBatchLoader
+ def self.load_upvotes(object, awardable_class: nil)
+ load_votes_for(object, AwardEmoji::UPVOTE_NAME, awardable_class: awardable_class)
+ end
+
+ def self.load_downvotes(object, awardable_class: nil)
+ load_votes_for(object, AwardEmoji::DOWNVOTE_NAME, awardable_class: awardable_class)
+ end
- def load_votes(object, vote_type)
- BatchLoader::GraphQL.for(object.id).batch(key: "#{object.issuing_parent_id}-#{vote_type}") do |ids, loader, args|
- counts = AwardEmoji.votes_for_collection(ids, object.class.name).named(vote_type).index_by(&:awardable_id)
+ def self.load_votes_for(object, vote_type, awardable_class: nil)
+ awardable_class ||= object.class.name
+
+ BatchLoader::GraphQL.for(object.id).batch(key: "#{object.issuing_parent_id}-#{vote_type}") do |ids, loader, _args|
+ counts = AwardEmoji.votes_for_collection(ids, awardable_class).named(vote_type).index_by(&:awardable_id)
ids.each do |id|
loader.call(id, counts[id]&.count || 0)
end
end
end
-
- def authorized_resource?(object)
- Ability.allowed?(current_user, "read_#{object.to_ability_name}".to_sym, object)
- end
end
end
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 37adf4c2d3b..eed7959a2f1 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -20,7 +20,7 @@ class GitlabSchema < GraphQL::Schema
use Gitlab::Graphql::GenericTracing
use Gitlab::Graphql::Tracers::TimerTracer
- use GraphQL::Subscriptions::ActionCableSubscriptions
+ use Gitlab::Graphql::Subscriptions::ActionCableWithLoadBalancing
use BatchLoader::GraphQL
use Gitlab::Graphql::Pagination::Connections
use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index 89656f1e018..d1798d2ade7 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -2,60 +2,62 @@
module GraphqlTriggers
def self.issuable_assignees_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_assignees_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.issue_crm_contacts_updated(issue)
- GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue)
+ GitlabSchema.subscriptions.trigger(:issue_crm_contacts_updated, { issuable_id: issue.to_gid }, issue)
end
def self.issuable_title_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_title_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.issuable_description_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableDescriptionUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_description_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.issuable_labels_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_labels_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.issuable_dates_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_dates_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.issuable_milestone_updated(issuable)
- GitlabSchema.subscriptions.trigger('issuableMilestoneUpdated', { issuable_id: issuable.to_gid }, issuable)
+ GitlabSchema.subscriptions.trigger(:issuable_milestone_updated, { issuable_id: issuable.to_gid }, issuable)
end
def self.work_item_note_created(work_item_gid, note_data)
- GitlabSchema.subscriptions.trigger('workItemNoteCreated', { noteable_id: work_item_gid }, note_data)
+ GitlabSchema.subscriptions.trigger(:work_item_note_created, { noteable_id: work_item_gid }, note_data)
end
def self.work_item_note_deleted(work_item_gid, note_data)
- GitlabSchema.subscriptions.trigger('workItemNoteDeleted', { noteable_id: work_item_gid }, note_data)
+ GitlabSchema.subscriptions.trigger(:work_item_note_deleted, { noteable_id: work_item_gid }, note_data)
end
def self.work_item_note_updated(work_item_gid, note_data)
- GitlabSchema.subscriptions.trigger('workItemNoteUpdated', { noteable_id: work_item_gid }, note_data)
+ GitlabSchema.subscriptions.trigger(:work_item_note_updated, { noteable_id: work_item_gid }, note_data)
end
def self.merge_request_reviewers_updated(merge_request)
GitlabSchema.subscriptions.trigger(
- 'mergeRequestReviewersUpdated', { issuable_id: merge_request.to_gid }, merge_request
+ :merge_request_reviewers_updated, { issuable_id: merge_request.to_gid }, merge_request
)
end
def self.merge_request_merge_status_updated(merge_request)
+ return unless Feature.enabled?(:realtime_mr_status_change, merge_request.project)
+
GitlabSchema.subscriptions.trigger(
- 'mergeRequestMergeStatusUpdated', { issuable_id: merge_request.to_gid }, merge_request
+ :merge_request_merge_status_updated, { issuable_id: merge_request.to_gid }, merge_request
)
end
def self.merge_request_approval_state_updated(merge_request)
GitlabSchema.subscriptions.trigger(
- 'mergeRequestApprovalStateUpdated', { issuable_id: merge_request.to_gid }, merge_request
+ :merge_request_approval_state_updated, { issuable_id: merge_request.to_gid }, merge_request
)
end
end
diff --git a/app/graphql/mutations/achievements/delete.rb b/app/graphql/mutations/achievements/delete.rb
new file mode 100644
index 00000000000..0b510b44b4e
--- /dev/null
+++ b/app/graphql/mutations/achievements/delete.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class Delete < BaseMutation
+ graphql_name 'AchievementsDelete'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :achievement,
+ ::Types::Achievements::AchievementType,
+ null: true,
+ description: 'Achievement.'
+
+ argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement],
+ required: true,
+ description: 'Global ID of the achievement being deleted.'
+
+ authorize :admin_achievement
+
+ def resolve(args)
+ achievement = authorized_find!(id: args[:achievement_id])
+
+ result = ::Achievements::DestroyService.new(current_user, achievement).execute
+ { achievement: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/achievements/update.rb b/app/graphql/mutations/achievements/update.rb
new file mode 100644
index 00000000000..2a9e6580629
--- /dev/null
+++ b/app/graphql/mutations/achievements/update.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class Update < BaseMutation
+ graphql_name 'AchievementsUpdate'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :achievement,
+ ::Types::Achievements::AchievementType,
+ null: true,
+ description: 'Achievement.'
+
+ argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement],
+ required: true,
+ description: 'Global ID of the achievement being updated.'
+
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Name for the achievement.'
+
+ argument :avatar, ApolloUploadServer::Upload,
+ required: false,
+ description: 'Avatar for the achievement.'
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of or notes for the achievement.'
+
+ authorize :admin_achievement
+
+ def resolve(args)
+ achievement = authorized_find!(id: args[:achievement_id])
+
+ args.delete(:achievement_id)
+ result = ::Achievements::UpdateService.new(current_user, achievement, args).execute
+ { achievement: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb
index dc2d46269e6..65065de0de4 100644
--- a/app/graphql/mutations/award_emojis/base.rb
+++ b/app/graphql/mutations/award_emojis/base.rb
@@ -3,8 +3,6 @@
module Mutations
module AwardEmojis
class Base < BaseMutation
- include ::Mutations::FindsByGid
-
NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.'
authorize :award_emoji
diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb
index 7cfce9d2d91..f611608d1b6 100644
--- a/app/graphql/mutations/boards/update.rb
+++ b/app/graphql/mutations/boards/update.rb
@@ -29,12 +29,6 @@ module Mutations
errors: errors_on_object(board)
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/ci/runner/common_mutation_arguments.rb b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb
index bfeed4881c6..f4fbd0a38c7 100644
--- a/app/graphql/mutations/ci/runner/common_mutation_arguments.rb
+++ b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb
@@ -38,11 +38,6 @@ module Mutations
argument :tag_list, [GraphQL::Types::String],
required: false,
description: 'Tags associated with the runner.'
-
- argument :associated_projects, [::Types::GlobalIDType[::Project]],
- required: false,
- description: 'Projects associated with the runner. Available only for project runners.',
- prepare: ->(global_ids, _ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } }
end
end
end
diff --git a/app/graphql/mutations/ci/runner/create.rb b/app/graphql/mutations/ci/runner/create.rb
index 98300ee4c38..7eca6c27d10 100644
--- a/app/graphql/mutations/ci/runner/create.rb
+++ b/app/graphql/mutations/ci/runner/create.rb
@@ -10,25 +10,49 @@ module Mutations
include Mutations::Ci::Runner::CommonMutationArguments
+ argument :runner_type, ::Types::Ci::RunnerTypeEnum,
+ required: true,
+ description: 'Type of the runner to create.'
+
+ argument :group_id, ::Types::GlobalIDType[Group],
+ required: false,
+ description: 'Global ID of the group that the runner is created in (valid only for group runner).'
+
+ argument :project_id, ::Types::GlobalIDType[Project],
+ required: false,
+ description: 'Global ID of the project that the runner is created in (valid only for project runner).'
+
field :runner,
Types::Ci::RunnerType,
null: true,
description: 'Runner after mutation.'
- def resolve(**args)
- if Feature.disabled?(:create_runner_workflow_for_admin, current_user)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- '`create_runner_workflow_for_admin` feature flag is disabled.'
+ def ready?(**args)
+ case args[:runner_type]
+ when 'group_type'
+ raise Gitlab::Graphql::Errors::ArgumentError, '`group_id` is missing' unless args[:group_id].present?
+ when 'project_type'
+ raise Gitlab::Graphql::Errors::ArgumentError, '`project_id` is missing' unless args[:project_id].present?
end
- create_runner(args)
+ parse_gid(**args)
+
+ check_feature_flag(**args)
+
+ super
end
- private
+ def resolve(**args)
+ case args[:runner_type]
+ when 'group_type', 'project_type'
+ args[:scope] = authorized_find!(**args)
+ args.except!(:group_id, :project_id)
+ else
+ raise_resource_not_available_error! unless current_user.can?(:create_instance_runner)
+ end
- def create_runner(params)
response = { runner: nil, errors: [] }
- result = ::Ci::Runners::CreateRunnerService.new(user: current_user, type: nil, params: params).execute
+ result = ::Ci::Runners::CreateRunnerService.new(user: current_user, params: args).execute
if result.success?
response[:runner] = result.payload[:runner]
@@ -38,6 +62,45 @@ module Mutations
response
end
+
+ private
+
+ def find_object(**args)
+ obj = parse_gid(**args)
+
+ GitlabSchema.find_by_gid(obj) if obj
+ end
+
+ def parse_gid(runner_type:, **args)
+ case runner_type
+ when 'group_type'
+ GitlabSchema.parse_gid(args[:group_id], expected_type: ::Group)
+ when 'project_type'
+ GitlabSchema.parse_gid(args[:project_id], expected_type: ::Project)
+ end
+ end
+
+ def check_feature_flag(**args)
+ case args[:runner_type]
+ when 'instance_type'
+ if Feature.disabled?(:create_runner_workflow_for_admin, current_user)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ '`create_runner_workflow_for_admin` feature flag is disabled.'
+ end
+ when 'group_type'
+ namespace = find_object(**args).sync
+ if Feature.disabled?(:create_runner_workflow_for_namespace, namespace)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ '`create_runner_workflow_for_namespace` feature flag is disabled.'
+ end
+ when 'project_type'
+ project = find_object(**args).sync
+ if project && Feature.disabled?(:create_runner_workflow_for_namespace, project.namespace)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ '`create_runner_workflow_for_namespace` feature flag is disabled.'
+ end
+ end
+ end
end
end
end
diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb
index db68914a4eb..ba309ca754d 100644
--- a/app/graphql/mutations/ci/runner/delete.rb
+++ b/app/graphql/mutations/ci/runner/delete.rb
@@ -15,16 +15,12 @@ module Mutations
description: 'ID of the runner to delete.'
def resolve(id:, **runner_attrs)
- runner = authorized_find!(id)
+ runner = authorized_find!(id: id)
::Ci::Runners::UnregisterRunnerService.new(runner, current_user).execute
{ errors: runner.errors.full_messages }
end
-
- def find_object(id)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index 70f08e03553..da28397bb71 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -21,13 +21,18 @@ module Mutations
description: 'Indicates the runner is allowed to receive jobs.',
deprecated: { reason: :renamed, replacement: 'paused', milestone: '14.8' }
+ argument :associated_projects, [::Types::GlobalIDType[::Project]],
+ required: false,
+ description: 'Projects associated with the runner. Available only for project runners.',
+ prepare: ->(global_ids, _ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } }
+
field :runner,
Types::Ci::RunnerType,
null: true,
description: 'Runner after mutation.'
def resolve(id:, **runner_attrs)
- runner = authorized_find!(id)
+ runner = authorized_find!(id: id)
associated_projects_ids = runner_attrs.delete(:associated_projects)
@@ -40,10 +45,6 @@ module Mutations
response
end
- def find_object(id)
- GitlabSchema.find_by_gid(id)
- end
-
private
def associate_runner_projects(response, runner, associated_project_ids)
diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb
index 1b104652bd2..e717ff4d798 100644
--- a/app/graphql/mutations/clusters/agent_tokens/create.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/create.rb
@@ -54,12 +54,6 @@ module Mutations
errors: Array.wrap(result.message)
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/clusters/agent_tokens/revoke.rb b/app/graphql/mutations/clusters/agent_tokens/revoke.rb
index 6e988799921..c4187746464 100644
--- a/app/graphql/mutations/clusters/agent_tokens/revoke.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/revoke.rb
@@ -21,12 +21,6 @@ module Mutations
{ errors: errors_on_object(token) }
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/clusters/agents/delete.rb b/app/graphql/mutations/clusters/agents/delete.rb
index fb482e02794..ddb4e36a68e 100644
--- a/app/graphql/mutations/clusters/agents/delete.rb
+++ b/app/graphql/mutations/clusters/agents/delete.rb
@@ -24,12 +24,6 @@ module Mutations
errors: Array.wrap(result.message)
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb
deleted file mode 100644
index 157f87a413d..00000000000
--- a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module FindsByGid
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
- end
-end
diff --git a/app/graphql/mutations/concerns/mutations/finds_namespace.rb b/app/graphql/mutations/concerns/mutations/finds_namespace.rb
new file mode 100644
index 00000000000..bc9dfbcffe5
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/finds_namespace.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Mutations
+ module FindsNamespace
+ private
+
+ def find_object(full_path)
+ Routable.find_by_full_path(full_path)
+ end
+ end
+end
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 72daaf3ee44..f009abdba70 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -40,6 +40,14 @@ module Mutations
::Types::WorkItems::Widgets::NotificationsUpdateInputType,
required: false,
description: 'Input for notifications widget.'
+ argument :current_user_todos_widget,
+ ::Types::WorkItems::Widgets::CurrentUserTodosInputType,
+ required: false,
+ description: 'Input for to-dos widget.'
+ argument :award_emoji_widget,
+ ::Types::WorkItems::Widgets::AwardEmojiUpdateInputType,
+ required: false,
+ description: 'Input for award emoji widget.'
end
end
end
diff --git a/app/graphql/mutations/container_repositories/destroy_base.rb b/app/graphql/mutations/container_repositories/destroy_base.rb
index 1c2c4d87a5f..46851c15702 100644
--- a/app/graphql/mutations/container_repositories/destroy_base.rb
+++ b/app/graphql/mutations/container_repositories/destroy_base.rb
@@ -4,12 +4,6 @@ module Mutations
module ContainerRepositories
class DestroyBase < Mutations::BaseMutation
include ::Mutations::PackageEventable
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/design_management/update.rb b/app/graphql/mutations/design_management/update.rb
index 5dc20730a90..67732b70f29 100644
--- a/app/graphql/mutations/design_management/update.rb
+++ b/app/graphql/mutations/design_management/update.rb
@@ -28,12 +28,6 @@ module Mutations
errors: errors_on_object(design)
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb
index fce6e4f416f..dc5731add3a 100644
--- a/app/graphql/mutations/discussions/toggle_resolve.rb
+++ b/app/graphql/mutations/discussions/toggle_resolve.rb
@@ -53,10 +53,6 @@ module Mutations
end
end
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
-
def resolve!(discussion)
::Discussions::ResolveService.new(
discussion.project,
diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb
index 1cddfdd815b..43e9b6c0881 100644
--- a/app/graphql/mutations/environments/canary_ingress/update.rb
+++ b/app/graphql/mutations/environments/canary_ingress/update.rb
@@ -35,10 +35,6 @@ module Mutations
{ errors: Array.wrap(result[:message]) }
end
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
-
private
def certificate_based_clusters_enabled?
diff --git a/app/graphql/mutations/members/projects/bulk_update.rb b/app/graphql/mutations/members/projects/bulk_update.rb
index cfb88e60c44..9bf7968670e 100644
--- a/app/graphql/mutations/members/projects/bulk_update.rb
+++ b/app/graphql/mutations/members/projects/bulk_update.rb
@@ -5,6 +5,9 @@ module Mutations
module Projects
class BulkUpdate < BulkUpdateBase
graphql_name 'ProjectMemberBulkUpdate'
+ description 'Updates multiple members of a project. ' \
+ 'To use this mutation, you must have at least the Maintainer role.'
+
authorize :admin_project_member
field :project_members,
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index d458bdcf82b..225d313c487 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -83,10 +83,6 @@ module Mutations
super(**args)
end
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
-
def annotation_create_params(args)
annotation_source = AnnotationSource.new(object: annotation_source(args))
diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb
index fb74805db17..d656835c335 100644
--- a/app/graphql/mutations/notes/base.rb
+++ b/app/graphql/mutations/notes/base.rb
@@ -13,12 +13,6 @@ module Mutations
Types::Notes::NoteType,
null: true,
description: 'Note after mutation.'
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb
index f48e62af767..69cd1426218 100644
--- a/app/graphql/mutations/notes/create/base.rb
+++ b/app/graphql/mutations/notes/create/base.rb
@@ -47,10 +47,6 @@ module Mutations
private
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
-
def create_note_params(noteable, args)
{
noteable: noteable,
diff --git a/app/graphql/mutations/packages/destroy.rb b/app/graphql/mutations/packages/destroy.rb
index a398b1ff9dc..95832ec8b85 100644
--- a/app/graphql/mutations/packages/destroy.rb
+++ b/app/graphql/mutations/packages/destroy.rb
@@ -23,12 +23,6 @@ module Mutations
errors: errors
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/packages/destroy_file.rb b/app/graphql/mutations/packages/destroy_file.rb
index f2a8f2b853a..c7dd2df704e 100644
--- a/app/graphql/mutations/packages/destroy_file.rb
+++ b/app/graphql/mutations/packages/destroy_file.rb
@@ -21,12 +21,6 @@ module Mutations
{ errors: package_file.errors.full_messages }
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/projects/sync_fork.rb b/app/graphql/mutations/projects/sync_fork.rb
index 121c16df87b..13dd0b60d26 100644
--- a/app/graphql/mutations/projects/sync_fork.rb
+++ b/app/graphql/mutations/projects/sync_fork.rb
@@ -7,8 +7,6 @@ module Mutations
include FindsProject
- authorize :push_code
-
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project to initialize.'
@@ -22,9 +20,12 @@ module Mutations
description: 'Updated fork details.'
def resolve(project_path:, target_branch:)
- project = authorized_find!(project_path)
+ project = authorized_find!(project_path, target_branch)
+
+ return respond(nil, ['Feature flag is disabled']) unless Feature.enabled?(:synchronize_fork,
+ project.fork_source)
- return respond(nil, ['Feature flag is disabled']) unless Feature.enabled?(:synchronize_fork, project)
+ return respond(nil, ['Target branch does not exist']) unless project.repository.branch_exists?(target_branch)
details_resolver = Resolvers::Projects::ForkDetailsResolver.new(object: project, context: context, field: nil)
details = details_resolver.resolve(ref: target_branch)
@@ -56,6 +57,14 @@ module Mutations
def respond(details, errors)
{ details: details, errors: errors }
end
+
+ def authorized_find!(project_path, target_branch)
+ project = find_object(project_path)
+
+ return project if ::Gitlab::UserAccess.new(current_user, container: project).can_update_branch?(target_branch)
+
+ raise_resource_not_available_error!
+ end
end
end
end
diff --git a/app/graphql/mutations/release_asset_links/delete.rb b/app/graphql/mutations/release_asset_links/delete.rb
index 9a75b472411..891d8e5a4d8 100644
--- a/app/graphql/mutations/release_asset_links/delete.rb
+++ b/app/graphql/mutations/release_asset_links/delete.rb
@@ -19,7 +19,7 @@ module Mutations
description: 'Deleted release asset link.'
def resolve(id:)
- link = authorized_find!(id)
+ link = authorized_find!(id: id)
result = ::Releases::Links::DestroyService
.new(link.release, current_user)
@@ -31,10 +31,6 @@ module Mutations
{ link: nil, errors: result.message }
end
end
-
- def find_object(id)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/release_asset_links/update.rb b/app/graphql/mutations/release_asset_links/update.rb
index 2e9054c290d..3df2d28b88c 100644
--- a/app/graphql/mutations/release_asset_links/update.rb
+++ b/app/graphql/mutations/release_asset_links/update.rb
@@ -44,7 +44,7 @@ module Mutations
end
def resolve(id:, **link_attrs)
- link = authorized_find!(id)
+ link = authorized_find!(id: id)
result = ::Releases::Links::UpdateService
.new(link.release, current_user, link_attrs)
@@ -56,10 +56,6 @@ module Mutations
{ link: nil, errors: result.message }
end
end
-
- def find_object(id)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/terraform/state/base.rb b/app/graphql/mutations/terraform/state/base.rb
index 01f69934ea3..9a264836ef5 100644
--- a/app/graphql/mutations/terraform/state/base.rb
+++ b/app/graphql/mutations/terraform/state/base.rb
@@ -10,12 +10,6 @@ module Mutations
Types::GlobalIDType[::Terraform::State],
required: true,
description: 'Global ID of the Terraform state.'
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb
deleted file mode 100644
index 9a94c5d1e6d..00000000000
--- a/app/graphql/mutations/todos/base.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module Todos
- class Base < ::Mutations::BaseMutation
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
- end
- end
-end
diff --git a/app/graphql/mutations/todos/create.rb b/app/graphql/mutations/todos/create.rb
index 489d2f490ff..8a0906da724 100644
--- a/app/graphql/mutations/todos/create.rb
+++ b/app/graphql/mutations/todos/create.rb
@@ -2,7 +2,7 @@
module Mutations
module Todos
- class Create < ::Mutations::Todos::Base
+ class Create < ::Mutations::BaseMutation
graphql_name 'TodoCreate'
authorize :create_todo
@@ -17,7 +17,7 @@ module Mutations
description: 'To-do item created.'
def resolve(target_id:)
- target = authorized_find!(target_id)
+ target = authorized_find!(id: target_id)
todo = TodoService.new.mark_todo(target, current_user)&.first
errors = errors_on_object(todo) if todo
@@ -27,12 +27,6 @@ module Mutations
errors: errors
}
end
-
- private
-
- def find_object(id)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index fe4023515a4..7f8d15e033a 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -2,7 +2,7 @@
module Mutations
module Todos
- class MarkAllDone < ::Mutations::Todos::Base
+ class MarkAllDone < ::Mutations::BaseMutation
graphql_name 'TodosMarkAllDone'
authorize :update_user
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
index 4fecba55242..05d69fbc969 100644
--- a/app/graphql/mutations/todos/mark_done.rb
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -2,7 +2,7 @@
module Mutations
module Todos
- class MarkDone < ::Mutations::Todos::Base
+ class MarkDone < ::Mutations::BaseMutation
graphql_name 'TodoMarkDone'
authorize :update_todo
diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb
index def24cb71bc..a169ec58a9a 100644
--- a/app/graphql/mutations/todos/restore.rb
+++ b/app/graphql/mutations/todos/restore.rb
@@ -2,7 +2,7 @@
module Mutations
module Todos
- class Restore < ::Mutations::Todos::Base
+ class Restore < ::Mutations::BaseMutation
graphql_name 'TodoRestore'
authorize :update_todo
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index f2f944860c2..106ba18b852 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -2,7 +2,7 @@
module Mutations
module Todos
- class RestoreMany < ::Mutations::Todos::Base
+ class RestoreMany < ::Mutations::BaseMutation
graphql_name 'TodoRestoreMany'
MAX_UPDATE_AMOUNT = 50
diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb
new file mode 100644
index 00000000000..e8a2d72bd04
--- /dev/null
+++ b/app/graphql/mutations/work_items/convert.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class Convert < BaseMutation
+ graphql_name 'WorkItemConvert'
+ description "Converts the work item to a new type"
+
+ include Mutations::SpamProtection
+
+ authorize :update_work_item
+
+ argument :id, ::Types::GlobalIDType[::WorkItem],
+ required: true,
+ description: 'Global ID of the work item.'
+ argument :work_item_type_id, ::Types::GlobalIDType[::WorkItems::Type],
+ required: true,
+ description: 'Global ID of the new work item type.'
+
+ field :work_item, Types::WorkItemType,
+ null: true,
+ description: 'Updated work item.'
+
+ def resolve(attributes)
+ work_item = authorized_find!(id: attributes[:id])
+
+ return { errors: ['Feature flag disabled'] } unless Feature.enabled?(:work_item_conversion, work_item.project)
+
+ work_item_type = find_work_item_type!(attributes[:work_item_type_id])
+ authorize_work_item_type!(work_item, work_item_type)
+
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+
+ update_result = ::WorkItems::UpdateService.new(
+ container: work_item.project,
+ current_user: current_user,
+ params: { work_item_type: work_item_type, issue_type: work_item_type.base_type },
+ spam_params: spam_params
+ ).execute(work_item)
+
+ check_spam_action_response!(work_item)
+
+ {
+ work_item: (update_result[:work_item] if update_result[:status] == :success),
+ errors: Array.wrap(update_result[:message])
+ }
+ end
+
+ private
+
+ def find_work_item_type!(gid)
+ work_item_type = ::WorkItems::Type.find_by_id(gid.model_id)
+
+ return work_item_type if work_item_type.present?
+
+ message = format(_('Work Item type with id %{id} was not found'), id: gid.model_id)
+ raise_resource_not_available_error! message
+ end
+
+ def authorize_work_item_type!(work_item, work_item_type)
+ return if current_user.can?(:"create_#{work_item_type.base_type}", work_item)
+
+ message = format(_('You are not allowed to change the Work Item type to %{name}.'), name: work_item_type.name)
+ raise_resource_not_available_error! message
+ end
+
+ def find_object(id:)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index 9f124de7ab2..dfd2d5d1f88 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -6,13 +6,15 @@ module Mutations
graphql_name 'WorkItemCreate'
include Mutations::SpamProtection
- include FindsProject
+ include FindsNamespace
include Mutations::WorkItems::Widgetable
description "Creates a work item."
authorize :create_work_item
+ MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR = 'Please provide either projectPath or namespacePath argument, but not both.'
+
argument :confidential, GraphQL::Types::Boolean,
required: false,
description: 'Sets the work item confidentiality.'
@@ -25,9 +27,16 @@ module Mutations
argument :milestone_widget, ::Types::WorkItems::Widgets::MilestoneInputType,
required: false,
description: 'Input for milestone widget.'
+ argument :namespace_path, GraphQL::Types::ID,
+ required: false,
+ description: 'Full path of the namespace(project or group) the work item is created in.'
argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Full path of the project the work item is associated with.'
+ required: false,
+ description: 'Full path of the project the work item is associated with.',
+ deprecated: {
+ reason: 'Please use namespace_path instead. That will cover for both projects and groups',
+ milestone: '15.10'
+ }
argument :title, GraphQL::Types::String,
required: true,
description: copy_field_description(Types::WorkItemType, :title)
@@ -39,8 +48,17 @@ module Mutations
null: true,
description: 'Created work item.'
- def resolve(project_path:, **attributes)
- project = authorized_find!(project_path)
+ def ready?(**args)
+ if args.slice(:project_path, :namespace_path)&.length != 1
+ raise Gitlab::Graphql::Errors::ArgumentError, MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR
+ end
+
+ super
+ end
+
+ def resolve(project_path: nil, namespace_path: nil, **attributes)
+ container_path = project_path || namespace_path
+ container = authorized_find!(container_path)
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
@@ -48,7 +66,7 @@ module Mutations
widget_params = extract_widget_params!(type, params)
create_result = ::WorkItems::CreateService.new(
- container: project,
+ container: container,
current_user: current_user,
params: params,
spam_params: spam_params,
diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb
index 4ef8269a42f..23ae09b23fd 100644
--- a/app/graphql/mutations/work_items/create_from_task.rb
+++ b/app/graphql/mutations/work_items/create_from_task.rb
@@ -46,12 +46,6 @@ module Mutations
response
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/work_items/delete.rb b/app/graphql/mutations/work_items/delete.rb
index ec0244fa65e..bce59448412 100644
--- a/app/graphql/mutations/work_items/delete.rb
+++ b/app/graphql/mutations/work_items/delete.rb
@@ -29,12 +29,6 @@ module Mutations
errors: result.errors
}
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/work_items/delete_task.rb b/app/graphql/mutations/work_items/delete_task.rb
index 47ab3748ab4..b13d7e2e3bf 100644
--- a/app/graphql/mutations/work_items/delete_task.rb
+++ b/app/graphql/mutations/work_items/delete_task.rb
@@ -53,11 +53,6 @@ module Mutations
raise_resource_not_available_error!
end
end
-
- # method used by `authorized_find!(id: id)`
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 60b5536df56..3bcec7ebb1c 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -42,10 +42,6 @@ module Mutations
private
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
-
def interpret_quick_actions!(work_item, current_user, widget_params, attributes = {})
return unless work_item.work_item_type.widgets.include?(::WorkItems::Widgets::Description)
diff --git a/app/graphql/resolvers/achievements/achievements_resolver.rb b/app/graphql/resolvers/achievements/achievements_resolver.rb
index 1d71fa1d9c1..eb3f6eaf92e 100644
--- a/app/graphql/resolvers/achievements/achievements_resolver.rb
+++ b/app/graphql/resolvers/achievements/achievements_resolver.rb
@@ -7,12 +7,20 @@ module Resolvers
type ::Types::Achievements::AchievementType.connection_type, null: true
+ argument :ids, [::Types::GlobalIDType[::Achievements::Achievement]],
+ required: false,
+ description: 'Filter achievements by IDs.'
+
alias_method :namespace, :object
- def resolve_with_lookahead
+ def resolve_with_lookahead(**args)
return ::Achievements::Achievement.none if Feature.disabled?(:achievements, namespace)
- apply_lookahead(namespace.achievements)
+ params = {}
+ params[:ids] = args[:ids].map(&:model_id) if args[:ids].present?
+
+ achievements = ::Achievements::AchievementsFinder.new(namespace, params).execute
+ apply_lookahead(achievements)
end
private
diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb
new file mode 100644
index 00000000000..648f314a961
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/base_count_resolver.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class BaseCountResolver < BaseResolver
+ type Types::Analytics::CycleAnalytics::MetricType, null: true
+
+ argument :from, Types::TimeType,
+ required: true,
+ description: 'After the date.'
+
+ argument :to, Types::TimeType,
+ required: true,
+ description: 'Before the date.'
+
+ def ready?(**args)
+ start_date = args[:from]
+ end_date = args[:to]
+
+ if start_date >= end_date
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ '`from` argument must be before `to` argument'
+ end
+
+ max_days = Gitlab::Analytics::CycleAnalytics::RequestParams::MAX_RANGE_DAYS
+
+ if (end_date.beginning_of_day - start_date.beginning_of_day) > max_days
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ "Max of #{max_days.inspect} timespan is allowed"
+ end
+
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb
index f08de3c5d7e..8128023aecb 100644
--- a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb
+++ b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb
@@ -3,7 +3,7 @@
module Resolvers
module Analytics
module CycleAnalytics
- class BaseIssueResolver < BaseResolver
+ class BaseIssueResolver < BaseCountResolver
type Types::Analytics::CycleAnalytics::MetricType, null: true
argument :assignee_usernames, [GraphQL::Types::String],
@@ -22,14 +22,6 @@ module Resolvers
required: false,
description: 'Labels applied to the issue.'
- argument :from, Types::TimeType,
- required: true,
- description: 'Issues created after the date.'
-
- argument :to, Types::TimeType,
- required: true,
- description: 'Issues created before the date.'
-
def finder_params
{ project_id: object.project.id }
end
diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
index be17601e7a2..51a1afdd5ab 100644
--- a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
+++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
@@ -1,19 +1,10 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseCountResolver)
module Resolvers
module Analytics
module CycleAnalytics
- class DeploymentCountResolver < BaseResolver
- type Types::Analytics::CycleAnalytics::MetricType, null: true
-
- argument :from, Types::TimeType,
- required: true,
- description: 'Deployments finished after the date.'
-
- argument :to, Types::TimeType,
- required: true,
- description: 'Deployments finished before the date.'
-
+ class DeploymentCountResolver < BaseCountResolver
def resolve(**args)
value = count(args)
{
@@ -57,6 +48,7 @@ module Resolvers
end
end
end
+# rubocop:enable Graphql/ResolverType
mod = Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver
mod.prepend_mod_with('Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver')
diff --git a/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb b/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb
new file mode 100644
index 00000000000..406c52eb0d5
--- /dev/null
+++ b/app/graphql/resolvers/award_emoji/base_votes_count_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AwardEmoji
+ class BaseVotesCountResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type GraphQL::Types::Int, null: true
+
+ private
+
+ def authorized_resource?(object)
+ Ability.allowed?(current_user, "read_#{object.to_ability_name}".to_sym, object)
+ end
+
+ def votes_batch_loader
+ BatchLoaders::AwardEmojiVotesBatchLoader
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
index fb5fa4465f9..0b8180dbce7 100644
--- a/app/graphql/resolvers/blobs_resolver.rb
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -38,7 +38,7 @@ module Resolvers
private
def validate_ref(ref)
- unless Gitlab::GitRefValidator.validate(ref)
+ unless Gitlab::GitRefValidator.validate(ref, skip_head_ref_check: true)
raise Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid'
end
end
diff --git a/app/graphql/resolvers/ci/all_jobs_resolver.rb b/app/graphql/resolvers/ci/all_jobs_resolver.rb
index d918bed9f57..1240138c0bd 100644
--- a/app/graphql/resolvers/ci/all_jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb
@@ -3,14 +3,35 @@
module Resolvers
module Ci
class AllJobsResolver < BaseResolver
+ include LooksAhead
+
type ::Types::Ci::JobType.connection_type, null: true
argument :statuses, [::Types::Ci::JobStatusEnum],
required: false,
description: 'Filter jobs by status.'
- def resolve(statuses: nil)
- ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute
+ def resolve_with_lookahead(statuses: nil)
+ jobs = ::Ci::JobsFinder.new(current_user: current_user, params: { scope: statuses }).execute
+
+ apply_lookahead(jobs)
+ end
+
+ private
+
+ def preloads
+ {
+ previous_stage_jobs_or_needs: [:needs, :pipeline],
+ artifacts: [:job_artifacts],
+ pipeline: [:user],
+ project: [{ project: [:route, { namespace: [:route] }] }],
+ commit_path: [:pipeline, { project: { namespace: [:route] } }],
+ ref_path: [{ project: [:route, { namespace: [:route] }] }],
+ browse_artifacts_path: [{ project: { namespace: [:route] } }],
+ play_path: [{ project: { namespace: [:route] } }],
+ web_path: [{ project: { namespace: [:route] } }],
+ tags: [:tags]
+ }
end
end
end
diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
index 467a3525867..9fe25a4d13d 100644
--- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb
@@ -36,7 +36,11 @@ module Resolvers
{ pipeline: [:merge_request] },
{ project: [:route, { namespace: :route }] }
],
- commit_path: [:pipeline, { project: [:route, { namespace: [:route] }] }],
+ commit_path: [:pipeline, { project: { namespace: [:route] } }],
+ ref_path: [{ project: [:route, { namespace: [:route] }] }],
+ browse_artifacts_path: [{ project: { namespace: [:route] } }],
+ play_path: [{ project: { namespace: [:route] } }],
+ web_path: [{ project: { namespace: [:route] } }],
short_sha: [:pipeline],
tags: [:tags]
}
diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb
index 13a493c42a5..625efc615c8 100644
--- a/app/graphql/resolvers/ci/runner_projects_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb
@@ -34,25 +34,30 @@ module Resolvers
.where(runner_id: runner_ids)
.pluck(:runner_id, :project_id)
- project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq
+ unique_project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq
projects = ProjectsFinder
.new(current_user: current_user,
params: project_finder_params(args),
- project_ids_relation: project_ids)
+ project_ids_relation: unique_project_ids)
.execute
projects = apply_lookahead(projects)
Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute
+ sorted_project_ids = projects.map(&:id)
projects_by_id = projects.index_by(&:id)
# In plucked_runner_and_project_ids, first() represents the runner ID, and second() the project ID,
# so let's group the project IDs by runner ID
- runner_project_ids_by_runner_id =
+ project_ids_by_runner_id =
plucked_runner_and_project_ids
.group_by(&:first)
- .transform_values { |values| values.map(&:second).filter_map { |project_id| projects_by_id[project_id] } }
+ .transform_values { |runner_id_and_project_id| runner_id_and_project_id.map(&:second) }
+ # Reorder the project IDs according to the order in sorted_project_ids
+ sorted_project_ids_by_runner_id =
+ project_ids_by_runner_id.transform_values { |project_ids| sorted_project_ids.intersection(project_ids) }
runner_ids.each do |runner_id|
- runner_projects = runner_project_ids_by_runner_id[runner_id] || []
+ runner_project_ids = sorted_project_ids_by_runner_id[runner_id] || []
+ runner_projects = runner_project_ids.map { |id| projects_by_id[id] }
loader.call(runner_id, runner_projects)
end
diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb
index 447ab306ba7..6c7a4aafa83 100644
--- a/app/graphql/resolvers/ci/runner_status_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_status_resolver.rb
@@ -22,6 +22,8 @@ module Resolvers
}
def resolve(legacy_mode:, **args)
+ legacy_mode = nil if Feature.enabled?(:disable_runner_graphql_legacy_mode)
+
runner.status(legacy_mode)
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index c68e120ee24..b9326015ac0 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -40,6 +40,7 @@ module ResolvesMergeRequests
def preloads
{
assignees: [:assignees],
+ award_emoji: { award_emoji: [:awardable] },
reviewers: [:reviewers],
participants: MergeRequest.participant_includes,
author: [:author],
diff --git a/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb b/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb
new file mode 100644
index 00000000000..da75a78b2ac
--- /dev/null
+++ b/app/graphql/resolvers/data_transfer/data_transfer_arguments.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DataTransfer
+ module DataTransferArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :from, Types::DateType,
+ description:
+ 'Retain egress data for one year. Data for the current month will increase dynamically as egress occurs.',
+ required: false
+ argument :to, Types::DateType,
+ description: 'End date for the data.',
+ required: false
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb
new file mode 100644
index 00000000000..83bb144017c
--- /dev/null
+++ b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DataTransfer
+ class GroupDataTransferResolver < BaseResolver
+ include DataTransferArguments
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorizes_object!
+ authorize :read_usage_quotas
+
+ type Types::DataTransfer::GroupDataTransferType, null: false
+
+ alias_method :group, :object
+
+ def resolve(**args)
+ return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, group)
+
+ results = if Feature.enabled?(:data_transfer_monitoring_mock_data, group)
+ ::DataTransfer::MockedTransferFinder.new.execute
+ else
+ ::DataTransfer::GroupDataTransferFinder.new(
+ group: group,
+ from: args[:from],
+ to: args[:to],
+ user: current_user
+ ).execute.map(&:attributes)
+ end
+
+ { egress_nodes: results.to_a }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb
new file mode 100644
index 00000000000..c3296f7d4c3
--- /dev/null
+++ b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module DataTransfer
+ class ProjectDataTransferResolver < BaseResolver
+ include DataTransferArguments
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorizes_object!
+ authorize :read_usage_quotas
+
+ type Types::DataTransfer::ProjectDataTransferType, null: false
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, project.group)
+
+ results = if Feature.enabled?(:data_transfer_monitoring_mock_data, project.group)
+ ::DataTransfer::MockedTransferFinder.new.execute
+ else
+ ::DataTransfer::ProjectDataTransferFinder.new(
+ project: project,
+ from: args[:from],
+ to: args[:to],
+ user: current_user
+ ).execute
+ end
+
+ { egress_nodes: results }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer_resolver.rb
deleted file mode 100644
index 1a240d2811f..00000000000
--- a/app/graphql/resolvers/data_transfer_resolver.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-module Resolvers
- class DataTransferResolver < BaseResolver
- argument :from, Types::DateType,
- description: 'Retain egress data for 1 year. Current month will increase dynamically as egress occurs.',
- required: false
- argument :to, Types::DateType,
- description: 'End date for the data.',
- required: false
-
- type ::Types::DataTransfer::BaseType, null: false
-
- def self.source
- raise NotImplementedError
- end
-
- def self.project
- Class.new(self) do
- type Types::DataTransfer::ProjectDataTransferType, null: false
-
- def self.source
- "Project"
- end
- end
- end
-
- def self.group
- Class.new(self) do
- type Types::DataTransfer::GroupDataTransferType, null: false
-
- def self.source
- "Group"
- end
- end
- end
-
- def resolve(**_args)
- return unless Feature.enabled?(:data_transfer_monitoring)
-
- start_date = Date.new(2023, 0o1, 0o1)
- date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') }
-
- nodes = 0.upto(3).map do |i|
- {
- date: date_for_index.call(i),
- repository_egress: 250_000,
- artifacts_egress: 250_000,
- packages_egress: 250_000,
- registry_egress: 250_000
- }
- end
-
- { egress_nodes: nodes }
- end
- end
-end
diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb
index 7895981d67c..0d2479ded40 100644
--- a/app/graphql/resolvers/design_management/version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version_resolver.rb
@@ -16,10 +16,6 @@ module Resolvers
def resolve(id:)
authorized_find!(id: id)
end
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/resolvers/down_votes_count_resolver.rb b/app/graphql/resolvers/down_votes_count_resolver.rb
index 0e7772f988a..5f5340578cd 100644
--- a/app/graphql/resolvers/down_votes_count_resolver.rb
+++ b/app/graphql/resolvers/down_votes_count_resolver.rb
@@ -1,15 +1,12 @@
# frozen_string_literal: true
module Resolvers
- class DownVotesCountResolver < BaseResolver
- include Gitlab::Graphql::Authorize::AuthorizeResource
- include BatchLoaders::AwardEmojiVotesBatchLoader
-
+ class DownVotesCountResolver < Resolvers::AwardEmoji::BaseVotesCountResolver
type GraphQL::Types::Int, null: true
def resolve
authorize!(object)
- load_votes(object, AwardEmoji::DOWNVOTE_NAME)
+ votes_batch_loader.load_downvotes(object)
end
end
end
diff --git a/app/graphql/resolvers/group_labels_resolver.rb b/app/graphql/resolvers/group_labels_resolver.rb
index a22fa9761d6..932834de895 100644
--- a/app/graphql/resolvers/group_labels_resolver.rb
+++ b/app/graphql/resolvers/group_labels_resolver.rb
@@ -13,5 +13,11 @@ module Resolvers
required: false,
description: 'Include only group level labels.',
default_value: false
+
+ before_connection_authorization do |nodes, current_user|
+ if Feature.enabled?(:preload_max_access_levels_for_labels_finder)
+ Preloaders::LabelsPreloader.new(nodes, current_user).preload_all
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
index 9db104287a6..74c5cbe55f1 100644
--- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb
+++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb
@@ -21,7 +21,7 @@ module Resolvers
private
def can_read_agent_configuration?
- current_user.can?(:read_cluster, project)
+ current_user.can?(:read_cluster_agent, project)
end
def kas_client
diff --git a/app/graphql/resolvers/labels_resolver.rb b/app/graphql/resolvers/labels_resolver.rb
index f0e099e8fb2..a6b00030121 100644
--- a/app/graphql/resolvers/labels_resolver.rb
+++ b/app/graphql/resolvers/labels_resolver.rb
@@ -17,6 +17,12 @@ module Resolvers
description: 'Include labels from ancestor groups.',
default_value: false
+ before_connection_authorization do |nodes, current_user|
+ if Feature.enabled?(:preload_max_access_levels_for_labels_finder)
+ Preloaders::LabelsPreloader.new(nodes, current_user).preload_all
+ end
+ end
+
def resolve(**args)
return Label.none if parent.nil?
@@ -24,6 +30,13 @@ module Resolvers
# LabelsFinder uses `search` param, so we transform `search_term` into `search`
args[:search] = args.delete(:search_term)
+
+ # Optimization:
+ # Rely on the LabelsPreloader rather than the default parent record preloading in the
+ # finder because LabelsPreloader preloads more associations which are required for the
+ # permission check.
+ args[:preload_parent_association] = false if Feature.disabled?(:preload_max_access_levels_for_labels_finder)
+
LabelsFinder.new(current_user, parent_param.merge(args)).execute
end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 72372ae6b42..c725f165682 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -55,6 +55,10 @@ module Resolvers
required: false,
description: 'Limit result to draft merge requests.'
+ argument :approved, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Limit results to approved merge requests.'
+
argument :created_after, Types::TimeType,
required: false,
description: 'Merge requests created after this timestamp.'
diff --git a/app/graphql/resolvers/notes/synthetic_note_resolver.rb b/app/graphql/resolvers/notes/synthetic_note_resolver.rb
index d4eafcd2c49..619f54d80b4 100644
--- a/app/graphql/resolvers/notes/synthetic_note_resolver.rb
+++ b/app/graphql/resolvers/notes/synthetic_note_resolver.rb
@@ -26,10 +26,6 @@ module Resolvers
synthetic_notes.find { |note| note.discussion_id == sha }
end
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb
index 6c4e978125e..8fd80b1a9b9 100644
--- a/app/graphql/resolvers/paginated_tree_resolver.rb
+++ b/app/graphql/resolvers/paginated_tree_resolver.rb
@@ -22,7 +22,7 @@ module Resolvers
alias_method :repository, :object
def resolve(**args)
- return unless repository.exists?
+ return if repository.empty?
cursor = args.delete(:after)
args[:ref] ||= :head
diff --git a/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..7e2661f3f77
--- /dev/null
+++ b/app/graphql/resolvers/projects/branches_tipping_at_commit_resolver.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class BranchesTippingAtCommitResolver < RefTippingAtCommitResolver
+ MAX_LIMIT = 100
+
+ calls_gitaly!
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ # the methode ref_prefix is implemented
+ # because this class is prepending Resolver::CommitParentNamesResolver module
+ # through it's parent ::Resolvers::RefTippingAtCommitResolver
+ def ref_prefix
+ Gitlab::Git::BRANCH_REF_PREFIX
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/commit_parent_names_resolver.rb b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb
new file mode 100644
index 00000000000..f52776d715a
--- /dev/null
+++ b/app/graphql/resolvers/projects/commit_parent_names_resolver.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ module CommitParentNamesResolver
+ extend ActiveSupport::Concern
+
+ prepended do
+ argument :commit_sha, GraphQL::Types::String,
+ required: true,
+ description: 'Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`.'
+
+ argument :limit, GraphQL::Types::Int,
+ required: false,
+ description: 'Number of branch names to return.'
+
+ alias_method :project, :object
+ end
+
+ def compute_limit(limit)
+ max = self.class::MAX_LIMIT
+
+ limit ? [limit, max].min : max
+ end
+
+ def get_tipping_refs(project, sha, limit: 0)
+ # the methode ref_prefix needs to be implemented in all classes prepending this module
+ refs = project.repository.refs_by_oid(oid: sha, ref_patterns: [ref_prefix], limit: limit)
+ refs.map { |n| n.delete_prefix(ref_prefix) }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/fork_details_resolver.rb b/app/graphql/resolvers/projects/fork_details_resolver.rb
index a3c60f55e14..620ce395915 100644
--- a/app/graphql/resolvers/projects/fork_details_resolver.rb
+++ b/app/graphql/resolvers/projects/fork_details_resolver.rb
@@ -14,8 +14,6 @@ module Resolvers
def resolve(**args)
return unless project.forked?
return unless authorized_fork_source?
- return unless project.repository.branch_exists?(args[:ref])
- return unless Feature.enabled?(:fork_divergence_counts, project)
::Projects::Forks::Details.new(project, args[:ref])
end
diff --git a/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..3259a29ac9c
--- /dev/null
+++ b/app/graphql/resolvers/projects/ref_tipping_at_commit_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class RefTippingAtCommitResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ prepend CommitParentNamesResolver
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ authorize :read_code
+
+ def resolve(commit_sha:, limit: nil)
+ final_limit = compute_limit(limit)
+
+ names = get_tipping_refs(project, commit_sha, limit: final_limit)
+
+ {
+ names: names,
+ total_count: nil
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb
new file mode 100644
index 00000000000..78ee9c997d5
--- /dev/null
+++ b/app/graphql/resolvers/projects/tags_tipping_at_commit_resolver.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class TagsTippingAtCommitResolver < RefTippingAtCommitResolver
+ MAX_LIMIT = 100
+
+ calls_gitaly!
+
+ type ::Types::Projects::CommitParentNamesType, null: true
+
+ # the methode ref_prefix is implemented
+ # because this class is prepending Resolver::CommitParentNamesResolver module
+ # through it's parent ::Resolvers::RefTippingAtCommitResolver
+ def ref_prefix
+ Gitlab::Git::TAG_REF_PREFIX
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb
index dc42a5f38c9..d2b67451698 100644
--- a/app/graphql/resolvers/timelog_resolver.rb
+++ b/app/graphql/resolvers/timelog_resolver.rb
@@ -121,7 +121,7 @@ module Resolvers
def apply_user_filter(timelogs, args)
return timelogs unless args[:username]
- user = UserFinder.new(args[:username]).find_by_username!
+ user = UserFinder.new(args[:username]).find_by_username
timelogs.for_user(user)
end
diff --git a/app/graphql/resolvers/up_votes_count_resolver.rb b/app/graphql/resolvers/up_votes_count_resolver.rb
index 1c78facb694..8b2d705c07a 100644
--- a/app/graphql/resolvers/up_votes_count_resolver.rb
+++ b/app/graphql/resolvers/up_votes_count_resolver.rb
@@ -1,15 +1,12 @@
# frozen_string_literal: true
module Resolvers
- class UpVotesCountResolver < BaseResolver
- include Gitlab::Graphql::Authorize::AuthorizeResource
- include BatchLoaders::AwardEmojiVotesBatchLoader
-
+ class UpVotesCountResolver < Resolvers::AwardEmoji::BaseVotesCountResolver
type GraphQL::Types::Int, null: true
def resolve
authorize!(object)
- load_votes(object, AwardEmoji::UPVOTE_NAME)
+ votes_batch_loader.load_upvotes(object)
end
end
end
diff --git a/app/graphql/resolvers/work_item_resolver.rb b/app/graphql/resolvers/work_item_resolver.rb
index b174a0d2693..34e2f329efd 100644
--- a/app/graphql/resolvers/work_item_resolver.rb
+++ b/app/graphql/resolvers/work_item_resolver.rb
@@ -13,11 +13,5 @@ module Resolvers
def resolve(id:)
authorized_find!(id: id)
end
-
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
- end
end
end
diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb
index 7115b028481..14eec4f696a 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -31,7 +31,7 @@ module Resolvers
def preloads
{
work_item_type: :work_item_type,
- web_url: { project: { namespace: :route } },
+ web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] },
widgets: { work_item_type: :enabled_widget_definitions }
}
end
@@ -56,7 +56,8 @@ module Resolvers
children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] },
labels: :labels,
milestone: { milestone: [:project, :group] },
- subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }]
+ subscribed: [:assignees, :award_emoji, { notes: [:author, :award_emoji] }],
+ award_emoji: { award_emoji: :awardable }
}
end
diff --git a/app/graphql/subscriptions/base_subscription.rb b/app/graphql/subscriptions/base_subscription.rb
index 5f7931787df..dcc9fe708d6 100644
--- a/app/graphql/subscriptions/base_subscription.rb
+++ b/app/graphql/subscriptions/base_subscription.rb
@@ -12,6 +12,18 @@ module Subscriptions
current_user.reset if current_user
end
+ # We override graphql-ruby's default `subscribe` since it returns
+ # :no_response instead, which leads to empty hashes rendered out
+ # to the caller which has caused problems in the client.
+ #
+ # Eventually, we should move to an approach where the caller receives
+ # a response here upon subscribing, but we don't need this currently
+ # because Vue components also perform an initial fetch query.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/402614
+ def subscribe(*)
+ nil
+ end
+
def authorized?(*)
raise NotImplementedError
end
diff --git a/app/graphql/subscriptions/issuable_updated.rb b/app/graphql/subscriptions/issuable_updated.rb
index ad78fd4b4a1..63fe81bbc32 100644
--- a/app/graphql/subscriptions/issuable_updated.rb
+++ b/app/graphql/subscriptions/issuable_updated.rb
@@ -10,10 +10,6 @@ module Subscriptions
required: true,
description: 'ID of the issuable.'
- def subscribe(issuable_id:)
- nil
- end
-
def authorized?(issuable_id:)
issuable = force(GitlabSchema.find_by_gid(issuable_id))
diff --git a/app/graphql/subscriptions/notes/base.rb b/app/graphql/subscriptions/notes/base.rb
index 3653c01e0e2..c117dc295f2 100644
--- a/app/graphql/subscriptions/notes/base.rb
+++ b/app/graphql/subscriptions/notes/base.rb
@@ -9,10 +9,6 @@ module Subscriptions
required: false,
description: 'ID of the noteable.'
- def subscribe(*args)
- nil
- end
-
def authorized?(noteable_id:)
noteable = force(GitlabSchema.find_by_gid(noteable_id))
diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb
index d2146807445..bf161d2f1e5 100644
--- a/app/graphql/types/achievements/user_achievement_type.rb
+++ b/app/graphql/types/achievements/user_achievement_type.rb
@@ -5,7 +5,7 @@ module Types
class UserAchievementType < BaseObject
graphql_name 'UserAchievement'
- authorize :read_achievement
+ authorize :read_user_achievement
field :id,
::Types::GlobalIDType[::Achievements::UserAchievement],
diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb
index 472733a6bc5..e6514ba8d7d 100644
--- a/app/graphql/types/branch_protections/base_access_level_type.rb
+++ b/app/graphql/types/branch_protections/base_access_level_type.rb
@@ -14,7 +14,7 @@ module Types
type: GraphQL::Types::String,
null: false,
description: 'Human readable representation for this access level.',
- hash_key: 'humanize'
+ method: 'humanize'
end
end
end
diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb
new file mode 100644
index 00000000000..b5947826fa1
--- /dev/null
+++ b/app/graphql/types/ci/catalog/resource_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ module Catalog
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ResourceType < BaseObject
+ graphql_name 'CiCatalogResource'
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.',
+ alpha: { milestone: '15.11' }
+
+ field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.',
+ alpha: { milestone: '15.11' }
+
+ field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.',
+ alpha: { milestone: '15.11' }
+
+ field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.',
+ method: :avatar_path, alpha: { milestone: '15.11' }
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/include_type_enum.rb b/app/graphql/types/ci/config/include_type_enum.rb
index 328824ae996..7ebcf786dd8 100644
--- a/app/graphql/types/ci/config/include_type_enum.rb
+++ b/app/graphql/types/ci/config/include_type_enum.rb
@@ -11,6 +11,7 @@ module Types
value 'local', description: 'Local include.', value: :local
value 'file', description: 'Project file include.', value: :file
value 'template', description: 'Template include.', value: :template
+ value 'component', description: 'Component include.', value: :component
end
end
end
diff --git a/app/graphql/types/ci/job_trace_type.rb b/app/graphql/types/ci/job_trace_type.rb
new file mode 100644
index 00000000000..a68e26106b8
--- /dev/null
+++ b/app/graphql/types/ci/job_trace_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# rubocop: disable Graphql/AuthorizeTypes
+module Types
+ module Ci
+ class JobTraceType < BaseObject
+ graphql_name 'CiJobTrace'
+
+ field :html_summary, GraphQL::Types::String, null: false,
+ alpha: { milestone: '15.11' }, # As we want the option to change from 10 if needed
+ description: "HTML summary containing the last 10 lines of the trace."
+
+ def html_summary
+ object.html(last_lines: 10).html_safe
+ end
+ end
+ end
+end
+# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 60c1c2e601d..1d12c296b2e 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -25,8 +25,8 @@ module Types
description: 'References to builds that must complete before the jobs run.'
field :pipeline, Types::Ci::PipelineType, null: true,
description: 'Pipeline the job belongs to.'
- field :runner_machine, ::Types::Ci::RunnerMachineType, null: true,
- description: 'Runner machine assigned to the job.',
+ field :runner_manager, ::Types::Ci::RunnerManagerType, null: true,
+ description: 'Runner manager assigned to the job.',
alpha: { milestone: '15.11' }
field :stage, Types::Ci::StageType, null: true,
description: 'Stage of the job.'
@@ -101,6 +101,8 @@ module Types
description: 'Short SHA1 ID of the commit.'
field :stuck, GraphQL::Types::Boolean, null: false, method: :stuck?,
description: 'Indicates the job is stuck.'
+ field :trace, Types::Ci::JobTraceType, null: true,
+ description: 'Trace generated by the job.'
field :triggered, GraphQL::Types::Boolean, null: true,
description: 'Whether the job was triggered.'
field :web_path, GraphQL::Types::String, null: true,
@@ -144,6 +146,10 @@ module Types
end
end
+ def trace
+ object.trace if object.has_trace?
+ end
+
def previous_stage_jobs_or_needs
if object.scheduling_type == 'stage'
Gitlab::Graphql::Lazy.with_value(previous_stage_jobs) do |jobs|
@@ -172,17 +178,16 @@ module Types
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Stage, object.stage_id).find
end
- def runner_machine
- BatchLoader::GraphQL.for(object.id).batch(key: :runner_machines) do |build_ids, loader|
- plucked_build_to_machine_ids = ::Ci::RunnerMachineBuild.for_build(build_ids).pluck_build_id_and_runner_machine_id
- runner_machines = ::Ci::RunnerMachine.id_in(plucked_build_to_machine_ids.values.uniq)
- Preloaders::RunnerMachinePolicyPreloader.new(runner_machines, current_user).execute
- runner_machines_by_id = runner_machines.index_by(&:id)
+ def runner_manager
+ BatchLoader::GraphQL.for(object.id).batch(key: :runner_managers) do |build_ids, loader|
+ plucked_build_to_runner_manager_ids =
+ ::Ci::RunnerManagerBuild.for_build(build_ids).pluck_build_id_and_runner_manager_id
+ runner_managers = ::Ci::RunnerManager.id_in(plucked_build_to_runner_manager_ids.values.uniq)
+ Preloaders::RunnerManagerPolicyPreloader.new(runner_managers, current_user).execute
+ runner_managers_by_id = runner_managers.index_by(&:id)
build_ids.each do |build_id|
- runner_machine_id = plucked_build_to_machine_ids[build_id]
-
- loader.call(build_id, runner_machines_by_id[runner_machine_id])
+ loader.call(build_id, runner_managers_by_id[plucked_build_to_runner_manager_ids[build_id]])
end
end
end
diff --git a/app/graphql/types/ci/runner_machine_type.rb b/app/graphql/types/ci/runner_manager_type.rb
index 8e6656288d9..2a5053f8f07 100644
--- a/app/graphql/types/ci/runner_machine_type.rb
+++ b/app/graphql/types/ci/runner_manager_type.rb
@@ -2,50 +2,48 @@
module Types
module Ci
- class RunnerMachineType < BaseObject
- graphql_name 'CiRunnerMachine'
+ class RunnerManagerType < BaseObject
+ graphql_name 'CiRunnerManager'
connection_type_class(::Types::CountableConnectionType)
- authorize :read_runner_machine
+ authorize :read_runner_manager
- alias_method :runner_machine, :object
+ alias_method :runner_manager, :object
field :architecture_name, GraphQL::Types::String, null: true,
- description: 'Architecture provided by the runner machine.',
+ description: 'Architecture provided by the runner manager.',
method: :architecture
field :contacted_at, Types::TimeType, null: true,
- description: 'Timestamp of last contact from the runner machine.',
+ description: 'Timestamp of last contact from the runner manager.',
method: :contacted_at
field :created_at, Types::TimeType, null: true,
- description: 'Timestamp of creation of the runner machine.'
+ description: 'Timestamp of creation of the runner manager.'
field :executor_name, GraphQL::Types::String, null: true,
description: 'Executor last advertised by the runner.',
method: :executor_name
- field :id, ::Types::GlobalIDType[::Ci::RunnerMachine], null: false,
- description: 'ID of the runner machine.'
+ field :id, ::Types::GlobalIDType[::Ci::RunnerManager], null: false,
+ description: 'ID of the runner manager.'
field :ip_address, GraphQL::Types::String, null: true,
- description: 'IP address of the runner machine.'
+ description: 'IP address of the runner manager.'
field :platform_name, GraphQL::Types::String, null: true,
- description: 'Platform provided by the runner machine.',
+ description: 'Platform provided by the runner manager.',
method: :platform
field :revision, GraphQL::Types::String, null: true, description: 'Revision of the runner.'
- field :runner, RunnerType, null: true, description: 'Runner configuration for the runner machine.'
+ field :runner, RunnerType, null: true, description: 'Runner configuration for the runner manager.'
field :status,
Types::Ci::RunnerStatusEnum,
null: false,
- description: 'Status of the runner machine.'
+ description: 'Status of the runner manager.'
field :system_id, GraphQL::Types::String,
null: false,
- description: 'System ID associated with the runner machine.',
+ description: 'System ID associated with the runner manager.',
method: :system_xid
field :version, GraphQL::Types::String, null: true, description: 'Version of the runner.'
def executor_name
- ::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_machine.executor_type&.to_sym]
+ ::Ci::Runner::EXECUTOR_TYPE_TO_NAMES[runner_manager.executor_type&.to_sym]
end
end
end
end
-
-Types::Ci::RunnerType.prepend_mod_with('Types::Ci::RunnerType')
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 60ea78752ca..20e8b506a3f 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -39,9 +39,12 @@ module Types
field :edit_admin_url, GraphQL::Types::String, null: true,
description: 'Admin form URL of the runner. Only available for administrators.'
field :ephemeral_authentication_token, GraphQL::Types::String, null: true,
- description: 'Ephemeral authentication token used for runner machine registration. Only available for the creator of the runner for a limited time during registration.',
+ description: 'Ephemeral authentication token used for runner manager registration. Only available for the creator of the runner for a limited time during registration.',
authorize: :read_ephemeral_token,
alpha: { milestone: '15.9' }
+ field :ephemeral_register_url, GraphQL::Types::String, null: true,
+ description: 'URL of the registration page of the runner manager. Only available for the creator of the runner for a limited time during registration.',
+ alpha: { milestone: '15.11' }
field :executor_name, GraphQL::Types::String, null: true,
description: 'Executor last advertised by the runner.',
method: :executor_name
@@ -65,12 +68,12 @@ module Types
resolver: ::Resolvers::Ci::RunnerJobsResolver
field :locked, GraphQL::Types::Boolean, null: true,
description: 'Indicates the runner is locked.'
- field :machines, ::Types::Ci::RunnerMachineType.connection_type, null: true,
- description: 'Machines associated with the runner configuration.',
- method: :runner_machines,
- alpha: { milestone: '15.10' }
field :maintenance_note, GraphQL::Types::String, null: true,
description: 'Runner\'s maintenance notes.'
+ field :managers, ::Types::Ci::RunnerManagerType.connection_type, null: true,
+ description: 'Machines associated with the runner configuration.',
+ method: :runner_managers,
+ alpha: { milestone: '15.10' }
field :maximum_timeout, GraphQL::Types::Int, null: true,
description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
field :owner_project, ::Types::ProjectType, null: true,
@@ -147,6 +150,17 @@ module Types
Gitlab::Routing.url_helpers.edit_admin_runner_url(runner) if can_admin_runners?
end
+ def ephemeral_register_url
+ return unless ephemeral_register_url_access_allowed?(runner)
+
+ case runner.runner_type
+ when 'instance_type'
+ Gitlab::Routing.url_helpers.register_admin_runner_url(runner)
+ when 'group_type'
+ Gitlab::Routing.url_helpers.register_group_runner_url(runner.groups[0], runner)
+ end
+ end
+
def register_admin_url
return unless can_admin_runners? && runner.registration_available?
@@ -187,6 +201,19 @@ module Types
def can_admin_runners?
context[:current_user]&.can_admin_all_resources?
end
+
+ def ephemeral_register_url_access_allowed?(runner)
+ return unless runner.registration_available?
+
+ case runner.runner_type
+ when 'instance_type'
+ can_admin_runners?
+ when 'group_type'
+ group = runner.groups[0]
+
+ group && context[:current_user]&.can?(:register_group_runners, group)
+ end
+ end
end
end
end
diff --git a/app/graphql/types/clusters/agent_activity_event_type.rb b/app/graphql/types/clusters/agent_activity_event_type.rb
index 3484acfe25e..1d0ec7c4959 100644
--- a/app/graphql/types/clusters/agent_activity_event_type.rb
+++ b/app/graphql/types/clusters/agent_activity_event_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentActivityEventType < BaseObject
graphql_name 'ClusterAgentActivityEvent'
- authorize :read_cluster
+ authorize :read_cluster_agent
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/clusters/agent_token_type.rb b/app/graphql/types/clusters/agent_token_type.rb
index 24489707698..720ee2f685b 100644
--- a/app/graphql/types/clusters/agent_token_type.rb
+++ b/app/graphql/types/clusters/agent_token_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentTokenType < BaseObject
graphql_name 'ClusterAgentToken'
- authorize :read_cluster
+ authorize :read_cluster_agent
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb
index 5d7b8815cde..317a1aab628 100644
--- a/app/graphql/types/clusters/agent_type.rb
+++ b/app/graphql/types/clusters/agent_type.rb
@@ -5,7 +5,7 @@ module Types
class AgentType < BaseObject
graphql_name 'ClusterAgent'
- authorize :read_cluster
+ authorize :read_cluster_agent
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/data_transfer/base_type.rb b/app/graphql/types/data_transfer/base_type.rb
index e077612bfd5..5031bd5c612 100644
--- a/app/graphql/types/data_transfer/base_type.rb
+++ b/app/graphql/types/data_transfer/base_type.rb
@@ -7,7 +7,7 @@ module Types
field :egress_nodes, type: Types::DataTransfer::EgressNodeType.connection_type,
description: 'Data nodes.',
- null: true # disallow null once data_transfer_monitoring feature flag is rolled-out!
+ null: true # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/397693
end
end
end
diff --git a/app/graphql/types/data_transfer/egress_node_type.rb b/app/graphql/types/data_transfer/egress_node_type.rb
index a050540999f..f0ad3d15b53 100644
--- a/app/graphql/types/data_transfer/egress_node_type.rb
+++ b/app/graphql/types/data_transfer/egress_node_type.rb
@@ -26,12 +26,8 @@ module Types
null: false
field :registry_egress, GraphQL::Types::BigInt,
- description: 'Registery egress for that project in that period of time.',
+ description: 'Registry egress for that project in that period of time.',
null: false
-
- def total_egress
- object.values.select { |x| x.is_a?(Integer) }.sum
- end
end
end
end
diff --git a/app/graphql/types/data_transfer/project_data_transfer_type.rb b/app/graphql/types/data_transfer/project_data_transfer_type.rb
index f385aa20a7e..36afa20194e 100644
--- a/app/graphql/types/data_transfer/project_data_transfer_type.rb
+++ b/app/graphql/types/data_transfer/project_data_transfer_type.rb
@@ -8,12 +8,14 @@ module Types
field :total_egress, GraphQL::Types::BigInt,
description: 'Total egress for that project in that period of time.',
- null: true # disallow null once data_transfer_monitoring feature flag is rolled-out!
+ null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/397693
+ extras: [:parent]
- def total_egress(**_)
- return unless Feature.enabled?(:data_transfer_monitoring)
+ def total_egress(parent:)
+ return unless Feature.enabled?(:data_transfer_monitoring, parent.group)
+ return 40_000_000 if Feature.enabled?(:data_transfer_monitoring_mock_data, parent.group)
- 40_000_000
+ object[:egress_nodes].sum('repository_egress + artifacts_egress + packages_egress + registry_egress')
end
end
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 3543ac29c17..d352d82a52e 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -241,7 +241,7 @@ module Types
field :data_transfer, Types::DataTransfer::GroupDataTransferType,
null: true,
- resolver: Resolvers::DataTransferResolver.group,
+ resolver: Resolvers::DataTransfer::GroupDataTransferResolver,
description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
def label(title:)
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 3c288c1d496..d73eaed8a0a 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -219,6 +219,10 @@ module Types
field :timelogs, Types::TimelogType.connection_type, null: false,
description: 'Timelogs on the merge request.'
+ field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type,
+ null: true,
+ description: 'List of award emojis associated with the merge request.'
+
markdown_field :title_html, null: true
markdown_field :description_html, null: true
@@ -295,6 +299,13 @@ module Types
def detailed_merge_status
::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute
end
+
+ # This is temporary to fix a bug where `committers` is already loaded and memoized
+ # and calling it again with a certain GraphQL query can cause the Rails to to throw
+ # a ActiveRecord::ImmutableRelation error
+ def committers
+ object.commits.committers
+ end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 9bdbdad4386..2714f4cf502 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -8,7 +8,9 @@ module Types
mount_mutation Mutations::Achievements::Award, alpha: { milestone: '15.10' }
mount_mutation Mutations::Achievements::Create, alpha: { milestone: '15.8' }
+ mount_mutation Mutations::Achievements::Delete, alpha: { milestone: '15.11' }
mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' }
+ mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' }
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
@@ -168,6 +170,7 @@ module Types
mount_mutation Mutations::WorkItems::Update, alpha: { milestone: '15.1' }
mount_mutation Mutations::WorkItems::UpdateTask, alpha: { milestone: '15.1' }
mount_mutation Mutations::WorkItems::Export, alpha: { milestone: '15.10' }
+ mount_mutation Mutations::WorkItems::Convert, alpha: { milestone: '15.11' }
mount_mutation Mutations::SavedReplies::Create
mount_mutation Mutations::SavedReplies::Update
mount_mutation Mutations::Pages::MarkOnboardingComplete
diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb
index f35f42001e0..9f8f9e4f2b9 100644
--- a/app/graphql/types/permission_types/work_item.rb
+++ b/app/graphql/types/permission_types/work_item.rb
@@ -6,7 +6,8 @@ module Types
graphql_name 'WorkItemPermissions'
description 'Check permissions for the current user on a work item'
- abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item
+ abilities :read_work_item, :update_work_item, :delete_work_item,
+ :admin_work_item, :admin_parent_link
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 4ca2bc8b1b5..5ebc1cf7ddd 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -24,9 +24,9 @@ module Types
authorize: :create_pipeline,
alpha: { milestone: '15.3' },
description: 'CI/CD config variable.' do
- argument :sha, GraphQL::Types::String,
+ argument :ref, GraphQL::Types::String,
required: true,
- description: 'Sha.'
+ description: 'Ref.'
end
field :full_path, GraphQL::Types::ID,
@@ -136,6 +136,11 @@ module Types
null: true,
description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.'
+ field :is_catalog_resource, GraphQL::Types::Boolean,
+ alpha: { milestone: '15.11' },
+ null: true,
+ description: 'Indicates if a project is a catalog resource.'
+
field :public_jobs, GraphQL::Types::Boolean,
null: true,
description: 'Indicates if there is public access to pipelines and job details of the project, ' \
@@ -567,8 +572,8 @@ module Types
description: "Find runners visible to the current user."
field :data_transfer, Types::DataTransfer::ProjectDataTransferType,
- null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out!
- resolver: Resolvers::DataTransferResolver.project,
+ null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682
+ resolver: Resolvers::DataTransfer::ProjectDataTransferResolver,
description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
field :visible_forks, Types::ProjectType.connection_type,
@@ -589,6 +594,16 @@ module Types
authorize: :read_cycle_analytics,
alpha: { milestone: '15.10' }
+ field :tags_tipping_at_commit, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ resolver: Resolvers::Projects::TagsTippingAtCommitResolver,
+ description: "Get tag names tipping at a given commit."
+
+ field :branches_tipping_at_commit, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ resolver: Resolvers::Projects::BranchesTippingAtCommitResolver,
+ description: "Get branch names tipping at a given commit."
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
@@ -635,6 +650,16 @@ module Types
BatchLoader::GraphQL.wrap(object.forks_count)
end
+ def is_catalog_resource # rubocop:disable Naming/PredicateName
+ lazy_catalog_resource = BatchLoader::GraphQL.for(object.id).batch do |project_ids, loader|
+ ::Ci::Catalog::Resource.for_projects(project_ids).each do |catalog_resource|
+ loader.call(catalog_resource.project_id, catalog_resource)
+ end
+ end
+
+ Gitlab::Graphql::Lazy.with_value(lazy_catalog_resource, &:present?)
+ end
+
def statistics
Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(object.id).find
end
@@ -643,10 +668,8 @@ module Types
project.container_repositories.size
end
- # Even if the parameter name is `sha`, it is actually a ref name. We always send `ref` to the endpoint.
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/389065
- def ci_config_variables(sha:)
- result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(sha)
+ def ci_config_variables(ref:)
+ result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(ref)
return if result.nil?
diff --git a/app/graphql/types/projects/commit_parent_names_type.rb b/app/graphql/types/projects/commit_parent_names_type.rb
new file mode 100644
index 00000000000..0aa1ca768e9
--- /dev/null
+++ b/app/graphql/types/projects/commit_parent_names_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module Projects
+ # rubocop: disable Graphql/AuthorizeTypes
+ class CommitParentNamesType < BaseObject
+ graphql_name 'CommitParentNames'
+
+ field :names, [GraphQL::Types::String], null: true, description: 'Names of the commit parent (branch or tag).'
+ field :total_count, GraphQL::Types::Int, null: true, description: 'Total of parent branches or tags.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/relative_position_type_enum.rb b/app/graphql/types/relative_position_type_enum.rb
new file mode 100644
index 00000000000..e0d28bea648
--- /dev/null
+++ b/app/graphql/types/relative_position_type_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class RelativePositionTypeEnum < BaseEnum
+ graphql_name 'RelativePositionType'
+ description 'The position to which the object should be moved'
+
+ value 'BEFORE', 'Object is moved before an adjacent object.'
+ value 'AFTER', 'Object is moved after an adjacent object.'
+ end
+end
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index ab5d1bd8c9e..40eade3a4d1 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -28,3 +28,5 @@ module Types
description: 'Tree of the repository.'
end
end
+
+Types::RepositoryType.prepend_mod
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index 3a060518cd9..88baca028ef 100644
--- a/app/graphql/types/timelog_type.rb
+++ b/app/graphql/types/timelog_type.rb
@@ -49,6 +49,10 @@ module Types
null: true,
description: 'Summary of how the time was spent.'
+ field :project, Types::ProjectType,
+ null: false,
+ description: 'Target project of the timelog merge request or issue.'
+
def user
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index b46362f66b8..888f22b4dd3 100644
--- a/app/graphql/types/work_item_type.rb
+++ b/app/graphql/types/work_item_type.rb
@@ -27,7 +27,10 @@ module Types
GraphQL::Types::Int,
null: false,
description: 'Lock version of the work item. Incremented each time the work item is updated.'
- field :project, Types::ProjectType, null: false,
+ field :namespace, Types::NamespaceType, null: true,
+ description: 'Namespace the work item belongs to.',
+ alpha: { milestone: '15.10' }
+ field :project, Types::ProjectType, null: true,
description: 'Project the work item belongs to.',
alpha: { milestone: '15.3' }
field :state, WorkItemStateEnum, null: false,
diff --git a/app/graphql/types/work_items/available_export_fields_enum.rb b/app/graphql/types/work_items/available_export_fields_enum.rb
index 59dd7ba89b1..f5b26d9818d 100644
--- a/app/graphql/types/work_items/available_export_fields_enum.rb
+++ b/app/graphql/types/work_items/available_export_fields_enum.rb
@@ -8,6 +8,7 @@ module Types
value 'ID', value: 'id', description: 'Unique identifier.'
value 'TITLE', value: 'title', description: 'Title.'
+ value 'DESCRIPTION', value: 'description', description: 'Description.'
value 'TYPE', value: 'type', description: 'Type of the work item.'
value 'AUTHOR', value: 'author', description: 'Author name.'
value 'AUTHOR_USERNAME', value: 'author username', description: 'Author username.'
diff --git a/app/graphql/types/work_items/award_emoji_update_action_enum.rb b/app/graphql/types/work_items/award_emoji_update_action_enum.rb
new file mode 100644
index 00000000000..5b2512a215f
--- /dev/null
+++ b/app/graphql/types/work_items/award_emoji_update_action_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class AwardEmojiUpdateActionEnum < BaseEnum
+ graphql_name 'WorkItemAwardEmojiUpdateAction'
+ description 'Values for work item award emoji update enum'
+
+ value 'ADD', 'Adds the emoji.', value: :add
+ value 'REMOVE', 'Removes the emoji.', value: :remove
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/todo_update_action_enum.rb b/app/graphql/types/work_items/todo_update_action_enum.rb
new file mode 100644
index 00000000000..d9ce8f65396
--- /dev/null
+++ b/app/graphql/types/work_items/todo_update_action_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class TodoUpdateActionEnum < BaseEnum
+ graphql_name 'WorkItemTodoUpdateAction'
+ description 'Values for work item to-do update enum'
+
+ value 'MARK_AS_DONE', 'Marks the to-do as done.', value: 'mark_as_done'
+ value 'ADD', 'Adds the to-do.', value: 'add'
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb
index 50f8e4f7d8a..53ea901ea10 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -19,7 +19,9 @@ module Types
::Types::WorkItems::Widgets::StartAndDueDateType,
::Types::WorkItems::Widgets::MilestoneType,
::Types::WorkItems::Widgets::NotesType,
- ::Types::WorkItems::Widgets::NotificationsType
+ ::Types::WorkItems::Widgets::NotificationsType,
+ ::Types::WorkItems::Widgets::CurrentUserTodosType,
+ ::Types::WorkItems::Widgets::AwardEmojiType
].freeze
def self.ce_orphan_types
@@ -47,6 +49,10 @@ module Types
::Types::WorkItems::Widgets::NotesType
when ::WorkItems::Widgets::Notifications
::Types::WorkItems::Widgets::NotificationsType
+ when ::WorkItems::Widgets::CurrentUserTodos
+ ::Types::WorkItems::Widgets::CurrentUserTodosType
+ when ::WorkItems::Widgets::AwardEmoji
+ ::Types::WorkItems::Widgets::AwardEmojiType
else
raise "Unknown GraphQL type for widget #{object}"
end
diff --git a/app/graphql/types/work_items/widgets/award_emoji_type.rb b/app/graphql/types/work_items/widgets/award_emoji_type.rb
new file mode 100644
index 00000000000..421bb8f0e98
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/award_emoji_type.rb
@@ -0,0 +1,41 @@
+# 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 AwardEmojiType < BaseObject
+ graphql_name 'WorkItemWidgetAwardEmoji'
+ description 'Represents the award emoji widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :award_emoji,
+ ::Types::AwardEmojis::AwardEmojiType.connection_type,
+ null: true,
+ description: 'Award emoji on the work item.'
+ field :downvotes,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Number of downvotes the work item has received.'
+ field :upvotes,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Number of upvotes the work item has received.'
+
+ def downvotes
+ BatchLoaders::AwardEmojiVotesBatchLoader
+ .load_downvotes(object.work_item, awardable_class: 'Issue')
+ end
+
+ def upvotes
+ BatchLoaders::AwardEmojiVotesBatchLoader
+ .load_upvotes(object.work_item, awardable_class: 'Issue')
+ end
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb b/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb
new file mode 100644
index 00000000000..1d43d4913d2
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/award_emoji_update_input_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class AwardEmojiUpdateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetAwardEmojiUpdateInput'
+
+ argument :action, ::Types::WorkItems::AwardEmojiUpdateActionEnum,
+ required: true,
+ description: 'Action for the update.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: copy_field_description(Types::AwardEmojis::AwardEmojiType, :name)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb b/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb
new file mode 100644
index 00000000000..630958def53
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/current_user_todos_input_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class CurrentUserTodosInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetCurrentUserTodosInput'
+
+ argument :action, ::Types::WorkItems::TodoUpdateActionEnum,
+ required: true,
+ description: 'Action for the update.'
+
+ argument :todo_id,
+ ::Types::GlobalIDType[::Todo],
+ required: false,
+ description: "Global ID of the to-do. If not present, all to-dos of the work item will be updated.",
+ prepare: ->(id, _) { id.model_id }
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/current_user_todos_type.rb b/app/graphql/types/work_items/widgets/current_user_todos_type.rb
new file mode 100644
index 00000000000..1c7cdd631e2
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/current_user_todos_type.rb
@@ -0,0 +1,26 @@
+# 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 CurrentUserTodosType < BaseObject
+ graphql_name 'WorkItemWidgetCurrentUserTodos'
+ description 'Represents a todos widget'
+
+ implements Types::WorkItems::WidgetInterface
+ implements Types::CurrentUserTodos
+
+ private
+
+ # Overriden as `Types::CurrentUserTodos` relies on `unpresented` being the Issuable record.
+ def unpresented
+ object.work_item
+ end
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ 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
index e1a9ebb76e9..297b06a8fab 100644
--- a/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
+++ b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
@@ -6,16 +6,27 @@ module Types
class HierarchyUpdateInputType < BaseInputObject
graphql_name 'WorkItemWidgetHierarchyUpdateInput'
- argument :parent_id, ::Types::GlobalIDType[::WorkItem],
+ argument :adjacent_work_item_id,
+ ::Types::GlobalIDType[::WorkItem],
required: false,
loads: ::Types::WorkItemType,
- description: 'Global ID of the parent work item. Use `null` to remove the association.'
+ description: 'ID of the work item to be switched with.'
argument :children_ids, [::Types::GlobalIDType[::WorkItem]],
required: false,
description: 'Global IDs of children work items.',
loads: ::Types::WorkItemType,
as: :children
+
+ 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 :relative_position,
+ Types::RelativePositionTypeEnum,
+ required: false,
+ description: 'Type of switch. Valid values are `BEFORE` or `AFTER`.'
end
end
end
diff --git a/app/helpers/abuse_reports_helper.rb b/app/helpers/abuse_reports_helper.rb
new file mode 100644
index 00000000000..c18c78b26c7
--- /dev/null
+++ b/app/helpers/abuse_reports_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AbuseReportsHelper
+ def valid_image_mimetypes
+ Gitlab::FileTypeDetection::SAFE_IMAGE_EXT
+ .map { |extension| "image/#{extension}" }
+ .to_sentence(last_word_connector: ' or ')
+ end
+end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index a4f19480539..0f6c81f5238 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -2,6 +2,6 @@
module AccountsHelper
def incoming_email_token_enabled?
- current_user.incoming_email_token && Gitlab::IncomingEmail.supports_issue_creation?
+ current_user.incoming_email_token && Gitlab::Email::IncomingEmail.supports_issue_creation?
end
end
diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb
index bd83ed19705..1741d6a953a 100644
--- a/app/helpers/admin/application_settings/settings_helper.rb
+++ b/app/helpers/admin/application_settings/settings_helper.rb
@@ -11,6 +11,10 @@ module Admin
inactive_projects_send_warning_email_after_months: settings.inactive_projects_send_warning_email_after_months
}
end
+
+ def project_missing_pipeline_yaml?(project)
+ project.repository&.gitlab_ci_yml.blank?
+ end
end
end
end
diff --git a/app/helpers/admin/background_migrations_helper.rb b/app/helpers/admin/background_migrations_helper.rb
index 79bb13810bb..cea9cd704c3 100644
--- a/app/helpers/admin/background_migrations_helper.rb
+++ b/app/helpers/admin/background_migrations_helper.rb
@@ -5,6 +5,7 @@ module Admin
def batched_migration_status_badge_variant(migration)
variants = {
active: :info,
+ finalizing: :info,
paused: :warning,
failed: :danger,
finished: :success
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index d0602952f9a..8c0a95b5fa8 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -281,7 +281,11 @@ module ApplicationHelper
end
def startup_css_enabled?
- !params.has_key?(:no_startup_css)
+ !Feature.enabled?(:remove_startup_css) && !params.has_key?(:no_startup_css)
+ end
+
+ def sign_in_with_redirect?
+ current_page?(new_user_session_path) && session[:user_return_to].present?
end
def outdated_browser?
@@ -316,6 +320,7 @@ module ApplicationHelper
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards)
class_names << 'with-performance-bar' if performance_bar_enabled?
+ class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar
class_names << system_message_class
class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com?
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index fd684ee5ecb..42c9481828c 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -356,6 +356,7 @@ module ApplicationSettingsHelper
:shared_runners_text,
:sign_in_text,
:signup_enabled,
+ :silent_mode_enabled,
:sourcegraph_enabled,
:sourcegraph_url,
:sourcegraph_public_only,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index e2e89c9abca..58d647c41b1 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -45,9 +45,11 @@ module AuthHelper
provider_has_builtin_icon?(name) || provider_has_custom_icon?(name)
end
- def qa_class_for_provider(provider)
+ def qa_selector_for_provider(provider)
{
- saml: 'qa-saml-login-button'
+ saml: 'saml_login_button',
+ openid_connect: 'oidc_login_button',
+ github: 'github_login_button'
}[provider.to_sym]
end
diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb
index 5117f7c6d9c..4eda89e2af2 100644
--- a/app/helpers/blame_helper.rb
+++ b/app/helpers/blame_helper.rb
@@ -39,4 +39,14 @@ module BlameHelper
row_height_exp = line_count == 1 ? COMMIT_BLOCK_HEIGHT_EXP : total_line_height_exp
"contain-intrinsic-size: 1px calc(#{row_height_exp})"
end
+
+ def blame_pages_streaming_url(id, project)
+ namespace_project_blame_page_url(namespace_id: project.namespace, project_id: project, id: id, streaming: true)
+ end
+
+ def entire_blame_path(id, project, blame_mode)
+ params = blame_mode.streaming_supported? ? { streaming: true } : { no_pagination: true }
+
+ namespace_project_blame_path(namespace_id: project.namespace, project_id: project, id: id, **params)
+ end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index bb6fd6c3dad..02f69327dff 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -330,4 +330,17 @@ module BlobHelper
@path.to_s.end_with?(Ci::Pipeline::CONFIG_EXTENSION) ||
@path.to_s == @project.ci_config_path_or_default
end
+
+ def vue_blob_app_data(project, blob, ref)
+ {
+ blob_path: blob.path,
+ project_path: project.full_path,
+ resource_id: project.to_global_id,
+ user_id: current_user.present? ? current_user.to_global_id : '',
+ target_branch: project.empty_repo? ? ref : @ref,
+ original_branch: @ref
+ }
+ end
end
+
+BlobHelper.prepend_mod_with('BlobHelper')
diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb
index 38ed6e95a44..6996c7a1766 100644
--- a/app/helpers/breadcrumbs_helper.rb
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -12,7 +12,7 @@ module BreadcrumbsHelper
def breadcrumb_title_link
return @breadcrumb_link if @breadcrumb_link
- request.path
+ request.fullpath
end
def breadcrumb_title(title)
diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb
index 46d78cd6b24..9f70410f17f 100644
--- a/app/helpers/ci/catalog/resources_helper.rb
+++ b/app/helpers/ci/catalog/resources_helper.rb
@@ -3,13 +3,15 @@
module Ci
module Catalog
module ResourcesHelper
- def can_view_private_catalog?(_project)
+ def can_view_namespace_catalog?(_project)
false
end
- def js_ci_catalog_data
+ def js_ci_catalog_data(_project)
{}
end
end
end
end
+
+Ci::Catalog::ResourcesHelper.prepend_mod_with('Ci::Catalog::ResourcesHelper')
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 823332c3d1d..90c89f04dc7 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -101,13 +101,9 @@ module Ci
has_gitlab_ci: has_gitlab_ci?(project).to_s,
pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project),
suggested_ci_templates: suggested_ci_templates.to_json,
- ci_runner_settings_path: project_settings_ci_cd_path(project, anchor: 'js-runners-settings')
+ full_path: project.full_path
}
- experiment(:runners_availability_section, namespace: project.root_ancestor) do |e|
- e.candidate { data[:any_runners_available] = project.active_runners.exists?.to_s }
- end
-
experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e|
e.candidate do
data[:registration_token] = project.runners_token if can?(current_user, :register_project_runners, project)
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 8449bccd285..5d554f57cc0 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -37,7 +37,6 @@ module ClustersHelper
editable: can_edit.to_s,
environment_scope: cluster.environment_scope,
base_domain: cluster.base_domain,
- application_ingress_external_ip: cluster.application_ingress_external_ip,
auto_devops_help_path: help_page_path('topics/autodevops/index'),
external_endpoint_help_path: help_page_path('user/project/clusters/gitlab_managed_clusters.md', anchor: 'base-domain')
}
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index 0352f5a1dfc..7c239f78088 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -41,7 +41,7 @@ module DashboardHelper
if doc_href.present?
link_to_doc = link_to(
- sprite_icon('question'),
+ sprite_icon('question-o'),
doc_href,
class: 'gl-ml-2',
title: _('Documentation'),
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index ce64ac1f21f..2ced1bec5e9 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -132,16 +132,6 @@ module GroupsHelper
}
end
- def verification_for_group_creation_data
- # overridden in EE
- {}
- end
-
- def require_verification_for_namespace_creation_enabled?
- # overridden in EE
- false
- end
-
def group_name_and_path_app_data
{
base_path: root_url,
@@ -161,6 +151,7 @@ module GroupsHelper
new_project_path: new_project_path(namespace_id: group.id),
new_subgroup_illustration: image_path('illustrations/subgroup-create-new-sm.svg'),
new_project_illustration: image_path('illustrations/project-create-new-sm.svg'),
+ empty_projects_illustration: image_path('illustrations/empty-state/empty-projects-md.svg'),
empty_subgroup_illustration: image_path('illustrations/empty-state/empty-subgroup-md.svg'),
render_empty_state: 'true',
can_create_subgroups: can?(current_user, :create_subgroup, group).to_s,
@@ -168,6 +159,26 @@ module GroupsHelper
}
end
+ def group_readme_app_data(group_readme)
+ {
+ web_path: group_readme.present.web_path,
+ name: group_readme.present.name
+ }
+ end
+
+ def show_group_readme?(group)
+ group.group_readme
+ end
+
+ def group_settings_readme_app_data(group)
+ {
+ group_readme_path: group.group_readme&.present&.web_path,
+ readme_project_path: group.readme_project&.present&.path_with_namespace,
+ group_path: group.full_path,
+ group_id: group.id
+ }
+ end
+
def enabled_git_access_protocol_options_for_group
case ::Gitlab::CurrentSettings.enabled_git_access_protocol
when nil, ""
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 063eef41f77..a8dbaa4325f 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -7,6 +7,7 @@ module IdeHelper
'can-use-new-web-ide' => can_use_new_web_ide?.to_s,
'use-new-web-ide' => use_new_web_ide?.to_s,
'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'),
+ 'sign-in-path' => new_session_path(current_user),
'user-preferences-path' => profile_preferences_path,
'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'),
'editor-font-family' => 'JetBrains Mono',
@@ -82,5 +83,3 @@ module IdeHelper
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
end
-
-IdeHelper.prepend_mod_with('IdeHelper')
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 9c68f54f42e..179ce01ae44 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -173,9 +173,6 @@ module IssuablesHelper
output << content_tag(:span, (sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-middle') if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!'))
- output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-md-inline-block gl-ml-3")
- output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none")
-
output.join.html_safe
end
@@ -252,7 +249,7 @@ module IssuablesHelper
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
initialDescriptionText: issuable.description,
- initialTaskStatus: issuable.task_status
+ initialTaskCompletionStatus: issuable.task_completion_status
}
data.merge!(issue_only_initial_data(issuable))
data.merge!(path_data(parent))
@@ -389,6 +386,16 @@ module IssuablesHelper
end
end
+ def issuable_type_selector_data(issuable)
+ {
+ selected_type: issuable.issue_type,
+ is_issue_allowed: create_issue_type_allowed?(@project, :issue).to_s,
+ is_incident_allowed: create_issue_type_allowed?(@project, :incident).to_s,
+ issue_path: new_project_issue_path(@project),
+ incident_path: new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } })
+ }
+ end
+
private
def sidebar_gutter_collapsed?
@@ -438,7 +445,7 @@ module IssuablesHelper
toggleSubscriptionEndpoint: issuable[:toggle_subscription_path],
moveIssueEndpoint: issuable[:move_issue_path],
projectsAutocompleteEndpoint: issuable[:projects_autocomplete_path],
- editable: issuable.dig(:current_user, :can_edit),
+ editable: issuable.dig(:current_user, :can_edit).to_s,
currentUser: issuable[:current_user],
rootPath: root_path,
fullPath: issuable[:project_full_path],
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 39399c2919b..e82f09a0a97 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -44,14 +44,6 @@ module IssuesHelper
end
end
- def work_item_type_icon(issue_type)
- if WorkItems::Type.base_types.include?(issue_type)
- "issue-type-#{issue_type.to_s.dasherize}"
- else
- 'issue-type-issue'
- end
- end
-
def confidential_icon(issue)
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 8c069bc828b..c4967a42a45 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -80,27 +80,20 @@ module LabelsHelper
def suggested_colors
{
+ '#cc338b' => s_('SuggestedColors|Magenta-pink'),
+ '#dc143c' => s_('SuggestedColors|Crimson'),
+ '#c21e56' => s_('SuggestedColors|Rose red'),
+ '#cd5b45' => s_('SuggestedColors|Dark coral'),
+ '#ed9121' => s_('SuggestedColors|Carrot orange'),
+ '#eee600' => s_('SuggestedColors|Titanium yellow'),
'#009966' => s_('SuggestedColors|Green-cyan'),
'#8fbc8f' => s_('SuggestedColors|Dark sea green'),
- '#3cb371' => s_('SuggestedColors|Medium sea green'),
- '#00b140' => s_('SuggestedColors|Green screen'),
- '#013220' => s_('SuggestedColors|Dark green'),
'#6699cc' => s_('SuggestedColors|Blue-gray'),
- '#0000ff' => s_('SuggestedColors|Blue'),
'#e6e6fa' => s_('SuggestedColors|Lavender'),
'#9400d3' => s_('SuggestedColors|Dark violet'),
'#330066' => s_('SuggestedColors|Deep violet'),
- '#808080' => s_('SuggestedColors|Gray'),
'#36454f' => s_('SuggestedColors|Charcoal grey'),
- '#f7e7ce' => s_('SuggestedColors|Champagne'),
- '#c21e56' => s_('SuggestedColors|Rose red'),
- '#cc338b' => s_('SuggestedColors|Magenta-pink'),
- '#dc143c' => s_('SuggestedColors|Crimson'),
- '#ff0000' => s_('SuggestedColors|Red'),
- '#cd5b45' => s_('SuggestedColors|Dark coral'),
- '#eee600' => s_('SuggestedColors|Titanium yellow'),
- '#ed9121' => s_('SuggestedColors|Carrot orange'),
- '#c39953' => s_('SuggestedColors|Aztec Gold')
+ '#808080' => s_('SuggestedColors|Gray')
}
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 7d9be2f93fd..833f2874a90 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -186,7 +186,7 @@ module MergeRequestsHelper
endpoint_metadata: @endpoint_metadata_url,
endpoint_batch: diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params),
endpoint_coverage: @coverage_path,
- endpoint_diff_for_path: diff_for_path_namespace_project_merge_request_path(format: 'json', id: merge_request.iid, namespace_id: project.namespace.path, project_id: project.path),
+ endpoint_diff_for_path: diff_for_path_namespace_project_merge_request_path(format: 'json', id: merge_request.iid, namespace_id: project.namespace.to_param, project_id: project.path),
help_page_path: help_page_path('user/project/merge_requests/reviews/suggestions.md'),
current_user_data: @current_user_data,
update_current_user_path: @update_current_user_path,
@@ -201,7 +201,7 @@ module MergeRequestsHelper
source_project_default_url: @merge_request.source_project && default_url_to_repo(@merge_request.source_project),
source_project_full_path: @merge_request.source_project&.full_path,
is_forked: @project.forked?.to_s,
- saved_replies_new_path: profile_saved_replies_path
+ new_comment_template_path: profile_comment_templates_path
}
end
@@ -233,24 +233,40 @@ module MergeRequestsHelper
end
def merge_request_source_branch(merge_request)
+ fork_icon = if merge_request.for_fork?
+ title = _('The source project is a fork')
+ content_tag(:span, class: 'gl-vertical-align-middle gl-mr-n2 has-tooltip', title: title) do
+ sprite_icon('fork', size: 12, css_class: 'gl-ml-1 has-tooltip')
+ end
+ else
+ ''
+ end
+
branch = if merge_request.for_fork?
- "#{merge_request.source_project_path}:#{merge_request.source_branch}"
+ _('%{fork_icon} %{source_project_path}:%{source_branch}').html_safe % { fork_icon: fork_icon.html_safe, source_project_path: merge_request.source_project_path.html_safe, source_branch: merge_request.source_branch.html_safe }
else
merge_request.source_branch
end
+ branch_title = if merge_request.for_fork?
+ _('%{source_project_path}:%{source_branch}').html_safe % { source_project_path: merge_request.source_project_path.html_safe, source_branch: merge_request.source_branch.html_safe }
+ else
+ merge_request.source_branch
+ end
+
branch_path = if merge_request.source_project
project_tree_path(merge_request.source_project, merge_request.source_branch)
else
''
end
- link_to branch, branch_path, title: branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2'
+ link_to branch, branch_path, title: branch_title, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2'
end
def merge_request_header(project, merge_request)
link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false)
copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'btn btn-default btn-sm gl-button btn-default-tertiary btn-icon gl-display-none! gl-md-display-inline-block! js-source-branch-copy')
+
target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2'
_('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe }
@@ -260,6 +276,10 @@ module MergeRequestsHelper
Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request)
end
+ def single_file_file_by_file?
+ Feature.enabled?(:single_file_file_by_file, @project)
+ end
+
def sticky_header_data
data = {
iid: @merge_request.iid,
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 59ffe6a183e..b101f184ca6 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -97,7 +97,7 @@ module NavHelper
def super_sidebar_supported?
return true if @nav.nil?
- %w(your_work explore project group profile user_profile).include?(@nav)
+ %w(your_work explore project group profile user_profile search admin).include?(@nav)
end
def get_header_links
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index dec1943db54..8861f1ffe9a 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -69,6 +69,11 @@ module PackagesHelper
Ability.allowed?(current_user, :admin_package, project)
end
+ def show_group_package_registry_settings(group)
+ group.packages_feature_enabled? &&
+ Ability.allowed?(current_user, :admin_group, group)
+ end
+
def cleanup_settings_data
{
project_id: @project.id,
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 2442856d7fe..f2fa82aebdb 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -132,7 +132,7 @@ module PreferencesHelper
Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/'
end
- # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
+ # Ensure that anyone adding new options updates `localized_dashboard_choices` too
def validate_dashboard_choices!(user_dashboards)
if user_dashboards.size != localized_dashboard_choices.size
raise "`User` defines #{user_dashboards.size} dashboard choices," \
diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb
index 55216d412a5..1f044ebed3b 100644
--- a/app/helpers/projects/ml/experiments_helper.rb
+++ b/app/helpers/projects/ml/experiments_helper.rb
@@ -15,6 +15,7 @@ module Projects
path_to_artifact: link_to_artifact(candidate),
experiment_name: candidate.experiment.name,
path_to_experiment: link_to_experiment(candidate.project, candidate.experiment),
+ path: link_to_details(candidate),
status: candidate.status
},
metadata: candidate.metadata
@@ -24,6 +25,15 @@ module Projects
Gitlab::Json.generate(data)
end
+ def experiment_as_data(experiment)
+ data = {
+ name: experiment.name,
+ path: link_to_experiment(experiment.project, experiment)
+ }
+
+ Gitlab::Json.generate(data)
+ end
+
def candidates_table_items(candidates)
items = candidates.map do |candidate|
{
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index 5c62920cd89..c5cbe79caf7 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -8,7 +8,7 @@ module Projects
{
failed_jobs_count: pipeline.failed_builds.count,
failed_jobs_summary: prepare_failed_jobs_summary_data(pipeline.failed_builds),
- full_path: project.full_path,
+ project_path: project.full_path,
graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
pipeline_iid: pipeline.iid,
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index a854b9990d2..9452aa491e4 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -2,6 +2,7 @@
module ProjectsHelper
include Gitlab::Utils::StrongMemoize
+ include CompareHelper
def project_incident_management_setting
@project_incident_management_setting ||= @project.incident_management_setting ||
@@ -131,15 +132,20 @@ module ProjectsHelper
source_default_branch = source_project.default_branch
{
+ project_path: project.full_path,
+ selected_branch: ref,
source_name: source_project.full_name,
source_path: project_path(source_project),
source_default_branch: source_default_branch,
+ can_sync_branch: ::Gitlab::UserAccess.new(current_user, container: project).can_update_branch?(ref).to_s,
ahead_compare_path: project_compare_path(
project, from: source_default_branch, to: ref, from_project_id: source_project.id
),
+ create_mr_path: create_mr_path(from: ref, source_project: project, to: source_default_branch, target_project: source_project),
behind_compare_path: project_compare_path(
source_project, from: ref, to: source_default_branch, from_project_id: project.id
- )
+ ),
+ can_user_create_mr_in_fork: can_user_create_mr_in_fork(source_project)
}
end
@@ -161,6 +167,10 @@ module ProjectsHelper
project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source)
end
+ def can_user_create_mr_in_fork(project)
+ can?(current_user, :create_merge_request_in, project)
+ end
+
def project_search_tabs?(tab)
return false unless @project.present?
@@ -837,4 +847,12 @@ def can_admin_group_clusters?(project)
project.group && project.group.clusters.any? && can?(current_user, :admin_cluster, project.group)
end
+def can_view_branch_rules?
+ can?(current_user, :maintainer_access, @project)
+end
+
+def branch_rules_path
+ project_settings_repository_path(@project, anchor: 'js-branch-rules')
+end
+
ProjectsHelper.prepend_mod_with('ProjectsHelper')
diff --git a/app/helpers/protected_branches_helper.rb b/app/helpers/protected_branches_helper.rb
index 07b07bfd33c..bd2a4d1170d 100644
--- a/app/helpers/protected_branches_helper.rb
+++ b/app/helpers/protected_branches_helper.rb
@@ -17,3 +17,5 @@ module ProtectedBranchesHelper
end
end
end
+
+ProtectedBranchesHelper.prepend_mod
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
index 63e2b377fef..a304d14afb9 100644
--- a/app/helpers/routing/pseudonymization_helper.rb
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -13,6 +13,11 @@ module Routing
glm_source
glm_content
_gl
+ utm_medium
+ utm_source
+ utm_campaign
+ utm_content
+ utm_budget
].freeze
def initialize(request_object, group, project)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index d62dc038388..9d14784f086 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -341,7 +341,7 @@ module SearchHelper
# Autocomplete results for the current user's projects
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
- current_user.authorized_projects.order_id_desc.search_by_title(term)
+ current_user.authorized_projects.order_id_desc.search(term, include_namespace: true)
.sorted_by_stars_desc.non_archived.limit(limit).map do |p|
{
category: "Projects",
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 6c9688b0f9d..5bbc89a9d65 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -4,6 +4,8 @@ module SidebarsHelper
include MergeRequestsHelper
include Nav::NewDropdownHelper
+ USER_BAR_COUNT_LIMIT = 99
+
def sidebar_tracking_attributes_by_object(object)
sidebar_attributes_for_object(object).fetch(:tracking_attrs, {})
end
@@ -40,7 +42,7 @@ module SidebarsHelper
Sidebars::Context.new(**context_data, **args)
end
- def super_sidebar_context(user, group:, project:, panel:)
+ def super_sidebar_context(user, group:, project:, panel:, panel_type:) # rubocop:disable Metrics/AbcSize
{
current_menu_items: panel.super_sidebar_menu_items,
current_context_header: panel.super_sidebar_context_header,
@@ -50,15 +52,7 @@ module SidebarsHelper
has_link_to_profile: current_user_menu?(:profile),
link_to_profile: user_url(user),
logo_url: current_appearance&.header_logo_path,
- status: {
- can_update: can?(current_user, :update_user_status, current_user),
- busy: user.status&.busy?,
- customized: user.status&.customized?,
- availability: user.status&.availability.to_s,
- emoji: user.status&.emoji,
- message: user.status&.message_html&.html_safe,
- clear_after: user_clear_status_at(user)
- },
+ status: user_status_menu_data(user),
trial: {
has_start_trial: current_user_menu?(:start_trial),
url: trials_link_url
@@ -70,14 +64,14 @@ module SidebarsHelper
},
can_sign_out: current_user_menu?(:sign_out),
sign_out_link: destroy_user_session_path,
- assigned_open_issues_count: user.assigned_open_issues_count,
+ assigned_open_issues_count: format_user_bar_count(user.assigned_open_issues_count),
todos_pending_count: user.todos_pending_count,
issues_dashboard_path: issues_dashboard_path(assignee_username: user.username),
- total_merge_requests_count: user_merge_requests_counts[:total],
+ total_merge_requests_count: format_user_bar_count(user_merge_requests_counts[:total]),
create_new_menu_groups: create_new_menu_groups(group: group, project: project),
merge_request_menu: create_merge_request_menu(user),
- projects_path: projects_path,
- groups_path: groups_path,
+ projects_path: dashboard_projects_path,
+ groups_path: dashboard_groups_path,
support_path: support_url,
display_whats_new: display_whats_new?,
whats_new_most_recent_release_items_count: whats_new_most_recent_release_items_count,
@@ -88,7 +82,15 @@ module SidebarsHelper
gitlab_com_but_not_canary: Gitlab.com_but_not_canary?,
gitlab_com_and_canary: Gitlab.com_and_canary?,
canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url,
- current_context: super_sidebar_current_context(project: project, group: group)
+ current_context: super_sidebar_current_context(project: project, group: group),
+ context_switcher_links: context_switcher_links,
+ search: search_data,
+ pinned_items: user.pinned_nav_items[panel_type] || [],
+ panel_type: panel_type,
+ update_pins_url: pins_url,
+ is_impersonating: impersonating?,
+ stop_impersonation_path: admin_impersonation_path,
+ shortcut_links: shortcut_links
}
end
@@ -111,6 +113,11 @@ module SidebarsHelper
Sidebars::UserProfile::Panel.new(context)
when 'explore'
Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds))
+ when 'search'
+ context = Sidebars::Context.new(current_user: user, container: nil, **context_adds)
+ Sidebars::Search::Panel.new(context)
+ when 'admin'
+ Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: user, container: nil, **context_adds))
else
context = your_work_sidebar_context(user, **context_adds)
Sidebars::YourWork::Panel.new(context)
@@ -119,6 +126,28 @@ module SidebarsHelper
private
+ def search_data
+ {
+ search_path: search_path,
+ issues_path: issues_dashboard_path,
+ mr_path: merge_requests_dashboard_path,
+ autocomplete_path: search_autocomplete_path,
+ search_context: header_search_context
+ }
+ end
+
+ def user_status_menu_data(user)
+ {
+ can_update: can?(user, :update_user_status, user),
+ busy: user.status&.busy?,
+ customized: user.status&.customized?,
+ availability: user.status&.availability.to_s,
+ emoji: user.status&.emoji,
+ message: user.status&.message_html&.html_safe,
+ clear_after: user_clear_status_at(user)
+ }
+ end
+
def create_new_menu_groups(group:, project:)
new_dropdown_sections = new_dropdown_view_model(group: group, project: project)[:menu_sections]
show_headers = new_dropdown_sections.length > 1
@@ -128,7 +157,14 @@ module SidebarsHelper
items: section[:menu_items].map do |item|
{
text: item[:title],
- href: item[:href]
+ href: item[:href],
+ extraAttrs: {
+ 'data-track-label': item[:id],
+ 'data-track-action': 'click_link',
+ 'data-track-property': 'nav_create_menu',
+ 'data-qa-selector': 'create_menu_item',
+ 'data-qa-create-menu-item': item[:id]
+ }
}
end
}
@@ -143,12 +179,24 @@ module SidebarsHelper
{
text: _('Assigned'),
href: merge_requests_dashboard_path(assignee_username: user.username),
- count: user_merge_requests_counts[:assigned]
+ count: user_merge_requests_counts[:assigned],
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_assigned',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-merge_requests'
+ }
},
{
text: _('Review requests'),
href: merge_requests_dashboard_path(reviewer_username: user.username),
- count: user_merge_requests_counts[:review_requested]
+ count: user_merge_requests_counts[:review_requested],
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_to_review',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-review_requests'
+ }
}
]
}
@@ -260,6 +308,57 @@ module SidebarsHelper
{}
end
+
+ def context_switcher_links
+ links = [
+ # We should probably not return "You work" when used is not logged-in
+ { title: s_('Navigation|Your work'), link: root_path, icon: 'work' },
+ { title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' }
+ ]
+
+ if current_user&.can_admin_all_resources?
+ links.append(
+ { title: s_('Navigation|Admin'), link: admin_root_path, icon: 'admin' }
+ )
+ end
+
+ links
+ end
+
+ # Formats the counts to be shown in the super sidebar's top section (issues, MRs and todos).
+ # We want to avoid printing huge numbers there, so when the count exceeds USER_BAR_COUNT_LIMIT,
+ # we cap it to USER_BAR_COUNT_LIMIT and append a "+" to it.
+ def format_user_bar_count(count)
+ if count > USER_BAR_COUNT_LIMIT
+ "#{USER_BAR_COUNT_LIMIT}+"
+ else
+ count.to_s
+ end
+ end
+
+ def impersonating?
+ !!session[:impersonator_id]
+ end
+
+ def shortcut_links
+ [
+ {
+ title: _('Milestones'),
+ href: dashboard_milestones_path,
+ css_class: 'dashboard-shortcuts-milestones'
+ },
+ {
+ title: _('Snippets'),
+ href: dashboard_snippets_path,
+ css_class: 'dashboard-shortcuts-snippets'
+ },
+ {
+ title: _('Activity'),
+ href: activity_dashboard_path,
+ css_class: 'dashboard-shortcuts-activity'
+ }
+ ]
+ end
end
SidebarsHelper.prepend_mod_with('SidebarsHelper')
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index a1b6e896475..3d31d697452 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -2,13 +2,13 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
- 'approved' => 'approval',
+ 'approved' => 'check',
'unapproved' => 'unapproval',
'cherry_pick' => 'cherry-pick-commit',
'commit' => 'commit',
'description' => 'pencil',
- 'merge' => 'git-merge',
- 'merged' => 'git-merge',
+ 'merge' => 'merge',
+ 'merged' => 'merge',
'opened' => 'issues',
'closed' => 'issue-close',
'time_tracking' => 'timer',
@@ -51,7 +51,11 @@ module SystemNoteHelper
}.freeze
def system_note_icon_name(note)
- ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ if note.system_note_metadata&.action == 'closed' && note.for_merge_request?
+ 'merge-request-close'
+ else
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
end
def icon_for_system_note(note)
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index e0cf7aa61ee..a137ff4d6f2 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -58,12 +58,21 @@ module UsersHelper
end
# Used to preload when you are rendering many projects and checking access
- #
- # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck
def load_max_project_member_accesses(projects)
- current_user&.max_member_access_for_project_ids(projects.pluck(:id))
+ # There are two different request store paradigms for max member access and
+ # we need to preload both of them. One is keyed User the other is keyed by
+ # Project. See https://gitlab.com/gitlab-org/gitlab/-/issues/396822
+
+ # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck
+ project_ids = projects.pluck(:id)
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ Preloaders::UserMaxAccessLevelInProjectsPreloader
+ .new(project_ids, current_user)
+ .execute
+
+ current_user&.max_member_access_for_project_ids(project_ids)
end
- # rubocop: enable CodeReuse/ActiveRecord
def max_project_member_access(project)
current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS
@@ -182,7 +191,8 @@ module UsersHelper
followees: user.followees.count,
followers: user.followers.count,
user_calendar_path: user_calendar_path(user, :json),
- utc_offset: local_timezone_instance(user.timezone).now.utc_offset
+ utc_offset: local_timezone_instance(user.timezone).now.utc_offset,
+ user_id: user.id
}
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index c577e2da1bb..68b15f7e042 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -22,7 +22,7 @@ module VisibilityLevelHelper
when Project
project_visibility_level_description(level)
when Group
- group_visibility_level_description(level)
+ group_visibility_level_description(level, form_model)
end
end
@@ -125,22 +125,39 @@ 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 is granted to members of the group.")
+ s_("VisibilityLevel|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.")
+ s_("VisibilityLevel|The project can be accessed by any logged in user except external users.")
when Gitlab::VisibilityLevel::PUBLIC
- _("The project can be accessed without any authentication.")
+ s_("VisibilityLevel|The project can be accessed without any authentication.")
end
end
- def group_visibility_level_description(level)
+ def show_updated_public_description_for_setting(group)
+ group && !group.new_record? && Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?)
+ end
+
+ def group_visibility_level_description(level, group = nil)
case level
when Gitlab::VisibilityLevel::PRIVATE
- _("The group and its projects can only be viewed by members.")
+ s_("VisibilityLevel|The group and its projects can only be viewed by members.")
when Gitlab::VisibilityLevel::INTERNAL
- _("The group and any internal projects can be viewed by any logged in user except external users.")
+ s_("VisibilityLevel|The group and any internal projects can be viewed by any logged in user except external users.")
when Gitlab::VisibilityLevel::PUBLIC
- _("The group and any public projects can be viewed without any authentication.")
+ unless show_updated_public_description_for_setting(group)
+ return s_('VisibilityLevel|The group and any public projects can be viewed without any authentication.')
+ end
+
+ Kernel.format(
+ s_(
+ 'VisibilityLevel|The group, any public projects, and any of their members, issues, and merge requests can be viewed without authentication. ' \
+ 'Public groups and projects will be indexed by search engines. ' \
+ 'Read more about %{free_user_limit_doc_link_start}free user limits%{link_end}, ' \
+ 'or %{group_billings_link_start}upgrade to a paid tier%{link_end}.'),
+ free_user_limit_doc_link_start: "<a href='#{help_page_path('user/free_user_limit')}' target='_blank' rel='noopener noreferrer'>".html_safe,
+ group_billings_link_start: "<a href='#{group_billings_path(group)}' target='_blank' rel='noopener noreferrer'>".html_safe,
+ link_end: "</a>".html_safe
+ ).html_safe
end
end
diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb
index bc270380fca..6fa5c499ee2 100644
--- a/app/helpers/work_items_helper.rb
+++ b/app/helpers/work_items_helper.rb
@@ -7,7 +7,7 @@ module WorkItemsHelper
issues_list_path: project_issues_path(project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
- saved_replies_new_path: profile_saved_replies_path
+ new_comment_template_path: profile_comment_templates_path
}
end
end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index a191bd4a8f6..54a4c4be6a8 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -177,6 +177,19 @@ module Emails
mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added")))
end
end
+
+ def new_achievement_email(user, achievement)
+ return unless user&.active?
+
+ @user = user
+ @achievement = achievement
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ email_with_layout(
+ to: @user.notification_email_or_default,
+ subject: subject(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement") % { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name }))
+ end
+ end
end
end
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index 1295f978049..e75882073f2 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -43,6 +43,61 @@ module Emails
inject_service_desk_custom_email(mail_answer_thread(@issue, options))
end
+ def service_desk_custom_email_verification_email(service_desk_setting)
+ @service_desk_setting = service_desk_setting
+
+ email_sender = sender(
+ User.support_bot.id,
+ send_from_user_email: false,
+ sender_name: @service_desk_setting.outgoing_name,
+ sender_email: @service_desk_setting.custom_email
+ )
+
+ @verification_token = @service_desk_setting.custom_email_verification.token
+
+ subject = format(s_("Notify|Verify custom email address %{email} for %{project_name}"),
+ email: @service_desk_setting.custom_email,
+ project_name: @service_desk_setting.project.name
+ )
+
+ options = {
+ from: email_sender,
+ to: @service_desk_setting.custom_email_address_for_verification,
+ subject: subject,
+ content_type: "text/plain"
+ }
+ # Outgoing emails from GitLab usually have this set to true.
+ # Service Desk email ingestion ignores auto generated emails.
+ headers["Auto-Submitted"] = "no"
+
+ inject_service_desk_custom_email(mail_with_locale(options), force: true)
+ end
+
+ def service_desk_verification_triggered_email(service_desk_setting, recipient)
+ @service_desk_setting = service_desk_setting
+ @triggerer = @service_desk_setting.custom_email_verification.triggerer
+ @smtp_address = @service_desk_setting.custom_email_credential.smtp_address
+
+ subject = format(s_("Notify|Verification for custom email %{email} for %{project_name} triggered"),
+ email: @service_desk_setting.custom_email,
+ project_name: @service_desk_setting.project.name
+ )
+
+ email_with_layout(to: recipient, subject: subject)
+ end
+
+ def service_desk_verification_result_email(service_desk_setting, recipient)
+ @service_desk_setting = service_desk_setting
+ @verification = @service_desk_setting.custom_email_verification
+
+ subject = format(s_("Notify|Verification result for custom email %{email} for %{project_name}"),
+ email: @service_desk_setting.custom_email,
+ project_name: @service_desk_setting.project.name
+ )
+
+ email_with_layout(to: recipient, subject: subject)
+ end
+
private
def setup_service_desk_mail(issue_id)
@@ -67,10 +122,11 @@ module Emails
end
end
- def inject_service_desk_custom_email(mail)
- return mail unless service_desk_custom_email_enabled?
+ def inject_service_desk_custom_email(mail, force: false)
+ return mail if !service_desk_custom_email_enabled? && !force
+ return mail unless @service_desk_setting.custom_email_credential.present?
- mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_delivery_options)
+ mail.delivery_method(::Mail::SMTP, @service_desk_setting.custom_email_credential.delivery_options)
end
def service_desk_custom_email_enabled?
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 2d6b2a3099c..65fdc233ea1 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -132,8 +132,8 @@ class Notify < ApplicationMailer
@reason = headers['X-GitLab-NotificationReason']
- if Gitlab::IncomingEmail.enabled? && @sent_notification
- headers['Reply-To'] = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)).tap do |address|
+ if Gitlab::Email::IncomingEmail.enabled? && @sent_notification
+ headers['Reply-To'] = Mail::Address.new(Gitlab::Email::IncomingEmail.reply_address(reply_key)).tap do |address|
address.display_name = reply_display_name(model)
end
@@ -221,8 +221,8 @@ class Notify < ApplicationMailer
return unless !@labels_url && @sent_notification && @sent_notification.unsubscribable?
list_unsubscribe_methods = [unsubscribe_sent_notification_url(@sent_notification, force: true)]
- if Gitlab::IncomingEmail.enabled? && Gitlab::IncomingEmail.supports_wildcard?
- list_unsubscribe_methods << "mailto:#{Gitlab::IncomingEmail.unsubscribe_address(reply_key)}"
+ if Gitlab::Email::IncomingEmail.enabled? && Gitlab::Email::IncomingEmail.supports_wildcard?
+ list_unsubscribe_methods << "mailto:#{Gitlab::Email::IncomingEmail.unsubscribe_address(reply_key)}"
end
headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',')
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 17b225c5e9b..510f35ee0d2 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -213,6 +213,52 @@ class NotifyPreview < ActionMailer::Preview
Notify.service_desk_thank_you_email(issue.id).message
end
+ def service_desk_custom_email_verification_email
+ cleanup do
+ setup_service_desk_custom_email_objects
+
+ Notify.service_desk_custom_email_verification_email(service_desk_setting).message
+ end
+ end
+
+ def service_desk_verification_triggered_email
+ cleanup do
+ setup_service_desk_custom_email_objects
+
+ Notify.service_desk_verification_triggered_email(service_desk_setting, 'owner@example.com').message
+ end
+ end
+
+ def service_desk_verification_result_email_for_verified_state
+ cleanup do
+ setup_service_desk_custom_email_objects
+
+ custom_email_verification.update!(state: 1)
+
+ Notify.service_desk_verification_result_email(service_desk_setting, 'owner@example.com').message
+ end
+ end
+
+ def service_desk_verification_result_email_for_incorrect_token_error
+ service_desk_verification_result_email_for_error_state(error: :incorrect_token)
+ end
+
+ def service_desk_verification_result_email_for_incorrect_from_error
+ service_desk_verification_result_email_for_error_state(error: :incorrect_from)
+ end
+
+ def service_desk_verification_result_email_for_mail_not_received_within_timeframe_error
+ service_desk_verification_result_email_for_error_state(error: :mail_not_received_within_timeframe)
+ end
+
+ def service_desk_verification_result_email_for_invalid_credentials_error
+ service_desk_verification_result_email_for_error_state(error: :invalid_credentials)
+ end
+
+ def service_desk_verification_result_email_for_smtp_host_issue_error
+ service_desk_verification_result_email_for_error_state(error: :smtp_host_issue)
+ end
+
def merge_when_pipeline_succeeds_email
Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, user.id).message
end
@@ -247,6 +293,53 @@ class NotifyPreview < ActionMailer::Preview
@project ||= Project.first
end
+ def service_desk_verification_result_email_for_error_state(error:)
+ cleanup do
+ setup_service_desk_custom_email_objects
+
+ custom_email_verification.update!(state: 2, error: error)
+
+ Notify.service_desk_verification_result_email(service_desk_setting, 'owner@example.com').message
+ end
+ end
+
+ def setup_service_desk_custom_email_objects
+ # Call accessors to ensure objects have been created
+ custom_email_credential
+ custom_email_verification
+
+ # Update associations in projects, because we access
+ # custom_email_credential and custom_email_verification via project
+ project.reset
+ end
+
+ def custom_email_verification
+ @custom_email_verification ||= project.service_desk_custom_email_verification || ServiceDesk::CustomEmailVerification.create!(
+ project: project,
+ token: 'XXXXXXXXXXXX',
+ triggerer: user,
+ triggered_at: Time.current,
+ state: 0
+ )
+ end
+
+ def custom_email_credential
+ @custom_email_credential ||= project.service_desk_custom_email_credential || ServiceDesk::CustomEmailCredential.create!(
+ project: project,
+ smtp_address: 'smtp.gmail.com', # Use gmail, because Gitlab::UrlBlocker resolves DNS
+ smtp_port: 587,
+ smtp_username: 'user@gmail.com',
+ smtp_password: 'supersecret'
+ )
+ end
+
+ def service_desk_setting
+ @service_desk_setting ||= project.service_desk_setting || ServiceDeskSetting.create!(
+ project: project,
+ custom_email: 'user@gmail.com'
+ )
+ end
+
def issue
@issue ||= project.issues.first
end
diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb
new file mode 100644
index 00000000000..9ad7c9b14b1
--- /dev/null
+++ b/app/models/abuse/trust_score.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Abuse
+ class TrustScore < ApplicationRecord
+ MAX_EVENTS = 100
+
+ self.table_name = 'abuse_trust_scores'
+
+ enum source: Enums::Abuse::Source.sources
+
+ belongs_to :user
+
+ validates :user, presence: true
+ validates :score, presence: true
+ validates :source, presence: true
+
+ before_create :assign_correlation_id
+ after_commit :remove_old_scores
+
+ private
+
+ def assign_correlation_id
+ self.correlation_id_value ||= (Labkit::Correlation::CorrelationId.current_id || '')
+ end
+
+ def remove_old_scores
+ count = user.trust_scores_for_source(source).count
+ return unless count > MAX_EVENTS
+
+ TrustScore.delete(
+ user.trust_scores_for_source(source)
+ .order(created_at: :asc)
+ .limit(count - MAX_EVENTS)
+ )
+ end
+ end
+end
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 5ae5367ca5a..716738e87c9 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -3,8 +3,11 @@
class AbuseReport < ApplicationRecord
include CacheMarkdownField
include Sortable
+ include Gitlab::FileTypeDetection
+ include WithUploads
MAX_CHAR_LIMIT_URL = 512
+ MAX_FILE_SIZE = 1.megabyte
cache_markdown_field :message, pipeline: :single_line
@@ -42,6 +45,10 @@ class AbuseReport < ApplicationRecord
before_validation :filter_empty_strings_from_links_to_spam
validate :links_to_spam_contains_valid_urls
+ mount_uploader :screenshot, AttachmentUploader
+ validates :screenshot, file_size: { maximum: MAX_FILE_SIZE }
+ validate :validate_screenshot_is_image
+
scope :by_user_id, ->(id) { where(user_id: id) }
scope :by_reporter_id, ->(id) { where(reporter_id: id) }
scope :by_category, ->(category) { where(category: category) }
@@ -84,6 +91,20 @@ class AbuseReport < ApplicationRecord
AbuseReportMailer.notify(id).deliver_later
end
+ def screenshot_path
+ return unless screenshot
+ return screenshot.url unless screenshot.upload
+
+ asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url
+ local_path = Gitlab::Routing.url_helpers.abuse_report_upload_path(
+ filename: screenshot.filename,
+ id: screenshot.upload.model_id,
+ model: 'abuse_report',
+ mounted_as: 'screenshot')
+
+ Gitlab::Utils.append_path(asset_host, local_path)
+ end
+
private
def filter_empty_strings_from_links_to_spam
@@ -113,4 +134,24 @@ class AbuseReport < ApplicationRecord
rescue ::Gitlab::UrlBlocker::BlockedUrlError
errors.add(:links_to_spam, _('only supports valid HTTP(S) URLs'))
end
+
+ def filename
+ screenshot&.filename
+ end
+
+ def valid_image_extensions
+ Gitlab::FileTypeDetection::SAFE_IMAGE_EXT
+ end
+
+ def validate_screenshot_is_image
+ return if screenshot.blank?
+ return if image?
+
+ errors.add(
+ :screenshot,
+ format(
+ _('must match one of the following file types: %{extension_list}'),
+ extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or '))
+ )
+ end
end
diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb
index 95606e50ad4..a436e32b35b 100644
--- a/app/models/achievements/achievement.rb
+++ b/app/models/achievements/achievement.rb
@@ -4,9 +4,6 @@ module Achievements
class Achievement < ApplicationRecord
include Avatarable
include StripAttribute
- include IgnorableColumns
-
- ignore_column :revokable, remove_with: '15.11', remove_after: '2023-04-22'
belongs_to :namespace, inverse_of: :achievements, optional: false
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 2d1dec1977d..133466e93e3 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -91,13 +91,6 @@ class ActiveSession
active_user_session.dump
)
- # Deprecated legacy format - temporary to support mixed deployments
- pipeline.setex(
- key_name_v1(user.id, session_private_id),
- expiry,
- Marshal.dump(active_user_session)
- )
-
pipeline.sadd?(
lookup_key_name(user.id),
session_private_id
@@ -107,6 +100,19 @@ class ActiveSession
end
end
+ # set marketing cookie when user has active session
+ def self.set_active_user_cookie(auth)
+ auth.cookies[:about_gitlab_active_user] =
+ {
+ value: true,
+ domain: Gitlab.config.gitlab.host
+ }
+ end
+
+ def self.unset_active_user_cookie(auth)
+ auth.cookies.delete :about_gitlab_active_user
+ end
+
def self.list(user)
Gitlab::Redis::Sessions.with do |redis|
cleaned_up_lookup_entries(redis, user).map do |raw_session|
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index b926c6abedc..8d6048d45d5 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Appearance < ApplicationRecord
+class Appearance < MainClusterwide::ApplicationRecord
include CacheableAttributes
include CacheMarkdownField
include WithUploads
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 71434931d8c..52abacfe3e8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -26,6 +26,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
# rather than the persisted value.
ADDRESSABLE_URL_VALIDATION_OPTIONS = { deny_all_requests_except_allowed: ->(settings) { settings.deny_all_requests_except_allowed } }.freeze
+ HUMANIZED_ATTRIBUTES = {
+ archive_builds_in_seconds: 'Archive job value'
+ }.freeze
+
enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true
enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 }, _prefix: true
@@ -336,7 +340,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :archive_builds_in_seconds,
allow_nil: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds }
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: 1.day.seconds,
+ message: N_('must be at least 1 day')
+ }
validates :local_markdown_version,
allow_nil: true,
@@ -431,6 +439,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ validates :silent_mode_enabled,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
Gitlab::SSHPublicKey.supported_types.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -654,6 +665,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :inactive_projects_send_warning_email_after_months,
numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months }
+ validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -696,6 +709,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :product_analytics_clickhouse_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :product_analytics_configurator_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
validates :disable_feed_token,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -873,6 +888,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
private
+ def self.human_attribute_name(attribute, *options)
+ HUMANIZED_ATTRIBUTES[attribute.to_sym] || super
+ end
+
def parsed_grafana_url
@parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url)
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index b8d6434d9c9..010c88179df 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -97,7 +97,7 @@ module ApplicationSettingImplementation
group_import_limit: 6,
help_page_hide_commercial_content: false,
help_page_text: nil,
- help_page_documentation_base_url: nil,
+ help_page_documentation_base_url: 'https://docs.gitlab.com',
hide_third_party_offers: false,
housekeeping_enabled: true,
housekeeping_full_repack_period: 50,
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index dbc5c7a584e..31bee8db1b4 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -7,6 +7,9 @@ class AwardEmoji < ApplicationRecord
include Participable
include GhostUser
include Importable
+ include IgnorableColumns
+
+ ignore_column :awardable_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb
deleted file mode 100644
index 0b652984630..00000000000
--- a/app/models/awareness_session.rb
+++ /dev/null
@@ -1,245 +0,0 @@
-# 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|
- Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- 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
- end
-
- nil
- end
-
- def leave(user)
- user_key = user_sessions_key(user.id)
-
- with_redis do |redis|
- Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- redis.pipelined do |pipeline|
- pipeline.srem?(user_key, id_i)
- pipeline.zrem(users_key, user.id)
- end
- 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 =
- Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- redis.pipelined do |pipeline|
- pipeline.scard(user_key)
- pipeline.zcard(users_key)
- end
- end
-
- Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- 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
- 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/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb
index 9d1376de0cb..aac7271242e 100644
--- a/app/models/blob_viewer/composer_json.rb
+++ b/app/models/blob_viewer/composer_json.rb
@@ -15,7 +15,7 @@ module BlobViewer
end
def package_name
- @package_name ||= package_name_from_json('name')
+ @package_name ||= fetch_from_json('name')
end
def package_url
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
index a3801025cd7..71bd90e7459 100644
--- a/app/models/blob_viewer/dependency_manager.rb
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -38,8 +38,10 @@ module BlobViewer
end
end
- def package_name_from_json(key)
- json_data[key]
+ def fetch_from_json(...)
+ json_data.dig(...)
+ rescue TypeError
+ nil
end
def package_name_from_method_call(name)
diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb
index 1d10cc82a85..5350b6b0626 100644
--- a/app/models/blob_viewer/package_json.rb
+++ b/app/models/blob_viewer/package_json.rb
@@ -11,7 +11,7 @@ module BlobViewer
end
def yarn?
- json_data['engines'].present? && json_data['engines']['yarn'].present?
+ fetch_from_json('engines', 'yarn').present?
end
def manager_url
@@ -19,7 +19,7 @@ module BlobViewer
end
def package_name
- @package_name ||= package_name_from_json('name')
+ @package_name ||= fetch_from_json('name')
end
def package_type
@@ -33,11 +33,11 @@ module BlobViewer
private
def private?
- !!json_data['private']
+ !!fetch_from_json('private')
end
def homepage
- url = json_data['homepage']
+ url = fetch_from_json('homepage')
url if Gitlab::UrlSanitizer.valid?(url)
end
diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb
index d3f6ae269da..d606f72376d 100644
--- a/app/models/blob_viewer/podspec_json.rb
+++ b/app/models/blob_viewer/podspec_json.rb
@@ -5,7 +5,7 @@ module BlobViewer
self.file_types = %i(podspec_json)
def package_name
- @package_name ||= package_name_from_json('name')
+ @package_name ||= fetch_from_json('name')
end
end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index c5a234ffa69..14aecbc9420 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
-class BroadcastMessage < ApplicationRecord
+class BroadcastMessage < MainClusterwide::ApplicationRecord
include CacheMarkdownField
include Sortable
+ include IgnorableColumns
ALLOWED_TARGET_ACCESS_LEVELS = [
Gitlab::Access::GUEST,
@@ -12,6 +13,8 @@ class BroadcastMessage < ApplicationRecord
Gitlab::Access::OWNER
].freeze
+ ignore_column :namespace_id, remove_with: '16.0', remove_after: '2022-06-22'
+
cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index ae2d3758110..b3540917197 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -44,24 +44,19 @@ class BulkImports::Entity < ApplicationRecord
validates :source_full_path,
presence: true,
format: { with: Gitlab::Regex.bulk_import_source_full_path_regex,
- message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message }
+ message: Gitlab::Regex.bulk_import_source_full_path_regex_message }
validates :destination_name,
presence: true,
- format: { with: Gitlab::Regex.group_path_regex,
- message: Gitlab::Regex.group_path_regex_message }
+ if: -> { group || project }
validates :destination_namespace,
exclusion: [nil],
- format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex,
- message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message },
if: :group
validates :destination_namespace,
presence: true,
- format: { with: Gitlab::Regex.bulk_import_destination_namespace_path_regex,
- message: Gitlab::Regex.bulk_import_destination_namespace_path_regex_message },
- if: :project
+ if: :project?
validate :validate_parent_is_a_group, if: :parent
validate :validate_imported_entity_type
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 697f06fbffd..b77e0f1d5c1 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -55,8 +55,6 @@ module Ci
end
def retryable?
- return false unless Feature.enabled?(:ci_recreate_downstream_pipeline, project)
-
return false if failed? && (pipeline_loop_detected? || reached_max_descendant_pipelines_depth?)
super
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 627604ec26c..d389c59f16b 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -55,9 +55,9 @@ module Ci
has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', foreign_key: :job_id, inverse_of: :job
end
- has_one :runner_machine_build, class_name: 'Ci::RunnerMachineBuild', foreign_key: :build_id, inverse_of: :build,
+ has_one :runner_manager_build, class_name: 'Ci::RunnerManagerBuild', foreign_key: :build_id, inverse_of: :build,
autosave: true
- has_one :runner_machine, through: :runner_machine_build, class_name: 'Ci::RunnerMachine'
+ has_one :runner_manager, foreign_key: :runner_machine_id, through: :runner_manager_build, class_name: 'Ci::RunnerManager'
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, foreign_key: :build_id, inverse_of: :build
has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', foreign_key: :build_id, inverse_of: :build
@@ -597,8 +597,14 @@ module Ci
.append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
.append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true)
.append(key: 'CI_JOB_STARTED_AT', value: started_at&.iso8601)
- .append(key: 'CI_BUILD_ID', value: id.to_s)
- .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
+
+ if Feature.disabled?(:ci_remove_legacy_predefined_variables, project)
+ variables
+ .append(key: 'CI_BUILD_ID', value: id.to_s)
+ .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
+ end
+
+ variables
.append(key: 'CI_REGISTRY_USER', value: ::Gitlab::Auth::CI_JOB_USER)
.append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
.append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 4b2be446fe3..b98fdba44ec 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -11,9 +11,11 @@ module Ci
include ChronicDurationAttribute
include Gitlab::Utils::StrongMemoize
include IgnorableColumns
+ include SafelyChangeColumnDefault
self.table_name = 'p_ci_builds_metadata'
self.primary_key = 'id'
+ columns_changing_default :partition_id
partitionable scope: :build
diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb
index f70e1ed69ea..b9a74102641 100644
--- a/app/models/ci/build_trace.rb
+++ b/app/models/ci/build_trace.rb
@@ -12,7 +12,11 @@ module Ci
if stream.valid?
stream.limit
- @trace = Gitlab::Ci::Ansi2json.convert(stream.stream, state)
+ @trace = Gitlab::Ci::Ansi2json.convert(
+ stream.stream,
+ state,
+ verify_state: Feature.enabled?(:sign_and_verify_ansi2json_state, build.project)
+ )
end
end
diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb
index 00cf1531483..4c76089617f 100644
--- a/app/models/ci/build_trace_metadata.rb
+++ b/app/models/ci/build_trace_metadata.rb
@@ -42,9 +42,7 @@ module Ci
end
def track_archival!(trace_artifact_id, checksum)
- update!(trace_artifact_id: trace_artifact_id,
- checksum: checksum,
- archived_at: Time.current)
+ update!(trace_artifact_id: trace_artifact_id, checksum: checksum, archived_at: Time.current)
end
def archival_attempts_message
diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb
index 92464cb645f..b9e777f27a0 100644
--- a/app/models/ci/catalog/listing.rb
+++ b/app/models/ci/catalog/listing.rb
@@ -27,7 +27,7 @@ module Ci
def projects_in_namespace_visible_to_user
Project
.in_namespace(namespace.self_and_descendant_ids)
- .public_or_visible_to_user(current_user)
+ .public_or_visible_to_user(current_user, ::Gitlab::Access::DEVELOPER)
end
end
end
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index 1b3dec5f54d..bb4584aacae 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -11,6 +11,18 @@ module Ci
self.table_name = 'catalog_resources'
belongs_to :project
+
+ scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
+
+ delegate :avatar_path, :description, :name, to: :project
+
+ def versions
+ project.releases.order_released_desc
+ end
+
+ def latest_version
+ versions.first
+ end
end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 5a7860174ff..10f0dd865ff 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -14,6 +14,9 @@ module Ci
include EachBatch
include Gitlab::Utils::StrongMemoize
+ # NOTE: Temporarily ignore. This will will be used in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106740
+ ignore_column :file_final_path, remove_with: '16.1', remove_after: '2023-05-23'
+
enum accessibility: { public: 0, private: 1 }, _suffix: true
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb
index 5ea51fbe0a7..ff7e681217a 100644
--- a/app/models/ci/namespace_mirror.rb
+++ b/app/models/ci/namespace_mirror.rb
@@ -41,8 +41,7 @@ module Ci
namespace = event.namespace
traversal_ids = namespace.self_and_ancestor_ids(hierarchy_order: :desc)
- upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids },
- unique_by: :namespace_id)
+ upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids }, unique_by: :namespace_id)
end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 2b0c79aab87..d06051c7a15 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -52,26 +52,39 @@ module Ci
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
has_internal_id :iid, scope: :project, presence: false,
- track_if: -> { !importing? },
- ensure_if: -> { !importing? },
- init: ->(pipeline, scope) do
- if pipeline
- pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
- elsif scope
- ::Ci::Pipeline.where(**scope).maximum(:iid)
- end
- end
+ track_if: -> { !importing? },
+ ensure_if: -> { !importing? },
+ init: ->(pipeline, scope) do
+ if pipeline
+ pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
+ elsif scope
+ ::Ci::Pipeline.where(**scope).maximum(:iid)
+ end
+ end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
+
+ #
+ # In https://gitlab.com/groups/gitlab-org/-/epics/9991, we aim to convert all CommitStatus related models to
+ # Ci:Job models. With that epic, we aim to replace `statuses` with `jobs`.
+ #
+ # DEPRECATED:
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :statuses_order_id_desc, -> { order_id_desc }, class_name: 'CommitStatus', foreign_key: :commit_id,
inverse_of: :pipeline
- has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :generic_commit_statuses, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus'
+ #
+ # NEW:
+ has_many :all_jobs, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :current_jobs, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :all_processable_jobs, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :current_processable_jobs, -> { latest }, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
+
has_many :job_artifacts, through: :builds
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
@@ -386,6 +399,7 @@ module Ci
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) }
+ scope :preload_pipeline_metadata, -> { preload(:pipeline_metadata) }
scope :outside_pipeline_family, ->(pipeline) do
where.not(id: pipeline.same_family_pipeline_ids)
@@ -407,11 +421,15 @@ module Ci
# In general, please use `Ci::PipelinesForMergeRequestFinder` instead,
# for checking permission of the actor.
scope :triggered_by_merge_request, -> (merge_request) do
- where(source: :merge_request_event,
- merge_request: merge_request,
- project: [merge_request.source_project, merge_request.target_project])
+ where(
+ source: :merge_request_event,
+ merge_request: merge_request,
+ project: [merge_request.source_project, merge_request.target_project]
+ )
end
+ scope :order_id_desc, -> { order(id: :desc) }
+
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
#
@@ -682,7 +700,7 @@ module Ci
# rubocop: enable CodeReuse/ServiceClass
def lazy_ref_commit
- BatchLoader.for(ref).batch do |refs, loader|
+ BatchLoader.for(ref).batch(key: project.id) do |refs, loader|
next unless project.repository_exists?
project.repository.list_commits_by_ref_name(refs).then do |commits|
@@ -843,8 +861,7 @@ module Ci
when 'manual' then block
when 'scheduled' then delay
else
- raise Ci::HasStatus::UnknownStatusError,
- "Unknown status `#{new_status}`"
+ raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`"
end
end
end
@@ -1319,7 +1336,7 @@ module Ci
def cluster_agent_authorizations
strong_memoize(:cluster_agent_authorizations) do
- ::Clusters::AgentAuthorizationsFinder.new(project).execute
+ ::Clusters::Agents::Authorizations::CiAccess::Finder.new(project).execute
end
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 83e6fa2f862..49d27053745 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -83,6 +83,8 @@ module Ci
Settings.cron_jobs['pipeline_schedule_worker']['cron']
end
+ # Using destroy instead of before_destroy as we want nullify_dependent_associations_in_batches
+ # to run first and not in a transaction block. This prevents timeouts for schedules with numerous pipelines
def destroy
nullify_dependent_associations_in_batches
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 37c82c125aa..4c421f066f9 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Ci
+ # This class is a collection of common features between Ci::Build and Ci::Bridge.
+ # In https://gitlab.com/groups/gitlab-org/-/epics/9991, we aim to clarify class naming conventions.
class Processable < ::CommitStatus
include Gitlab::Utils::StrongMemoize
include FromUnion
diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb
index 15a161d5b7c..23cd5d92730 100644
--- a/app/models/ci/project_mirror.rb
+++ b/app/models/ci/project_mirror.rb
@@ -13,8 +13,7 @@ module Ci
class << self
def sync!(event)
- upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id },
- unique_by: :project_id)
+ upsert({ project_id: event.project_id, namespace_id: event.project.namespace_id }, unique_by: :project_id)
end
end
end
diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb
index af5fdabff6e..199e1cd07e7 100644
--- a/app/models/ci/ref.rb
+++ b/app/models/ci/ref.rb
@@ -43,8 +43,7 @@ module Ci
class << self
def ensure_for(pipeline)
- safe_find_or_create_by(project_id: pipeline.project_id,
- ref_path: pipeline.source_ref_path)
+ safe_find_or_create_by(project_id: pipeline.project_id, ref_path: pipeline.source_ref_path)
end
def failing_state?(status_name)
diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb
index a220aa7bb18..48f321a236d 100644
--- a/app/models/ci/resource_group.rb
+++ b/app/models/ci/resource_group.rb
@@ -58,6 +58,10 @@ module Ci
end
end
+ def current_processable
+ Ci::Processable.find_by('(id, partition_id) IN (?)', resources.select('build_id, partition_id'))
+ end
+
private
# In order to avoid deadlock, we do NOT specify the job execution order in the same pipeline.
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 6fefe95769b..80a3d8df632 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -18,9 +18,9 @@ module Ci
extend ::Gitlab::Utils::Override
add_authentication_token_field :token,
- encrypted: :optional,
- expires_at: :compute_token_expiration,
- format_with_prefix: :prefix_for_new_and_legacy_runner
+ encrypted: :optional,
+ expires_at: :compute_token_expiration,
+ format_with_prefix: :prefix_for_new_and_legacy_runner
enum access_level: {
not_protected: 0,
@@ -70,7 +70,7 @@ module Ci
TAG_LIST_MAX_LENGTH = 50
- has_many :runner_machines, inverse_of: :runner
+ has_many :runner_managers, inverse_of: :runner
has_many :builds
has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects, disable_joins: true
@@ -134,7 +134,7 @@ module Ci
belonging_to_group(group_self_and_ancestors_ids)
}
- scope :belonging_to_parent_group_of_project, -> (project_id) {
+ scope :belonging_to_parent_groups_of_project, -> (project_id) {
raise ArgumentError, "only 1 project_id allowed for performance reasons" unless project_id.is_a?(Integer)
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
@@ -148,7 +148,7 @@ module Ci
from_union(
[
belonging_to_project(project_id),
- project.group_runners_enabled? ? belonging_to_parent_group_of_project(project_id) : nil,
+ project.group_runners_enabled? ? belonging_to_parent_groups_of_project(project_id) : nil,
project.shared_runners
].compact,
remove_duplicates: false
@@ -215,16 +215,14 @@ module Ci
cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout,
- error_message: 'Maximum job timeout has a value which could not be accepted'
+ error_message: 'Maximum job timeout has a value which could not be accepted'
validates :maximum_timeout, allow_nil: true,
- numericality: { greater_than_or_equal_to: 600,
- message: 'needs to be at least 10 minutes' }
+ numericality: { greater_than_or_equal_to: 600, message: 'needs to be at least 10 minutes' }
validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor,
allow_nil: false,
- numericality: { greater_than_or_equal_to: 0.0,
- message: 'needs to be non-negative' }
+ numericality: { greater_than_or_equal_to: 0.0, message: 'needs to be non-negative' }
validates :config, json_schema: { filename: 'ci_runner_config' }
@@ -498,14 +496,14 @@ module Ci
end
end
- def ensure_machine(system_xid, &blk)
- RunnerMachine.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods
+ def ensure_manager(system_xid, &blk)
+ RunnerManager.safe_find_or_create_by!(runner_id: id, system_xid: system_xid.to_s, &blk) # rubocop: disable Performance/ActiveRecordSubtransactionMethods
end
def registration_available?
authenticated_user_registration_type? &&
created_at > REGISTRATION_AVAILABILITY_TIME.ago &&
- !runner_machines.any?
+ !runner_managers.any?
end
private
@@ -595,7 +593,7 @@ module Ci
end
def exactly_one_group
- unless runner_namespaces.one?
+ unless runner_namespaces.size == 1
errors.add(:runner, 'needs to be assigned to exactly one group')
end
end
diff --git a/app/models/ci/runner_machine_build.rb b/app/models/ci/runner_machine_build.rb
deleted file mode 100644
index d4f2c403337..00000000000
--- a/app/models/ci/runner_machine_build.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class RunnerMachineBuild < Ci::ApplicationRecord
- include Ci::Partitionable
-
- self.table_name = :p_ci_runner_machine_builds
- self.primary_key = :build_id
-
- partitionable scope: :build, partitioned: true
-
- belongs_to :build, inverse_of: :runner_machine_build, class_name: 'Ci::Build'
- belongs_to :runner_machine, inverse_of: :runner_machine_builds, class_name: 'Ci::RunnerMachine'
-
- validates :build, presence: true
- validates :runner_machine, presence: true
-
- scope :for_build, ->(build_id) { where(build_id: build_id) }
-
- def self.pluck_build_id_and_runner_machine_id
- select(:build_id, :runner_machine_id)
- .pluck(:build_id, :runner_machine_id)
- .to_h
- end
- end
-end
diff --git a/app/models/ci/runner_machine.rb b/app/models/ci/runner_manager.rb
index 8cf395aadb4..e36024d9f5b 100644
--- a/app/models/ci/runner_machine.rb
+++ b/app/models/ci/runner_manager.rb
@@ -1,20 +1,23 @@
# frozen_string_literal: true
module Ci
- class RunnerMachine < Ci::ApplicationRecord
+ class RunnerManager < Ci::ApplicationRecord
include FromUnion
include RedisCacheable
include Ci::HasRunnerExecutor
+ # For legacy reasons, the table name is ci_runner_machines in the database
+ self.table_name = 'ci_runner_machines'
+
# The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated
UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes)
belongs_to :runner
- has_many :runner_machine_builds, inverse_of: :runner_machine, class_name: 'Ci::RunnerMachineBuild'
- has_many :builds, through: :runner_machine_builds, class_name: 'Ci::Build'
- belongs_to :runner_version, inverse_of: :runner_machines, primary_key: :version, foreign_key: :version,
- class_name: 'Ci::RunnerVersion'
+ has_many :runner_manager_builds, inverse_of: :runner_manager, class_name: 'Ci::RunnerManagerBuild'
+ has_many :builds, through: :runner_manager_builds, class_name: 'Ci::Build'
+ belongs_to :runner_version, inverse_of: :runner_managers, primary_key: :version, foreign_key: :version,
+ class_name: 'Ci::RunnerVersion'
validates :runner, presence: true
validates :system_xid, presence: true, length: { maximum: 64 }
@@ -27,7 +30,7 @@ module Ci
cached_attr_reader :version, :revision, :platform, :architecture, :ip_address, :contacted_at, :executor_type
- # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner machine
+ # The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner manager
# will be considered stale
STALE_TIMEOUT = 7.days
diff --git a/app/models/ci/runner_manager_build.rb b/app/models/ci/runner_manager_build.rb
new file mode 100644
index 00000000000..322c5ae3a68
--- /dev/null
+++ b/app/models/ci/runner_manager_build.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnerManagerBuild < Ci::ApplicationRecord
+ include Ci::Partitionable
+
+ self.table_name = :p_ci_runner_machine_builds
+ self.primary_key = :build_id
+
+ partitionable scope: :build, partitioned: true
+
+ alias_attribute :runner_manager_id, :runner_machine_id
+
+ belongs_to :build, inverse_of: :runner_manager_build, class_name: 'Ci::Build'
+ belongs_to :runner_manager, foreign_key: :runner_machine_id, inverse_of: :runner_manager_builds,
+ class_name: 'Ci::RunnerManager'
+
+ validates :build, presence: true
+ validates :runner_manager, presence: true
+
+ scope :for_build, ->(build_id) { where(build_id: build_id) }
+
+ def self.pluck_build_id_and_runner_manager_id
+ select(:build_id, :runner_manager_id)
+ .pluck(:build_id, :runner_manager_id)
+ .to_h
+ end
+ end
+end
diff --git a/app/models/ci/runner_version.rb b/app/models/ci/runner_version.rb
index 41e7a2b8e8a..03b50f13989 100644
--- a/app/models/ci/runner_version.rb
+++ b/app/models/ci/runner_version.rb
@@ -19,7 +19,7 @@ module Ci
recommended: 'Upgrade is available and recommended for the runner.'
}.freeze
- has_many :runner_machines, inverse_of: :runner_version, foreign_key: :version, class_name: 'Ci::RunnerMachine'
+ has_many :runner_managers, inverse_of: :runner_version, foreign_key: :version, class_name: 'Ci::RunnerManager'
# 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
diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb
index 43214b0c336..e6f80658f5d 100644
--- a/app/models/ci/running_build.rb
+++ b/app/models/ci/running_build.rb
@@ -24,10 +24,12 @@ module Ci
raise ArgumentError, 'build has not been picked by a shared runner'
end
- entry = self.new(build: build,
- project: build.project,
- runner: build.runner,
- runner_type: build.runner.runner_type)
+ entry = self.new(
+ build: build,
+ project: build.project,
+ runner: build.runner,
+ runner_type: build.runner.runner_type
+ )
entry.validate!
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 02093bdf153..d61760bd0fc 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -112,8 +112,7 @@ module Ci
when 'scheduled' then delay
when 'skipped', nil then skip
else
- raise Ci::HasStatus::UnknownStatusError,
- "Unknown status `#{new_status}`"
+ raise Ci::HasStatus::UnknownStatusError, "Unknown status `#{new_status}`"
end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 1b2a7dc3fe4..0cfe2d50283 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -26,8 +26,7 @@ module Ci
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32,
- encode: false,
- encode_vi: false
+ encode: false
before_validation :set_default_values
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 3478bb69707..374deabfe33 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -12,11 +12,17 @@ module Clusters
has_many :agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent
- has_many :group_authorizations, class_name: 'Clusters::Agents::GroupAuthorization'
- has_many :authorized_groups, class_name: '::Group', through: :group_authorizations, source: :group
+ has_many :ci_access_group_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::GroupAuthorization'
+ has_many :ci_access_authorized_groups, class_name: '::Group', through: :ci_access_group_authorizations, source: :group
- has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
- has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project
+ has_many :ci_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization'
+ has_many :ci_access_authorized_projects, class_name: '::Project', through: :ci_access_project_authorizations, source: :project
+
+ has_many :user_access_group_authorizations, class_name: 'Clusters::Agents::Authorizations::UserAccess::GroupAuthorization'
+ has_many :user_access_authorized_groups, class_name: '::Group', through: :user_access_group_authorizations, source: :group
+
+ has_many :user_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization'
+ has_many :user_access_authorized_projects, class_name: '::Project', through: :user_access_project_authorizations, source: :project
has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent
diff --git a/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb
new file mode 100644
index 00000000000..4261fd6570f
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/ci_access/group_authorization.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class GroupAuthorization < ApplicationRecord
+ include ConfigScopes
+
+ self.table_name = 'agent_group_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :group, class_name: '::Group', optional: false
+
+ validates :config, json_schema: { filename: 'clusters_agents_authorizations_ci_access_config' }
+
+ def config_project
+ agent.project
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb
new file mode 100644
index 00000000000..b996ae3f92b
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/ci_access/implicit_authorization.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class ImplicitAuthorization
+ attr_reader :agent
+
+ delegate :id, to: :agent, prefix: true
+
+ def initialize(agent:)
+ @agent = agent
+ end
+
+ def config_project
+ agent.project
+ end
+
+ def config
+ {}
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb b/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb
new file mode 100644
index 00000000000..7742d109cdb
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/ci_access/project_authorization.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class ProjectAuthorization < ApplicationRecord
+ include ConfigScopes
+
+ self.table_name = 'agent_project_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :project, class_name: '::Project', optional: false
+
+ validates :config, json_schema: { filename: 'clusters_agents_authorizations_ci_access_config' }
+
+ def config_project
+ agent.project
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/authorizations/user_access/group_authorization.rb b/app/models/clusters/agents/authorizations/user_access/group_authorization.rb
new file mode 100644
index 00000000000..e46a52e73a6
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/user_access/group_authorization.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class GroupAuthorization < ApplicationRecord
+ self.table_name = 'agent_user_access_group_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :group, class_name: '::Group', optional: false
+
+ validates :config, json_schema: { filename: 'clusters_agents_authorizations_user_access_config' }
+
+ def config_project
+ agent.project
+ end
+
+ class << self
+ def upsert_configs(configs)
+ upsert_all(configs, unique_by: [:agent_id, :group_id])
+ end
+
+ def delete_unlisted(group_ids)
+ where.not(group_id: group_ids).delete_all
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/authorizations/user_access/project_authorization.rb b/app/models/clusters/agents/authorizations/user_access/project_authorization.rb
new file mode 100644
index 00000000000..2b0cbd3032a
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/user_access/project_authorization.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class ProjectAuthorization < ApplicationRecord
+ self.table_name = 'agent_user_access_project_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :project, class_name: '::Project', optional: false
+
+ validates :config, json_schema: { filename: 'clusters_agents_authorizations_user_access_config' }
+
+ def config_project
+ agent.project
+ end
+
+ class << self
+ def upsert_configs(configs)
+ upsert_all(configs, unique_by: [:agent_id, :project_id])
+ end
+
+ def delete_unlisted(project_ids)
+ where.not(project_id: project_ids).delete_all
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb
deleted file mode 100644
index 58ba874ab53..00000000000
--- a/app/models/clusters/agents/group_authorization.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- class GroupAuthorization < ApplicationRecord
- include ::Clusters::Agents::AuthorizationConfigScopes
-
- self.table_name = 'agent_group_authorizations'
-
- belongs_to :agent, class_name: 'Clusters::Agent', optional: false
- belongs_to :group, class_name: '::Group', optional: false
-
- validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
-
- def config_project
- agent.project
- end
- end
- end
-end
diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb
deleted file mode 100644
index a365ccdc568..00000000000
--- a/app/models/clusters/agents/implicit_authorization.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- class ImplicitAuthorization
- attr_reader :agent
-
- delegate :id, to: :agent, prefix: true
-
- def initialize(agent:)
- @agent = agent
- end
-
- def config_project
- agent.project
- end
-
- def config
- {}
- end
- end
- end
-end
diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb
deleted file mode 100644
index b9b44741936..00000000000
--- a/app/models/clusters/agents/project_authorization.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- class ProjectAuthorization < ApplicationRecord
- include ::Clusters::Agents::AuthorizationConfigScopes
-
- self.table_name = 'agent_project_authorizations'
-
- belongs_to :agent, class_name: 'Clusters::Agent', optional: false
- belongs_to :project, class_name: '::Project', optional: false
-
- validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
-
- def config_project
- agent.project
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
deleted file mode 100644
index 9fac852ed5b..00000000000
--- a/app/models/clusters/applications/helm.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-# frozen_string_literal: true
-
-require 'openssl'
-
-module Clusters
- module Applications
- # DEPRECATED: This model represents the Helm 2 Tiller server.
- # It is being kept around to enable the cleanup of the unused Tiller server.
- class Helm < ApplicationRecord
- self.table_name = 'clusters_applications_helm'
-
- attr_encrypted :ca_key,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_truncated,
- algorithm: 'aes-256-cbc'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Gitlab::Utils::StrongMemoize
-
- attribute :version, default: Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION
-
- before_create :create_keys_and_certs
-
- def issue_client_cert
- ca_cert_obj.issue
- end
-
- def set_initial_status
- # The legacy Tiller server is not installable, which is the initial status of every app
- end
-
- # DEPRECATED: This command is only for development and testing purposes, to simulate
- # a Helm 2 cluster with an existing Tiller server.
- def install_command
- Gitlab::Kubernetes::Helm::V2::InitCommand.new(
- name: name,
- files: files,
- rbac: cluster.platform_kubernetes_rbac?
- )
- end
-
- def uninstall_command
- Gitlab::Kubernetes::Helm::V2::ResetCommand.new(
- name: name,
- files: files,
- rbac: cluster.platform_kubernetes_rbac?
- )
- end
-
- def has_ssl?
- ca_key.present? && ca_cert.present?
- end
-
- private
-
- def files
- {
- 'ca.pem': ca_cert,
- 'cert.pem': tiller_cert.cert_string,
- 'key.pem': tiller_cert.key_string
- }
- end
-
- def create_keys_and_certs
- ca_cert = Gitlab::Kubernetes::Helm::V2::Certificate.generate_root
- self.ca_key = ca_cert.key_string
- self.ca_cert = ca_cert.cert_string
- end
-
- def tiller_cert
- @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::V2::Certificate::INFINITE_EXPIRY)
- end
-
- def ca_cert_obj
- return unless has_ssl?
-
- Gitlab::Kubernetes::Helm::V2::Certificate
- .from_strings(ca_key, ca_cert)
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
deleted file mode 100644
index 034b178d67d..00000000000
--- a/app/models/clusters/applications/ingress.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- # DEPRECATED for removal in %14.0
- # See https://gitlab.com/groups/gitlab-org/-/epics/4280
- class Ingress < ApplicationRecord
- VERSION = '1.40.2'
- INGRESS_CONTAINER_NAME = 'nginx-ingress-controller'
-
- self.table_name = 'clusters_applications_ingress'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
- include AfterCommitQueue
- include UsageStatistics
-
- attribute :version, default: VERSION
-
- enum ingress_type: {
- nginx: 1
- }, _default: :nginx
-
- FETCH_IP_ADDRESS_DELAY = 30.seconds
-
- state_machine :status do
- after_transition any => [:installed] do |application|
- application.run_after_commit do
- ClusterWaitForIngressIpAddressWorker.perform_in(
- FETCH_IP_ADDRESS_DELAY, application.name, application.id)
- end
- end
- end
-
- def chart
- "#{name}/nginx-ingress"
- end
-
- def repository
- 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
- end
-
- def values
- content_values.to_yaml
- end
-
- def allowed_to_uninstall?
- external_ip_or_hostname? && !application_jupyter_installed?
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: name,
- repository: repository,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files
- )
- end
-
- def external_ip_or_hostname?
- external_ip.present? || external_hostname.present?
- end
-
- def schedule_status_update
- return unless installed?
- return if external_ip
- return if external_hostname
-
- ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
- end
-
- def ingress_service
- cluster.kubeclient.get_service("ingress-#{INGRESS_CONTAINER_NAME}", Gitlab::Kubernetes::Helm::NAMESPACE)
- end
-
- private
-
- def content_values
- YAML.load_file(chart_values_file)
- end
-
- def application_jupyter_installed?
- cluster.application_jupyter&.installed?
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
deleted file mode 100644
index 9c0e90d59ed..00000000000
--- a/app/models/clusters/applications/jupyter.rb
+++ /dev/null
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-require 'securerandom'
-
-module Clusters
- module Applications
- # DEPRECATED for removal in %14.0
- # See https://gitlab.com/groups/gitlab-org/-/epics/4280
- class Jupyter < ApplicationRecord
- VERSION = '0.9.0'
-
- self.table_name = 'clusters_applications_jupyter'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
-
- belongs_to :oauth_application, class_name: 'Doorkeeper::Application'
-
- attribute :version, default: VERSION
-
- def set_initial_status
- return unless not_installable?
- return unless cluster&.application_ingress_available?
-
- ingress = cluster.application_ingress
- self.status = status_states[:installable] if ingress.external_ip_or_hostname?
- end
-
- def chart
- "#{name}/jupyterhub"
- end
-
- def repository
- 'https://jupyterhub.github.io/helm-chart/'
- end
-
- def values
- content_values.to_yaml
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: name,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files,
- repository: repository
- )
- end
-
- def callback_url
- "http://#{hostname}/hub/oauth_callback"
- end
-
- def oauth_scopes
- 'api read_repository write_repository'
- end
-
- private
-
- def specification
- {
- "ingress" => {
- "hosts" => [hostname],
- "tls" => [{
- "hosts" => [hostname],
- "secretName" => "jupyter-cert"
- }]
- },
- "hub" => {
- "extraEnv" => {
- "GITLAB_HOST" => gitlab_url
- },
- "cookieSecret" => cookie_secret
- },
- "proxy" => {
- "secretToken" => secret_token
- },
- "auth" => {
- "state" => {
- "cryptoKey" => crypto_key
- },
- "gitlab" => {
- "clientId" => oauth_application.uid,
- "clientSecret" => oauth_application.secret,
- "callbackUrl" => callback_url,
- "gitlabProjectIdWhitelist" => cluster.projects.ids,
- "gitlabGroupWhitelist" => cluster.groups.map(&:to_param)
- }
- },
- "singleuser" => {
- "extraEnv" => {
- "GITLAB_CLUSTER_ID" => cluster.id.to_s,
- "GITLAB_HOST" => gitlab_host
- }
- }
- }
- end
-
- def crypto_key
- @crypto_key ||= SecureRandom.hex(32)
- end
-
- def gitlab_url
- Gitlab.config.gitlab.url
- end
-
- def gitlab_host
- Gitlab.config.gitlab.host
- end
-
- def content_values
- YAML.load_file(chart_values_file).deep_merge!(specification)
- end
-
- def secret_token
- @secret_token ||= SecureRandom.hex(32)
- end
-
- def cookie_secret
- @cookie_secret ||= SecureRandom.hex(32)
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
deleted file mode 100644
index c8c043f3312..00000000000
--- a/app/models/clusters/applications/knative.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- # DEPRECATED for removal in %14.0
- # See https://gitlab.com/groups/gitlab-org/-/epics/4280
- class Knative < ApplicationRecord
- VERSION = '0.10.0'
- REPOSITORY = 'https://charts.gitlab.io'
- METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml'
- FETCH_IP_ADDRESS_DELAY = 30.seconds
- API_GROUPS_PATH = 'config/knative/api_groups.yml'
-
- self.table_name = 'clusters_applications_knative'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
- include AfterCommitQueue
-
- alias_method :original_set_initial_status, :set_initial_status
- def set_initial_status
- return unless cluster&.platform_kubernetes_rbac?
-
- original_set_initial_status
- end
-
- state_machine :status do
- after_transition any => [:installed] do |application|
- application.run_after_commit do
- ClusterWaitForIngressIpAddressWorker.perform_in(
- FETCH_IP_ADDRESS_DELAY, application.name, application.id)
- end
- end
-
- after_transition any => [:installed, :updated] do |application|
- application.run_after_commit do
- ClusterConfigureIstioWorker.perform_async(application.cluster_id)
- end
- end
- end
-
- attribute :version, default: VERSION
-
- validates :hostname, presence: true, hostname: true
-
- scope :for_cluster, -> (cluster) { where(cluster: cluster) }
-
- def chart
- 'knative/knative'
- end
-
- def values
- { "domain" => hostname }.to_yaml
- end
-
- def available_domains
- PagesDomain.instance_serverless
- end
-
- def find_available_domain(pages_domain_id)
- available_domains.find_by(id: pages_domain_id)
- end
-
- def allowed_to_uninstall?
- !pre_installed?
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: name,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files,
- repository: REPOSITORY,
- postinstall: install_knative_metrics
- )
- end
-
- def schedule_status_update
- return unless installed?
- return if external_ip
- return if external_hostname
-
- ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
- end
-
- def ingress_service
- cluster.kubeclient.get_service('istio-ingressgateway', Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE)
- end
-
- def uninstall_command
- helm_command_module::DeleteCommand.new(
- name: name,
- rbac: cluster.platform_kubernetes_rbac?,
- files: files,
- predelete: delete_knative_services_and_metrics,
- postdelete: delete_knative_istio_leftovers
- )
- end
-
- private
-
- def delete_knative_services_and_metrics
- delete_knative_services + delete_knative_istio_metrics
- end
-
- def delete_knative_services
- cluster.kubernetes_namespaces.map do |kubernetes_namespace|
- Gitlab::Kubernetes::KubectlCmd.delete("ksvc", "--all", "-n", kubernetes_namespace.namespace)
- end
- end
-
- def delete_knative_istio_leftovers
- delete_knative_namespaces + delete_knative_and_istio_crds
- end
-
- def delete_knative_namespaces
- [
- Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-serving"),
- Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "ns", "knative-build")
- ]
- end
-
- def delete_knative_and_istio_crds
- api_groups.map do |group|
- Gitlab::Kubernetes::KubectlCmd.delete_crds_from_group(group)
- end
- end
-
- # returns an array of CRDs to be postdelete since helm does not
- # manage the CRDs it creates.
- def api_groups
- @api_groups ||= YAML.safe_load(File.read(Rails.root.join(API_GROUPS_PATH)))
- end
-
- # Relied on application_prometheus which is now removed
- def install_knative_metrics
- []
- end
-
- # Relied on application_prometheus which is now removed
- def delete_knative_istio_metrics
- []
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
deleted file mode 100644
index b8ed33828bc..00000000000
--- a/app/models/clusters/applications/runner.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class Runner < ApplicationRecord
- VERSION = '0.42.1'
-
- self.table_name = 'clusters_applications_runners'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
-
- belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
- delegate :project, :group, to: :cluster
-
- attribute :version, default: VERSION
-
- def chart
- "#{name}/gitlab-runner"
- end
-
- def repository
- 'https://charts.gitlab.io'
- end
-
- def values
- content_values.to_yaml
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: name,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files,
- repository: repository
- )
- end
-
- def prepare_uninstall
- # No op, see https://gitlab.com/gitlab-org/gitlab/-/issues/350180.
- end
-
- def post_uninstall
- runner.destroy!
- end
-
- private
-
- def gitlab_url
- Gitlab::Routing.url_helpers.root_url(only_path: false)
- end
-
- def specification
- {
- "gitlabUrl" => gitlab_url,
- "runners" => { "privileged" => privileged }
- }
- end
-
- def content_values
- YAML.load_file(chart_values_file).deep_merge!(specification)
- end
- end
- end
-end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 5cd11265808..a2903bba6d2 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -11,16 +11,8 @@ module Clusters
self.table_name = 'clusters'
- APPLICATIONS = {
- Clusters::Applications::Helm.application_name => Clusters::Applications::Helm,
- Clusters::Applications::Ingress.application_name => Clusters::Applications::Ingress,
- Clusters::Applications::Runner.application_name => Clusters::Applications::Runner,
- Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter,
- Clusters::Applications::Knative.application_name => Clusters::Applications::Knative
- }.freeze
DEFAULT_ENVIRONMENT = '*'
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
- APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze
self.reactive_cache_work_type = :external_dependency
@@ -52,12 +44,6 @@ module Clusters
has_one application.association_name, class_name: application.to_s, inverse_of: :cluster # rubocop:disable Rails/ReflectionClassName
end
- has_one_cluster_application :helm
- has_one_cluster_application :ingress
- has_one_cluster_application :runner
- has_one_cluster_application :jupyter
- has_one_cluster_application :knative
-
has_many :kubernetes_namespaces
has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster
@@ -84,9 +70,6 @@ module Clusters
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, 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
-
alias_attribute :base_domain, :domain
alias_attribute :provided_by_user?, :user?
@@ -119,7 +102,6 @@ module Clusters
scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct }
scope :managed, -> { where(managed: true) }
- scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
scope :with_management_project, -> { where.not(management_project: nil) }
@@ -228,24 +210,6 @@ module Clusters
connection_data.merge(Gitlab::Kubernetes::Node.new(self).all)
end
- def persisted_applications
- APPLICATIONS_ASSOCIATIONS.filter_map { |association_name| public_send(association_name) } # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def applications
- APPLICATIONS.each_value.map do |application_class|
- find_or_build_application(application_class)
- end
- end
-
- def find_or_build_application(application_class)
- raise ArgumentError, "#{application_class} is not in APPLICATIONS" unless APPLICATIONS.value?(application_class)
-
- association_name = application_class.association_name
-
- public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
- end
-
def find_or_build_integration_prometheus
integration_prometheus || build_integration_prometheus
end
@@ -266,18 +230,6 @@ module Clusters
!!platform_kubernetes&.rbac?
end
- def application_helm_available?
- !!application_helm&.available?
- end
-
- def application_ingress_available?
- !!application_ingress&.available?
- end
-
- def application_knative_available?
- !!application_knative&.available?
- end
-
def integration_prometheus_available?
!!integration_prometheus&.available?
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 716be080851..4f6ca5a9617 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -8,10 +8,12 @@ class CommitStatus < Ci::ApplicationRecord
include Presentable
include BulkInsertableAssociations
include TaggableQueries
+ include SafelyChangeColumnDefault
self.table_name = 'ci_builds'
self.primary_key = :id
partitionable scope: :pipeline
+ columns_changing_default :partition_id
belongs_to :user
belongs_to :project
diff --git a/app/models/compare.rb b/app/models/compare.rb
index f03390334f4..58279cb58aa 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -30,7 +30,7 @@ class Compare
# See `namespace_project_compare_url`
def to_param
{
- from: @straight ? start_commit_sha : base_commit_sha,
+ from: @straight ? start_commit_sha : (base_commit_sha || start_commit_sha),
to: head_commit_sha
}
end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
index 1bdb89349aa..c01399184ad 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
@@ -74,7 +74,7 @@ module Analytics
query = <<~SQL
INSERT INTO #{quoted_table_name}
(
- stage_event_hash_id,
+ stage_event_hash_id,
#{connection.quote_column_name(issuable_id_column)},
group_id,
project_id,
diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb
deleted file mode 100644
index da87d87e838..00000000000
--- a/app/models/concerns/awareness.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
index c3aa3019abb..11e88ee3372 100644
--- a/app/models/concerns/bulk_member_access_load.rb
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -5,16 +5,20 @@ module BulkMemberAccessLoad
included do
def merge_value_to_request_store(resource_klass, resource_id, value)
- Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass),
- resource_ids: [resource_id],
- default_value: Gitlab::Access::NO_ACCESS) do
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: max_member_access_for_resource_key(resource_klass),
+ resource_ids: [resource_id],
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do
{ resource_id => value }
end
end
def purge_resource_id_from_request_store(resource_klass, resource_id)
- Gitlab::SafeRequestPurger.execute(resource_key: max_member_access_for_resource_key(resource_klass),
- resource_ids: [resource_id])
+ Gitlab::SafeRequestPurger.execute(
+ resource_key: max_member_access_for_resource_key(resource_klass),
+ resource_ids: [resource_id]
+ )
end
def max_member_access_for_resource_key(klass)
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index d91f33452a0..1c6b82d6ea7 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -9,10 +9,11 @@ module Ci
extend ActiveSupport::Concern
included do
- has_one :metadata, class_name: 'Ci::BuildMetadata',
- foreign_key: :build_id,
- inverse_of: :build,
- autosave: true
+ has_one :metadata,
+ class_name: 'Ci::BuildMetadata',
+ foreign_key: :build_id,
+ inverse_of: :build,
+ autosave: true
accepts_nested_attributes_for :metadata
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
index 28cc17432bc..d8417773dbd 100644
--- a/app/models/concerns/ci/partitionable.rb
+++ b/app/models/concerns/ci/partitionable.rb
@@ -2,7 +2,7 @@
module Ci
##
- # This module implements a way to set the `partion_id` value on a dependent
+ # This module implements a way to set the `partition_id` value on a dependent
# resource from a parent record.
# Usage:
#
@@ -36,7 +36,7 @@ module Ci
Ci::Pipeline
Ci::PendingBuild
Ci::RunningBuild
- Ci::RunnerMachineBuild
+ Ci::RunnerManagerBuild
Ci::PipelineVariable
Ci::Sources::Pipeline
Ci::Stage
diff --git a/app/models/concerns/clusters/agents/authorization_config_scopes.rb b/app/models/concerns/clusters/agents/authorization_config_scopes.rb
deleted file mode 100644
index 0a0406c3389..00000000000
--- a/app/models/concerns/clusters/agents/authorization_config_scopes.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- module AuthorizationConfigScopes
- extend ActiveSupport::Concern
-
- included do
- scope :with_available_ci_access_fields, ->(project) {
- where("config->'access_as' IS NULL")
- .or(where("config->'access_as' = '{}'"))
- .or(where("config->'access_as' ?| array[:fields]", fields: available_ci_access_fields(project)))
- }
- end
-
- class_methods do
- def available_ci_access_fields(_project)
- %w(agent)
- end
- end
- end
- end
-end
-
-Clusters::Agents::AuthorizationConfigScopes.prepend_mod
diff --git a/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb
new file mode 100644
index 00000000000..eef68bfd349
--- /dev/null
+++ b/app/models/concerns/clusters/agents/authorizations/ci_access/config_scopes.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ module ConfigScopes
+ extend ActiveSupport::Concern
+
+ included do
+ scope :with_available_ci_access_fields, ->(project) {
+ where("config->'access_as' IS NULL")
+ .or(where("config->'access_as' = '{}'"))
+ .or(where("config->'access_as' ?| array[:fields]", fields: available_ci_access_fields(project)))
+ }
+ end
+
+ class_methods do
+ def available_ci_access_fields(_project)
+ %w(agent)
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+Clusters::Agents::Authorizations::CiAccess::ConfigScopes.prepend_mod
diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb
index 9f75b3ed4d8..37b479d5237 100644
--- a/app/models/concerns/database_event_tracking.rb
+++ b/app/models/concerns/database_event_tracking.rb
@@ -30,7 +30,7 @@ module DatabaseEventTracking
# that reports data asynchronously and does not impact performance nor carries a risk of
# rollback in case of error
- Gitlab::Tracking.event(
+ Gitlab::Tracking.database_event(
self.class.to_s,
"database_event_#{name}",
label: self.class.table_name,
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index 40891073738..d3ebda2702d 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -7,20 +7,20 @@ module DiscussionOnDiff
NUMBER_OF_TRUNCATED_DIFF_LINES = 16
included do
- delegate :line_code,
- :original_line_code,
- :note_diff_file,
- :diff_line,
- :active?,
- :created_at_diff?,
- to: :first_note
-
- delegate :file_path,
- :blob,
- :highlighted_diff_lines,
- :diff_lines,
- to: :diff_file,
- allow_nil: true
+ delegate :line_code,
+ :original_line_code,
+ :note_diff_file,
+ :diff_line,
+ :active?,
+ :created_at_diff?,
+ to: :first_note
+
+ delegate :file_path,
+ :blob,
+ :highlighted_diff_lines,
+ :diff_lines,
+ to: :diff_file,
+ allow_nil: true
end
def diff_discussion?
diff --git a/app/models/concerns/enums/abuse/source.rb b/app/models/concerns/enums/abuse/source.rb
new file mode 100644
index 00000000000..80703126aae
--- /dev/null
+++ b/app/models/concerns/enums/abuse/source.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Enums
+ module Abuse
+ module Source
+ def self.sources
+ {
+ spamcheck: 0,
+ virus_total: 1,
+ arkose_custom_score: 2,
+ arkose_global_score: 3,
+ telesign: 4,
+ pvs: 5
+ }
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb
index a8227363a22..8e161c1513f 100644
--- a/app/models/concerns/enums/internal_id.rb
+++ b/app/models/concerns/enums/internal_id.rb
@@ -17,7 +17,8 @@ module Enums
sprints: 9, # iterations
design_management_designs: 10,
incident_management_oncall_schedules: 11,
- ml_experiments: 12
+ ml_experiments: 12,
+ ml_candidates: 13
}
end
end
diff --git a/app/models/concerns/enums/package_metadata.rb b/app/models/concerns/enums/package_metadata.rb
index e15fe758e69..a866e2b995a 100644
--- a/app/models/concerns/enums/package_metadata.rb
+++ b/app/models/concerns/enums/package_metadata.rb
@@ -10,11 +10,19 @@ module Enums
maven: 5,
npm: 6,
nuget: 7,
- pypi: 8
+ pypi: 8,
+ apk: 9,
+ rpm: 10,
+ deb: 11,
+ cbl_mariner: 12
}.with_indifferent_access.freeze
def self.purl_types
PURL_TYPES
end
+
+ def self.purl_types_numerical
+ purl_types.invert
+ end
end
end
diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb
index 8848c0c5555..3ba911dbcc5 100644
--- a/app/models/concerns/enums/sbom.rb
+++ b/app/models/concerns/enums/sbom.rb
@@ -14,7 +14,11 @@ module Enums
maven: 5,
npm: 6,
nuget: 7,
- pypi: 8
+ pypi: 8,
+ apk: 9,
+ rpm: 10,
+ deb: 11,
+ cbl_mariner: 12
}.with_indifferent_access.freeze
def self.component_types
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
index 5975ea23723..cc55315d6d7 100644
--- a/app/models/concerns/expirable.rb
+++ b/app/models/concerns/expirable.rb
@@ -8,7 +8,7 @@ module Expirable
included do
scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) }
- scope :expired, -> { where('expires_at IS NOT NULL AND expires_at <= ?', Time.current) }
+ scope :expired, -> { where.not(expires_at: nil).where(arel_table[:expires_at].lteq(Time.current)) }
scope :not_expired, -> { self.not(expired) }
end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
index 224ac8930b5..de316446e14 100644
--- a/app/models/concerns/group_descendant.rb
+++ b/app/models/concerns/group_descendant.rb
@@ -60,10 +60,7 @@ module GroupDescendant
end
if parent && parent != hierarchy_top
- expand_hierarchy_for_child(parent,
- { parent => hierarchy },
- hierarchy_top,
- preloaded)
+ expand_hierarchy_for_child(parent, { parent => hierarchy }, hierarchy_top, preloaded)
else
hierarchy
end
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 0b1c6780db8..468ea26c51a 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -4,7 +4,8 @@ module HasUserType
extend ActiveSupport::Concern
USER_TYPES = {
- human: nil,
+ human_deprecated: nil,
+ human: 0,
support_bot: 1,
alert_bot: 2,
visual_review_bot: 3,
@@ -17,7 +18,8 @@ module HasUserType
security_policy_bot: 10, # Currently not in use. See https://gitlab.com/gitlab-org/gitlab/-/issues/384174
admin_bot: 11,
suggested_reviewers_bot: 12,
- service_account: 13
+ service_account: 13,
+ llm_bot: 14
}.with_indifferent_access.freeze
BOT_USER_TYPES = %w[
@@ -32,15 +34,20 @@ module HasUserType
admin_bot
suggested_reviewers_bot
service_account
+ llm_bot
].freeze
# `service_account` allows instance/namespaces to configure a user for external integrations/automations
# `service_user` is an internal, `gitlab-com`-specific user type for integrations like suggested reviewers
- NON_INTERNAL_USER_TYPES = %w[human project_bot service_user service_account].freeze
+ NON_INTERNAL_USER_TYPES = %w[human human_deprecated project_bot service_user service_account].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
included do
- scope :humans, -> { where(user_type: :human) }
+ enum user_type: USER_TYPES
+
+ scope :humans, -> { where(user_type: :human).or(where(user_type: :human_deprecated)) }
+ # Override default scope to include temporary human type. See https://gitlab.com/gitlab-org/gitlab/-/issues/386474
+ scope :human, -> { humans }
scope :bots, -> { where(user_type: BOT_USER_TYPES) }
scope :without_bots, -> { humans.or(where(user_type: USER_TYPES.keys - BOT_USER_TYPES)) }
scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
@@ -48,10 +55,8 @@ module HasUserType
scope :without_project_bot, -> { humans.or(where(user_type: USER_TYPES.keys - ['project_bot'])) }
scope :human_or_service_user, -> { humans.or(where(user_type: :service_user)) }
- enum user_type: USER_TYPES
-
def human?
- super || user_type.nil?
+ super || human_deprecated? || user_type.nil?
end
end
diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb
index 57f8e21c5a6..223191fb963 100644
--- a/app/models/concerns/integrations/has_issue_tracker_fields.rb
+++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb
@@ -8,29 +8,29 @@ module Integrations
self.field_storage = :data_fields
field :project_url,
- required: true,
- title: -> { _('Project URL') },
- help: -> do
- s_('IssueTracker|The URL to the project in the external issue tracker.')
- end
+ required: true,
+ title: -> { _('Project URL') },
+ help: -> do
+ s_('IssueTracker|The URL to the project in the external issue tracker.')
+ end
field :issues_url,
- required: true,
- title: -> { s_('IssueTracker|Issue URL') },
- help: -> do
- 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
+ required: true,
+ title: -> { s_('IssueTracker|Issue URL') },
+ help: -> do
+ 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,
- title: -> { s_('IssueTracker|New issue URL') },
- help: -> do
- s_('IssueTracker|The URL to create an issue in the external issue tracker.')
- end
+ required: true,
+ title: -> { s_('IssueTracker|New issue URL') },
+ 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/issuable.rb b/app/models/concerns/issuable.rb
index c1c1691e424..6594884ca0a 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -84,11 +84,11 @@ module Issuable
has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true
delegate :name,
- :email,
- :public_email,
- to: :author,
- allow_nil: true,
- prefix: true
+ :email,
+ :public_email,
+ to: :author,
+ allow_nil: true,
+ prefix: true
validates :author, presence: true
validates :title, presence: true, length: { maximum: TITLE_LENGTH_MAX }
@@ -345,8 +345,7 @@ module Issuable
order_milestone_due_asc
.order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
- .reorder(milestones_due_date_with_direction.nulls_last,
- highest_priority_arel_with_direction.nulls_last)
+ .reorder(milestones_due_date_with_direction.nulls_last, highest_priority_arel_with_direction.nulls_last)
end
def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false)
@@ -620,8 +619,10 @@ module Issuable
end
def updated_tasks
- Taskable.get_updated_tasks(old_content: previous_changes['description'].first,
- new_content: description)
+ Taskable.get_updated_tasks(
+ old_content: previous_changes['description'].first,
+ new_content: description
+ )
end
##
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
index 209456f8b67..c5d194a93e7 100644
--- a/app/models/concerns/issue_available_features.rb
+++ b/app/models/concerns/issue_available_features.rb
@@ -27,7 +27,14 @@ module IssueAvailableFeatures
raise ArgumentError, 'invalid feature'
end
- self.class.available_features_for_issue_types[feature].include?(issue_type)
+ type_for_issue = if Feature.enabled?(:issue_type_uses_work_item_types_table)
+ # The default will only be used in places where an issue is only build and not saved
+ work_item_type_with_default.base_type
+ else
+ issue_type
+ end
+
+ self.class.available_features_for_issue_types[feature].include?(type_for_issue)
end
end
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
index 0cccb7b51a8..7ed7f65ca57 100644
--- a/app/models/concerns/limitable.rb
+++ b/app/models/concerns/limitable.rb
@@ -59,7 +59,10 @@ module Limitable
def check_plan_limit_not_exceeded(limits, relation)
return unless limits&.exceeded?(limit_name, relation)
- errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
- { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
+ errors.add(
+ :base,
+ _("Maximum number of %{name} (%{count}) exceeded") %
+ { name: limit_name.humanize(capitalize: false), count: limits.public_send(limit_name) } # rubocop:disable GitlabSecurity/PublicSend
+ )
end
end
diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb
index b05beb6c764..0265d609e19 100644
--- a/app/models/concerns/mentionable/reference_regexes.rb
+++ b/app/models/concerns/mentionable/reference_regexes.rb
@@ -5,9 +5,7 @@ module Mentionable
extend Gitlab::Utils::StrongMemoize
def self.reference_pattern(link_patterns, issue_pattern)
- Regexp.union(link_patterns,
- issue_pattern,
- *other_patterns)
+ Regexp.union(link_patterns, issue_pattern, *other_patterns)
end
def self.other_patterns
@@ -29,7 +27,7 @@ module Mentionable
def self.external_pattern
strong_memoize(:external_pattern) do
- issue_pattern = Integrations::BaseIssueTracker.reference_pattern
+ issue_pattern = Integrations::BaseIssueTracker.base_reference_pattern
link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https))
reference_pattern(link_patterns, issue_pattern)
end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index facf0808e7a..6ed2cfb6f78 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -27,13 +27,11 @@ module ProtectedRefAccess
scope :for_user, -> { where.not(user_id: nil) }
scope :for_group, -> { where.not(group_id: nil) }
- validates :access_level, presence: true, if: :role?, inclusion: {
- in: self.allowed_access_levels
- }
+ validates :access_level, presence: true, if: :role?, inclusion: { in: allowed_access_levels }
end
def humanize
- HUMAN_ACCESS_LEVELS[self.access_level]
+ HUMAN_ACCESS_LEVELS[access_level]
end
def type
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index 141c480ea1f..45818942326 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -24,14 +24,14 @@ module ResolvableDiscussion
)
delegate :potentially_resolvable?,
- :noteable_id,
- :noteable_type,
- to: :first_note
-
- delegate :resolved_at,
- :resolved_by,
- to: :last_resolved_note,
- allow_nil: true
+ :noteable_id,
+ :noteable_type,
+ to: :first_note
+
+ delegate :resolved_at,
+ :resolved_by,
+ to: :last_resolved_note,
+ allow_nil: true
end
def resolved_by_push?
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
index 1e8a290c050..a5b69997900 100644
--- a/app/models/concerns/vulnerability_finding_helpers.rb
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -47,8 +47,9 @@ module VulnerabilityFindingHelpers
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)
+ finding_data = report_finding.to_hash.except(
+ :compare_key, :identifiers, :location, :scanner, :links, :signatures, :flags, :evidence
+ )
identifiers = report_finding.identifiers.uniq(&:fingerprint).map do |identifier|
Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project }))
end
diff --git a/app/models/concerns/web_hooks/auto_disabling.rb b/app/models/concerns/web_hooks/auto_disabling.rb
index 05aaca32f35..2ad2e47ec4e 100644
--- a/app/models/concerns/web_hooks/auto_disabling.rb
+++ b/app/models/concerns/web_hooks/auto_disabling.rb
@@ -39,8 +39,11 @@ module WebHooks
scope :disabled, -> do
return none unless auto_disabling_enabled?
- where('recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)',
- FAILURE_THRESHOLD, Time.current)
+ where(
+ 'recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)',
+ FAILURE_THRESHOLD,
+ Time.current
+ )
end
# A hook is executable if:
@@ -52,8 +55,12 @@ module WebHooks
scope :executable, -> do
return all unless auto_disabling_enabled?
- where('recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))',
- FAILURE_THRESHOLD, FAILURE_THRESHOLD, Time.current)
+ where(
+ 'recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))',
+ FAILURE_THRESHOLD,
+ FAILURE_THRESHOLD,
+ Time.current
+ )
end
end
diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb
index d90f32d8b1c..caaf2b33ef0 100644
--- a/app/models/concerns/with_uploads.rb
+++ b/app/models/concerns/with_uploads.rb
@@ -25,6 +25,13 @@ module WithUploads
FILE_UPLOADERS = %w(PersonalFileUploader NamespaceFileUploader FileUploader).freeze
included do
+ around_destroy :ignore_uploads_table_in_transaction
+
+ def ignore_uploads_table_in_transaction(&blk)
+ Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
+ %w[uploads], url: "https://gitlab.com/gitlab-org/gitlab/-/issues/398199", &blk)
+ end
+
has_many :uploads, as: :model
has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) },
class_name: 'Upload', as: :model,
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index b3cbe498551..62b6effeb89 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -22,6 +22,12 @@ class ContainerRepository < ApplicationRecord
MAX_TAGS_PAGES = 2000
+ # The Registry client uses JWT token to authenticate to Registry. We cache the client using expiration
+ # time of JWT token. However it's possible that the token is valid but by the time the request is made to
+ # Regsitry, it's already expired. To prevent this case, we are subtracting a few seconds, defined by this constant
+ # from the cache expiration time.
+ AUTH_TOKEN_USAGE_RESERVED_TIME_IN_SECS = 5
+
TooManyImportsError = Class.new(StandardError)
belongs_to :project
@@ -289,6 +295,10 @@ class ContainerRepository < ApplicationRecord
all
end
+ def self.registry_client_expiration_time
+ (Gitlab::CurrentSettings.container_registry_token_expire_delay * 60) - AUTH_TOKEN_USAGE_RESERVED_TIME_IN_SECS
+ end
+
class << self
alias_method :pending_destruction, :delete_scheduled # needed by Packages::Destructible
end
@@ -410,7 +420,7 @@ class ContainerRepository < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def registry
- @registry ||= begin
+ strong_memoize_with_expiration(:registry, self.class.registry_client_expiration_time) do
token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
url = Gitlab.config.registry.api_url
diff --git a/app/models/design_management/git_repository.rb b/app/models/design_management/git_repository.rb
new file mode 100644
index 00000000000..92db82f7bd1
--- /dev/null
+++ b/app/models/design_management/git_repository.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class GitRepository < ::Repository
+ extend ::Gitlab::Utils::Override
+
+ # We define static git attributes for the design repository as this
+ # repository is entirely GitLab-managed rather than user-facing.
+ #
+ # Enable all uploaded files to be stored in LFS.
+ MANAGED_GIT_ATTRIBUTES = <<~GA.freeze
+ /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
+ GA
+
+ # Passing the `project` explicitly saves on one query on the `project` table
+ # in Mutations::DesignManagement::Delete
+
+ def initialize(project)
+ @project = project
+
+ full_path = @project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
+ disk_path = @project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
+
+ # Ideally a DesignManagement::Repository, not a project would be
+ # the container to this Git repository.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/394816.
+
+ super(
+ full_path,
+ @project,
+ shard: @project.repository_storage,
+ disk_path: disk_path,
+ repo_type: Gitlab::GlRepository::DESIGN
+ )
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def info_attributes
+ @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes(path)
+ info_attributes.attributes(path)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def gitattribute(path, name)
+ attributes(path)[name]
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes_at(_ref = nil)
+ info_attributes
+ end
+
+ override :copy_gitattributes
+ def copy_gitattributes(_ref = nil)
+ true
+ end
+ end
+end
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
index 2b1e6070e6b..e6790bc3253 100644
--- a/app/models/design_management/repository.rb
+++ b/app/models/design_management/repository.rb
@@ -1,51 +1,24 @@
# frozen_string_literal: true
module DesignManagement
- class Repository < ::Repository
- extend ::Gitlab::Utils::Override
+ class Repository < ApplicationRecord
+ include ::Gitlab::Utils::StrongMemoize
- # We define static git attributes for the design repository as this
- # repository is entirely GitLab-managed rather than user-facing.
- #
- # Enable all uploaded files to be stored in LFS.
- MANAGED_GIT_ATTRIBUTES = <<~GA
- /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
- GA
+ belongs_to :project, inverse_of: :design_management_repository
+ validates :project, presence: true, uniqueness: true
- def initialize(project)
- full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
- disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
+ # This is so that git_repo is initialized once `project` has been
+ # set. If it is not set after intialization and saving the record
+ # fails for some reason, the first call to `git_repo`` (initiated by
+ # `delegate_missing_to`) will throw an error because project would
+ # be missing.
+ after_initialize :git_repo
- super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN)
- end
-
- # Override of a method called on Repository instances but sent via
- # method_missing to Gitlab::Git::Repository where it is defined
- def info_attributes
- @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES)
- end
-
- # Override of a method called on Repository instances but sent via
- # method_missing to Gitlab::Git::Repository where it is defined
- def attributes(path)
- info_attributes.attributes(path)
- end
-
- # Override of a method called on Repository instances but sent via
- # method_missing to Gitlab::Git::Repository where it is defined
- def gitattribute(path, name)
- attributes(path)[name]
- end
-
- # Override of a method called on Repository instances but sent via
- # method_missing to Gitlab::Git::Repository where it is defined
- def attributes_at(_ref = nil)
- info_attributes
- end
+ delegate_missing_to :git_repo
- override :copy_gitattributes
- def copy_gitattributes(_ref = nil)
- true
+ def git_repo
+ GitRepository.new(project)
end
+ strong_memoize_attr :git_repo
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 333841b1f90..76a34bf7810 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -9,6 +9,9 @@ class Event < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include UsageStatistics
include ShaAttribute
+ include IgnorableColumns
+
+ ignore_column :target_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
ACTIONS = HashWithIndifferentAccess.new(
created: 1,
diff --git a/app/models/group.rb b/app/models/group.rb
index 01e2c220dbe..f13ce2ddca1 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -200,14 +200,27 @@ class Group < Namespace
.where(project_authorizations: { user_id: user_ids })
end
+ scope :with_project_creation_levels, -> (project_creation_levels) do
+ where(project_creation_level: project_creation_levels)
+ end
+
scope :project_creation_allowed, -> do
- permitted_levels = [
+ project_creation_allowed_on_levels = [
::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS,
::Gitlab::Access::MAINTAINER_PROJECT_ACCESS,
nil
]
- where(project_creation_level: permitted_levels)
+ # When the value of application_settings.default_project_creation is set to `NO_ONE_PROJECT_ACCESS`,
+ # it means that a `nil` value for `groups.project_creation_level` is telling us:
+ # do not allow project creation in such groups.
+ # ie, `nil` is a placeholder value for inheriting the value from the ApplicationSetting.
+ # So we remove `nil` from the list when the application_setting's value is `NO_ONE_PROJECT_ACCESS`
+ if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS
+ project_creation_allowed_on_levels.delete(nil)
+ end
+
+ with_project_creation_levels(project_creation_allowed_on_levels)
end
scope :shared_into_ancestors, -> (group) do
@@ -551,7 +564,7 @@ class Group < Namespace
# rubocop: enable CodeReuse/ServiceClass
def users_ids_of_direct_members
- direct_members.pluck(:user_id)
+ direct_members.pluck_user_ids
end
def user_ids_for_project_authorizations
@@ -894,6 +907,10 @@ class Group < Namespace
].compact.min
end
+ def content_editor_on_issues_feature_flag_enabled?
+ feature_flag_enabled_for_self_or_ancestor?(:content_editor_on_issues)
+ end
+
def work_items_feature_flag_enabled?
feature_flag_enabled_for_self_or_ancestor?(:work_items)
end
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index 15949570f9c..fdb8fb9ed75 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -19,6 +19,14 @@ class GroupGroupLink < ApplicationRecord
where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
end
+ scope :with_developer_maintainer_owner_access, -> do
+ where(group_access: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER])
+ end
+
+ scope :with_developer_access, -> do
+ where(group_access: [Gitlab::Access::DEVELOPER])
+ end
+
scope :with_owner_access, -> do
where(group_access: [Gitlab::Access::OWNER])
end
diff --git a/app/models/group_label.rb b/app/models/group_label.rb
index 0d2eb524929..46e56166951 100644
--- a/app/models/group_label.rb
+++ b/app/models/group_label.rb
@@ -11,4 +11,8 @@ class GroupLabel < Label
def subject_foreign_key
'group_id'
end
+
+ def preloaded_parent_container
+ association(:group).loaded? ? group : parent_container
+ end
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 25ccdc2b4f1..5ccbc926a71 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -133,7 +133,7 @@ class WebHook < ApplicationRecord
def reset_url_variables
interpolated_url_was = interpolated_url(decrypt_url_was, url_variables_were)
- return if url_variables_were.empty? || interpolated_url_was == interpolated_url
+ return if url_variables_were.blank? || interpolated_url_was == interpolated_url
self.url_variables = {} if url_changed? && url_variables_were.to_a.intersection(url_variables.to_a).any?
end
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index 34da4c0f4b8..9efc85cbdb1 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -24,14 +24,12 @@ module Integrations
field :app_store_key_id,
section: SECTION_TYPE_CONNECTION,
required: true,
- title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') },
- is_secret: false
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') }
field :app_store_private_key_file_name,
- section: SECTION_TYPE_CONNECTION,
- is_secret: false
+ section: SECTION_TYPE_CONNECTION
- field :app_store_private_key, api_only: true, is_secret: false
+ field :app_store_private_key, api_only: true
def title
'Apple App Store Connect'
@@ -53,7 +51,7 @@ module Integrations
s_("Use the Apple App Store Connect integration to easily connect to the Apple App Store with Fastlane in CI/CD pipelines."),
s_("After the Apple App Store Connect integration is activated, the following protected variables will be created for CI/CD use."),
variable_list.join('<br>'),
- s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: "https://docs.gitlab.com/ee/integration/apple_app_store.html")).html_safe
+ s_(format("To get started, see the <a href='%{url}' target='_blank'>integration documentation</a> for instructions on how to generate App Store Connect credentials, and how to use this integration.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/apple_app_store'))).html_safe
]
# rubocop:enable Layout/LineLength
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index fc5e6a88c2d..4638ca0c5f1 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -17,7 +17,8 @@ module Integrations
non_empty_password_title: -> { s_('BambooService|Enter new build key') },
non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') },
placeholder: -> { _('KEY') },
- required: true
+ required: true,
+ is_secret: true
field :username,
help: -> { s_('BambooService|The user with API access to the Bamboo server.') }
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index e0994305e9d..7a54d354007 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -14,7 +14,7 @@ module Integrations
# This pattern does not support cross-project references
# The other code assumes that this pattern is a superset of all
# overridden patterns. See ReferenceRegexes.external_pattern
- def self.reference_pattern(only_long: false)
+ def self.base_reference_pattern(only_long: false)
if only_long
/(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/
else
@@ -22,6 +22,10 @@ module Integrations
end
end
+ def reference_pattern(only_long: false)
+ self.class.base_reference_pattern(only_long: only_long)
+ end
+
def handle_properties
# this has been moved from initialize_properties and should be improved
# as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb
index 1b86ef73c85..003c896704a 100644
--- a/app/models/integrations/ewm.rb
+++ b/app/models/integrations/ewm.rb
@@ -6,7 +6,7 @@ module Integrations
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- def self.reference_pattern(only_long: true)
+ def reference_pattern(only_long: true)
@reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i
end
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
index 329c046075f..9f2274216f6 100644
--- a/app/models/integrations/field.rb
+++ b/app/models/integrations/field.rb
@@ -2,8 +2,6 @@
module Integrations
class Field
- SECRET_NAME = %r/token|key|password|passphrase|secret/.freeze
-
BOOLEAN_ATTRIBUTES = %i[required api_only is_secret exposes_secrets].freeze
ATTRIBUTES = %i[
@@ -17,11 +15,11 @@ module Integrations
attr_reader :name, :integration_class
- def initialize(name:, integration_class:, type: 'text', is_secret: true, api_only: false, **attributes)
+ def initialize(name:, integration_class:, type: 'text', is_secret: false, api_only: false, **attributes)
@name = name.to_s.freeze
@integration_class = integration_class
- attributes[:type] = SECRET_NAME.match?(@name) && is_secret ? 'password' : type
+ attributes[:type] = is_secret ? 'password' : type
attributes[:api_only] = api_only
attributes[:is_secret] = is_secret
@attributes = attributes.freeze
diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb
index 8f1d2e7e1ec..9fa6dc19f11 100644
--- a/app/models/integrations/google_play.rb
+++ b/app/models/integrations/google_play.rb
@@ -2,6 +2,8 @@
module Integrations
class GooglePlay < Integration
+ PACKAGE_NAME_REGEX = /\A[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*){1,20}\z/
+
SECTION_TYPE_GOOGLE_PLAY = 'google_play'
with_options if: :activated? do
@@ -9,14 +11,19 @@ module Integrations
filename: "google_service_account_key", parse_json: true
}
validates :service_account_key_file_name, presence: true
+ validates :package_name, presence: true, format: { with: PACKAGE_NAME_REGEX }
end
+ field :package_name,
+ section: SECTION_TYPE_CONNECTION,
+ placeholder: 'com.example.myapp',
+ required: true
+
field :service_account_key_file_name,
section: SECTION_TYPE_CONNECTION,
- required: true,
- is_secret: false
+ required: true
- field :service_account_key, api_only: true, is_secret: false
+ field :service_account_key, api_only: true
def title
s_('GooglePlay|Google Play')
@@ -28,6 +35,7 @@ module Integrations
def help
variable_list = [
+ '<code>SUPPLY_PACKAGE_NAME</code>',
'<code>SUPPLY_JSON_KEY_DATA</code>'
]
@@ -36,7 +44,7 @@ module Integrations
s_("Use the Google Play integration to connect to Google Play with fastlane in CI/CD pipelines."),
s_("After you enable the integration, the following protected variable is created for CI/CD use:"),
variable_list.join('<br>'),
- s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: "#")).html_safe
+ s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe
]
# rubocop:enable Layout/LineLength
@@ -62,9 +70,9 @@ module Integrations
end
def test(*_args)
- client.fetch_access_token!
+ client.list_reviews(package_name)
{ success: true }
- rescue Signet::AuthorizationError => error
+ rescue Google::Apis::ClientError => error
{ success: false, message: error }
end
@@ -72,17 +80,22 @@ module Integrations
return [] unless activated?
[
- { key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false }
+ { key: 'SUPPLY_JSON_KEY_DATA', value: service_account_key, masked: true, public: false },
+ { key: 'SUPPLY_PACKAGE_NAME', value: package_name, masked: false, public: false }
]
end
private
def client
- Google::Auth::ServiceAccountCredentials.make_creds(
+ service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new # rubocop: disable CodeReuse/ServiceClass
+
+ service.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
json_key_io: StringIO.new(service_account_key),
- scope: ['https://www.googleapis.com/auth/androidpublisher']
+ scope: [Google::Apis::AndroidpublisherV3::AUTH_ANDROIDPUBLISHER]
)
+
+ service
end
end
end
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
index 01a04743d5d..079811e0df0 100644
--- a/app/models/integrations/harbor.rb
+++ b/app/models/integrations/harbor.rb
@@ -17,7 +17,8 @@ module Integrations
field :project_name,
title: -> { s_('HarborIntegration|Harbor project name') },
- help: -> { s_('HarborIntegration|The name of the project in Harbor.') }
+ help: -> { s_('HarborIntegration|The name of the project in Harbor.') },
+ required: true
field :username,
title: -> { s_('HarborIntegration|Harbor username') },
@@ -62,7 +63,7 @@ module Integrations
end
def test(*_args)
- client.ping
+ client.check_project_availability
end
def ci_variables
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index a1cdd55ceae..0f7e9b96370 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -23,6 +23,8 @@ module Integrations
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
validates :password, presence: true, if: :activated?
+ validates :jira_issue_prefix, untrusted_regexp: true, length: { maximum: 255 }, if: :activated?
+ validates :jira_issue_regex, untrusted_regexp: true, length: { maximum: 255 }, if: :activated?
validates :jira_issue_transition_id,
format: {
@@ -70,7 +72,20 @@ module Integrations
title: -> { s_('JiraService|Password or API token') },
non_empty_password_title: -> { s_('JiraService|Enter new password or API token') },
non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') },
- help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') }
+ help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') },
+ is_secret: true
+
+ field :jira_issue_regex,
+ section: SECTION_TYPE_CONFIGURATION,
+ required: false,
+ title: -> { s_('JiraService|Jira issue regex') },
+ help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') }
+
+ field :jira_issue_prefix,
+ section: SECTION_TYPE_CONFIGURATION,
+ required: false,
+ title: -> { s_('JiraService|Jira issue prefix') },
+ help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') }
field :jira_issue_transition_id, api_only: true
@@ -90,8 +105,8 @@ module Integrations
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
- def self.reference_pattern(only_long: true)
- @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
+ def reference_pattern(only_long: true)
+ @reference_pattern ||= jira_issue_match_regex
end
def self.valid_jira_cloud_url?(url)
@@ -166,6 +181,11 @@ module Integrations
type: SECTION_TYPE_JIRA_TRIGGER,
title: _('Trigger'),
description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.')
+ },
+ {
+ type: SECTION_TYPE_CONFIGURATION,
+ title: _('Jira issue matching'),
+ description: s_('Configure custom rules for Jira issue key matching')
}
]
@@ -325,6 +345,12 @@ module Integrations
private
+ def jira_issue_match_regex
+ match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex)
+
+ /\b#{jira_issue_prefix}(?<issue>#{match_regex})/
+ end
+
def parse_project_from_issue_key(issue_key)
issue_key.gsub(Gitlab::Regex.jira_issue_key_project_key_extraction_regex, '')
end
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index f5079b9b907..e075400d9b5 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -15,11 +15,11 @@ module Integrations
end
def title
- 'Mattermost slash commands'
+ s_('Integrations|Mattermost slash commands')
end
def description
- "Perform common tasks with slash commands."
+ s_('Integrations|Perform common tasks with slash commands.')
end
def self.to_param
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index fa719f925ed..15246a37aa7 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -7,12 +7,11 @@ module Integrations
validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
# {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030
- def self.reference_pattern(only_long: false)
- if only_long
- /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/
- else
- /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/
- end
+ def reference_pattern(only_long: false)
+ return @reference_pattern if defined?(@reference_pattern)
+
+ regex_suffix = "|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})"
+ @reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/
end
def title
diff --git a/app/models/issue.rb b/app/models/issue.rb
index a19b5809ff8..8ace5dfff57 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -39,6 +39,8 @@ class Issue < ApplicationRecord
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
+ IssueTypeOutOfSyncError = Class.new(StandardError)
+
SORTING_PREFERENCE_FIELD = :issues_sort
MAX_BRANCH_TEMPLATE = 255
@@ -52,16 +54,18 @@ class Issue < ApplicationRecord
# Types of issues that should be displayed on issue board lists
TYPES_FOR_BOARD_LIST = %w(issue incident).freeze
+ # This default came from the enum `issue_type` column. Defined as default in the DB
+ DEFAULT_ISSUE_TYPE = :issue
+
belongs_to :project
belongs_to :namespace, inverse_of: :issues
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
- belongs_to :iteration, foreign_key: 'sprint_id'
belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :work_items
- belongs_to :moved_to, class_name: 'Issue'
- has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
+ belongs_to :moved_to, class_name: 'Issue', inverse_of: :moved_from
+ has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id, inverse_of: :moved_to
has_internal_id :iid, scope: :namespace, track_if: -> { !importing? }, init: ->(issue, scope) do
# we need this init for the case where the IID allocation in internal_ids#last_value
@@ -114,6 +118,7 @@ class Issue < ApplicationRecord
has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue
has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues
has_many :incident_management_timeline_events, class_name: 'IncidentManagement::TimelineEvent', foreign_key: :issue_id, inverse_of: :incident
+ has_many :assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', inverse_of: :issue
alias_attribute :escalation_status, :incident_management_issuable_escalation_status
@@ -231,6 +236,7 @@ class Issue < ApplicationRecord
scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') }
before_validation :ensure_namespace_id, :ensure_work_item_type
+ before_save :check_issue_type_in_sync!
after_save :ensure_metrics!, unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
@@ -594,6 +600,10 @@ class Issue < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def update_project_counter_caches
+ # TODO: Fix counter cache for issues in group
+ # TODO: see https://gitlab.com/gitlab-org/gitlab/-/work_items/393125?iid_path=true
+ return unless project
+
Projects::OpenIssuesCountService.new(project).refresh_cache
end
# rubocop: enable CodeReuse/ServiceClass
@@ -688,6 +698,10 @@ class Issue < ApplicationRecord
end
def expire_etag_cache
+ # TODO: Fix this for the case when issues is created at group level
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/395814?iid_path=true
+ return unless project
+
key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Gitlab::EtagCaching::Store.new.touch(key)
end
@@ -702,8 +716,45 @@ class Issue < ApplicationRecord
::Gitlab::GlobalId.as_global_id(id, model_name: WorkItem.name)
end
+ def resource_parent
+ project || namespace
+ end
+
+ # Persisted records will always have a work_item_type. This method is useful
+ # in places where we use a non persisted issue to perform feature checks
+ def work_item_type_with_default
+ work_item_type || WorkItems::Type.default_by_type(DEFAULT_ISSUE_TYPE)
+ end
+
private
+ def check_issue_type_in_sync!
+ # We might have existing records out of sync, so we need to skip this check unless the value is changed
+ # so those records can still be updated until we fix them and remove the issue_type column
+ # https://gitlab.com/gitlab-org/gitlab/-/work_items/403158?iid_path=true
+ return unless (changes.keys & %w[issue_type work_item_type_id]).any?
+
+ if issue_type != work_item_type.base_type
+ error = IssueTypeOutOfSyncError.new(
+ <<~ERROR
+ Issue `issue_type` out of sync with `work_item_type_id` column.
+ `issue_type` must be equal to `work_item.base_type`.
+ You can assign the correct work_item_type like this for example:
+
+ Issue.new(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident))
+
+ More details in https://gitlab.com/gitlab-org/gitlab/-/issues/338005
+ ERROR
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
+ error,
+ issue_type: issue_type,
+ work_item_type_id: work_item_type_id
+ )
+ end
+ end
+
def due_date_after_start_date
return unless start_date.present? && due_date.present?
@@ -729,6 +780,10 @@ class Issue < ApplicationRecord
override :persist_pg_full_text_search_vector
def persist_pg_full_text_search_vector(search_vector)
+ # TODO: Fix search vector for issues at group level
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/work_items/393126?iid_path=true
+ return unless project
+
Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
end
@@ -745,12 +800,14 @@ class Issue < ApplicationRecord
end
def record_create_action
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author, project: project)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(
+ author: author, namespace: namespace.reset
+ )
end
# Returns `true` if this Issue is visible to everybody.
def publicly_visible?
- project.public? && project.feature_available?(:issues, nil) &&
+ resource_parent.public? && resource_parent.feature_available?(:issues, nil) &&
!confidential? && !hidden? && !::Gitlab::ExternalAuthorization.enabled?
end
@@ -766,6 +823,8 @@ class Issue < ApplicationRecord
def ensure_work_item_type
return if work_item_type_id.present? || work_item_type_id_change&.last.present?
+ # TODO: We should switch to DEFAULT_ISSUE_TYPE here when the issue_type column is dropped
+ # https://gitlab.com/gitlab-org/gitlab/-/work_items/402700?iid_path=true
self.work_item_type = WorkItems::Type.default_by_type(issue_type)
end
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
deleted file mode 100644
index ebec24731ed..00000000000
--- a/app/models/iteration.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-# Placeholder class for model that is implemented in EE
-class Iteration < ApplicationRecord
- include IgnorableColumns
-
- self.table_name = 'sprints'
-
- def self.reference_prefix
- '*iteration:'
- end
-
- def self.reference_pattern
- nil
- end
-end
-
-Iteration.prepend_mod_with('Iteration')
diff --git a/app/models/member.rb b/app/models/member.rb
index 4329b61fc3d..529666a069c 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Member < ApplicationRecord
+ extend ::Gitlab::Utils::Override
include EachBatch
include AfterCommitQueue
include Sortable
@@ -359,6 +360,10 @@ class Member < ApplicationRecord
def valid_email?(email)
Devise.email_regexp.match?(email)
end
+
+ def pluck_user_ids
+ pluck(:user_id)
+ end
end
def real_source_type
@@ -572,7 +577,7 @@ class Member < ApplicationRecord
end
def after_decline_invite
- # override in subclass
+ notification_service.decline_invite(self)
end
def after_accept_request
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index f23d7208b6e..aabc902fe03 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class GroupMember < Member
- extend ::Gitlab::Utils::Override
include FromUnion
include CreatedAtFilterable
@@ -38,10 +37,6 @@ class GroupMember < Member
Gitlab::Access.options_with_owner
end
- def self.pluck_user_ids
- pluck(:user_id)
- end
-
def group
source
end
@@ -112,12 +107,6 @@ class GroupMember < Member
super
end
- def after_decline_invite
- notification_service.decline_group_invite(self)
-
- super
- end
-
def send_welcome_email?
true
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 733b7c4bc87..e0fecf702de 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProjectMember < Member
- extend ::Gitlab::Utils::Override
SOURCE_TYPE = 'Project'
SOURCE_TYPE_FORMAT = /\AProject\z/.freeze
@@ -21,40 +20,6 @@ class ProjectMember < Member
end
class << self
- # 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_members_to_projects(
- # project_ids,
- # user_ids,
- # ProjectMember::MAINTAINER
- # )
- #
- # add_members_to_projects(
- # project_ids,
- # user_ids,
- # :maintainer
- # )
- #
- 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_members( # rubocop:disable CodeReuse/ServiceClass
- project,
- users,
- access_level,
- current_user: current_user,
- expires_at: expires_at
- )
- end
- end
- end
-
def truncate_teams(project_ids)
ProjectMember.transaction do
members = ProjectMember.where(source_id: project_ids)
@@ -180,12 +145,6 @@ class ProjectMember < Member
super
end
- def after_decline_invite
- notification_service.decline_project_invite(self)
-
- super
- end
-
# rubocop: disable CodeReuse/ServiceClass
def event_service
EventCreateService.new
diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb
index f6617fa0888..1fef155e6ea 100644
--- a/app/models/members_preloader.rb
+++ b/app/models/members_preloader.rb
@@ -8,15 +8,12 @@ class MembersPreloader
end
def preload_all
- user_associations = [:status]
- user_associations << :webauthn_registrations if Feature.enabled?(:webauthn)
-
ActiveRecord::Associations::Preloader.new(
records: members,
associations: [
:source,
:created_by,
- { user: user_associations }
+ { user: [:status, :webauthn_registrations] }
]
).call
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 85e95a556a8..1e7ff6e8f0e 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -44,7 +44,6 @@ class MergeRequest < ApplicationRecord
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
- belongs_to :iteration, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? },
init: ->(mr, scope) do
@@ -123,6 +122,7 @@ class MergeRequest < ApplicationRecord
has_many :reviews, inverse_of: :merge_request
has_many :reviewed_by_users, -> { distinct }, through: :reviews, source: :author
has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request
+ has_many :assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', inverse_of: :merge_request
KNOWN_MERGE_PARAMS = [
:auto_merge_strategy,
@@ -254,7 +254,7 @@ class MergeRequest < ApplicationRecord
Gitlab::Timeless.timeless(merge_request, &block)
end
- after_transition any => [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking, :can_be_merged, :cannot_be_merged] do |merge_request, transition|
+ after_transition any => [:unchecked, :cannot_be_merged_recheck, :can_be_merged, :cannot_be_merged] do |merge_request, transition|
next if merge_request.skip_merge_status_trigger
merge_request.run_after_commit do
diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb
index 19171e682b7..14808158fd0 100644
--- a/app/models/milestone_note.rb
+++ b/app/models/milestone_note.rb
@@ -17,6 +17,7 @@ class MilestoneNote < SyntheticNote
def note_text(html: false)
format = milestone&.group_milestone? ? :name : :iid
- event.remove? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
+ reference = milestone&.to_reference(project, format: format)
+ event.remove? ? "removed milestone #{reference}" : "changed milestone to #{reference}"
end
end
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
index f973b00c568..40758defafc 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -3,25 +3,34 @@
module Ml
class Candidate < ApplicationRecord
include Sortable
+ include AtomicInternalId
+ include IgnorableColumns
- PACKAGE_PREFIX = 'ml_candidate_'
+ ignore_column :iid, remove_with: '16.0', remove_after: '2023-05-01'
enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 }
- validates :iid, :experiment, presence: true
+ validates :eid, :experiment, presence: true
validates :status, inclusion: { in: statuses.keys }
belongs_to :experiment, class_name: 'Ml::Experiment'
belongs_to :user
+ belongs_to :package, class_name: 'Packages::Package'
+ belongs_to :project
has_many :metrics, class_name: 'Ml::CandidateMetric'
has_many :params, class_name: 'Ml::CandidateParam'
has_many :metadata, class_name: 'Ml::CandidateMetadata'
has_many :latest_metrics, -> { latest }, class_name: 'Ml::CandidateMetric', inverse_of: :candidate
- attribute :iid, default: -> { SecureRandom.uuid }
+ attribute :eid, default: -> { SecureRandom.uuid }
- scope :including_relationships, -> { includes(:latest_metrics, :params, :user) }
+ has_internal_id :internal_id,
+ scope: :project,
+ init: AtomicInternalId.project_init(self, :internal_id)
+
+ scope :including_relationships, -> { includes(:latest_metrics, :params, :user, :package, :project) }
scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection
+
scope :order_by_metric, ->(metric, direction) do
subquery = Ml::CandidateMetric.latest.where(name: metric)
column_expression = Arel::Table.new('latest')[:value]
@@ -46,40 +55,30 @@ module Ml
)
end
- delegate :project_id, :project, to: :experiment
+ alias_attribute :artifact, :package
+ alias_attribute :iid, :internal_id
+
+ delegate :package_name, to: :experiment
def artifact_root
"/#{package_name}/#{package_version}/"
end
- def artifact
- artifact_lazy&.itself
- end
-
- def artifact_lazy
- BatchLoader.for(id).batch do |candidate_ids, loader|
- Packages::Package
- .joins("INNER JOIN ml_candidates ON packages_packages.name=(concat('#{PACKAGE_PREFIX}', ml_candidates.id))")
- .where(ml_candidates: { id: candidate_ids })
- .find_each do |package|
- loader.call(package.name.delete_prefix(PACKAGE_PREFIX).to_i, package)
- end
- end
- end
-
- def package_name
- "#{PACKAGE_PREFIX}#{id}"
- end
-
def package_version
- '-'
+ iid
end
class << self
+ def with_project_id_and_eid(project_id, eid)
+ return unless project_id.present? && eid.present?
+
+ find_by(project_id: project_id, eid: eid)
+ end
+
def with_project_id_and_iid(project_id, iid)
return unless project_id.present? && iid.present?
- joins(:experiment).find_by(experiment: { project_id: project_id }, iid: iid)
+ find_by(project_id: project_id, internal_id: iid)
end
end
end
diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb
index 7bb80a170c5..d1277efac7b 100644
--- a/app/models/ml/experiment.rb
+++ b/app/models/ml/experiment.rb
@@ -4,6 +4,8 @@ module Ml
class Experiment < ApplicationRecord
include AtomicInternalId
+ PACKAGE_PREFIX = 'ml_experiment_'
+
validates :name, :project, presence: true
validates :name, uniqueness: { scope: :project, message: "should be unique in the project" }
@@ -20,6 +22,10 @@ module Ml
has_internal_id :iid, scope: :project
+ def package_name
+ "#{PACKAGE_PREFIX}#{iid}"
+ end
+
class << self
def by_project_id_and_iid(project_id, iid)
find_by(project_id: project_id, iid: iid)
@@ -32,6 +38,20 @@ module Ml
def by_project_id(project_id)
where(project_id: project_id).order(id: :desc)
end
+
+ def package_for_experiment?(package_name)
+ return false unless package_name&.starts_with?(PACKAGE_PREFIX)
+
+ iid = package_name.delete_prefix(PACKAGE_PREFIX)
+
+ numeric?(iid)
+ end
+
+ private
+
+ def numeric?(value)
+ value.match?(/\A\d+\z/)
+ end
end
end
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index aeb4d7a5694..3ac585a6957 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -13,6 +13,7 @@ class NamespaceSetting < ApplicationRecord
enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2 }, _suffix: true
validates :enabled_git_access_protocol, inclusion: { in: enabled_git_access_protocols.keys }
+ validates :code_suggestions, allow_nil: false, inclusion: { in: [true, false] }
validate :allow_mfa_for_group
validate :allow_resource_access_token_creation_for_group
diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb
index 2a2ea11ddc5..cf2612b7f33 100644
--- a/app/models/namespaces/project_namespace.rb
+++ b/app/models/namespaces/project_namespace.rb
@@ -11,6 +11,8 @@ module Namespaces
alias_attribute :namespace_id, :parent_id
has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace
+ delegate :execute_hooks, :execute_integrations, to: :project, allow_nil: true
+
def self.sti_name
'Project'
end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 0fae66b18ca..9006f104c64 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -117,15 +117,7 @@ module Namespaces
traversal_ids.present?
end
- def use_traversal_ids_for_root_ancestor?
- return false unless Feature.enabled?(:use_traversal_ids_for_root_ancestor)
-
- traversal_ids.present?
- end
-
def root_ancestor
- return super unless use_traversal_ids_for_root_ancestor?
-
strong_memoize(:root_ancestor) do
if association(:parent).loaded? && parent.present?
# This case is possible when parent has not been persisted or we're inside a transaction.
@@ -133,7 +125,7 @@ module Namespaces
elsif parent_id.nil?
# There is no parent, so we are the root ancestor.
self
- elsif traversal_ids.present?
+ else
Namespace.find_by(id: traversal_ids.first)
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index b9b884b88c5..13fff9520b7 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -173,6 +173,14 @@ class Note < ApplicationRecord
end
scope :with_metadata, -> { includes(:system_note_metadata) }
+ scope :without_hidden, -> {
+ if Feature.enabled?(:hidden_notes)
+ where_not_exists(Users::BannedUser.where('notes.author_id = banned_users.user_id'))
+ else
+ all
+ end
+ }
+
scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb
index 0966a9f2912..afbd671f82e 100644
--- a/app/models/onboarding/completion.rb
+++ b/app/models/onboarding/completion.rb
@@ -13,7 +13,10 @@ module Onboarding
:issue_created,
:git_write,
:merge_request_created,
- :user_added
+ :user_added,
+ :license_scanning_run,
+ :secure_dependency_scanning_run,
+ :secure_dast_run
].freeze
def initialize(project, current_user = nil)
@@ -58,26 +61,10 @@ module Onboarding
def action_columns
[:code_added] +
- tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
+ ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
end
strong_memoize_attr :action_columns
- def tracked_actions
- ACTION_PATHS + deploy_section_tracked_actions
- end
-
- def deploy_section_tracked_actions
- experiment(
- :security_actions_continuous_onboarding,
- namespace: namespace,
- user: current_user,
- sticky_to: current_user
- ) do |e|
- e.control { [:security_scan_enabled] }
- e.candidate { [:license_scanning_run, :secure_dependency_scanning_run, :secure_dast_run] }
- end.run
- end
-
attr_reader :project, :namespace, :current_user
end
end
diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb
index 887a5695530..2b8d0a4f51e 100644
--- a/app/models/packages/debian.rb
+++ b/app/models/packages/debian.rb
@@ -12,6 +12,8 @@ module Packages
EMPTY_FILE_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'.freeze
+ INCOMING_PACKAGE_NAME = 'incoming'
+
def self.table_name_prefix
'packages_debian_'
end
diff --git a/app/models/packages/debian/file_metadatum.rb b/app/models/packages/debian/file_metadatum.rb
index 77ce8e265ff..325ae0c468e 100644
--- a/app/models/packages/debian/file_metadatum.rb
+++ b/app/models/packages/debian/file_metadatum.rb
@@ -1,59 +1,69 @@
# frozen_string_literal: true
-class Packages::Debian::FileMetadatum < ApplicationRecord
- self.primary_key = :package_file_id
+module Packages
+ module Debian
+ class FileMetadatum < ApplicationRecord
+ include UpdatedAtFilterable
- belongs_to :package_file, inverse_of: :debian_file_metadatum
+ self.primary_key = :package_file_id
- validates :package_file, presence: true
- validate :valid_debian_package_type
+ belongs_to :package_file, inverse_of: :debian_file_metadatum
- enum file_type: {
- unknown: 1, source: 2, dsc: 3, deb: 4, udeb: 5, buildinfo: 6, changes: 7, ddeb: 8
- }
+ validates :package_file, presence: true
+ validate :valid_debian_package_type
- validates :file_type, presence: true
- validates :file_type, inclusion: { in: %w[unknown] },
- if: -> { package_file&.package&.debian_incoming? || package_file&.package&.processing? }
- validates :file_type,
- inclusion: { in: %w[source dsc deb udeb buildinfo changes ddeb] },
- if: -> { package_file&.package&.debian_package? && !package_file&.package&.processing? }
+ enum file_type: {
+ unknown: 1, source: 2, dsc: 3, deb: 4, udeb: 5, buildinfo: 6, changes: 7, ddeb: 8
+ }
- validates :component,
- presence: true,
- format: { with: Gitlab::Regex.debian_component_regex },
- if: :requires_component?
- validates :component, absence: true, unless: :requires_component?
+ validates :file_type, presence: true
+ validates :file_type, inclusion: { in: %w[unknown] },
+ if: -> { package_file&.package&.debian_incoming? || package_file&.package&.processing? }
+ validates :file_type,
+ inclusion: { in: %w[source dsc deb udeb buildinfo changes ddeb] },
+ if: -> { package_file&.package&.debian_package? && !package_file&.package&.processing? }
- validates :architecture,
- presence: true,
- format: { with: Gitlab::Regex.debian_architecture_regex },
- if: :requires_architecture?
- validates :architecture, absence: true, unless: :requires_architecture?
+ validates :component,
+ presence: true,
+ format: { with: Gitlab::Regex.debian_component_regex },
+ if: :requires_component?
+ validates :component, absence: true, unless: :requires_component?
- validates :fields,
- presence: true,
- json_schema: { filename: "debian_fields" },
- if: :requires_fields?
- validates :fields, absence: true, unless: :requires_fields?
+ validates :architecture,
+ presence: true,
+ format: { with: Gitlab::Regex.debian_architecture_regex },
+ if: :requires_architecture?
+ validates :architecture, absence: true, unless: :requires_architecture?
- private
+ validates :fields,
+ presence: true,
+ json_schema: { filename: "debian_fields" },
+ if: :requires_fields?
+ validates :fields, absence: true, unless: :requires_fields?
- def valid_debian_package_type
- return if package_file&.package&.debian?
+ scope :with_file_type, ->(file_type) do
+ where(file_type: file_type)
+ end
- errors.add(:package_file, _('Package type must be Debian'))
- end
+ private
- def requires_architecture?
- deb? || udeb? || ddeb?
- end
+ def valid_debian_package_type
+ return if package_file&.package&.debian?
- def requires_component?
- source? || dsc? || requires_architecture? || buildinfo?
- end
+ errors.add(:package_file, _('Package type must be Debian'))
+ end
+
+ def requires_architecture?
+ deb? || udeb? || ddeb?
+ end
+
+ def requires_component?
+ source? || dsc? || requires_architecture? || buildinfo?
+ end
- def requires_fields?
- dsc? || requires_architecture? || buildinfo? || changes?
+ def requires_fields?
+ dsc? || requires_architecture? || buildinfo? || changes?
+ end
+ end
end
end
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
index bb2c33594e5..d93c22adcda 100644
--- a/app/models/packages/event.rb
+++ b/app/models/packages/event.rb
@@ -1,61 +1,60 @@
# frozen_string_literal: true
-class Packages::Event < ApplicationRecord
- belongs_to :package, optional: true
-
- UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze
- EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001, dependency_proxy: 1002).freeze
-
- EVENT_PREFIX = "i_package"
-
- enum event_scope: EVENT_SCOPES
-
- enum event_type: {
- push_package: 0,
- delete_package: 1,
- pull_package: 2,
- search_package: 3,
- list_package: 4,
- list_repositories: 5,
- delete_repository: 6,
- delete_tag: 7,
- delete_tag_bulk: 8,
- list_tags: 9,
- cli_metadata: 10,
- pull_symbol_package: 11,
- push_symbol_package: 12,
- pull_manifest: 13,
- pull_manifest_from_cache: 14,
- pull_blob: 15,
- pull_blob_from_cache: 16
- }
-
- enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
-
- # Remove some of the events, for now, so we don't hammer Redis too hard.
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770
- def self.event_allowed?(event_type)
- return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym)
-
- false
- end
-
- # counter names for unique user tracking (for MAU)
- def self.unique_counters_for(event_scope, event_type, originator_type)
- return [] unless event_allowed?(event_type)
- return [] if originator_type.to_s == 'guest'
-
- ["#{EVENT_PREFIX}_#{event_scope}_#{originator_type}"]
- end
-
- # total counter names for tracking number of events
- def self.counters_for(event_scope, event_type, originator_type)
- return [] unless event_allowed?(event_type)
-
- [
- "#{EVENT_PREFIX}_#{event_type}",
- "#{EVENT_PREFIX}_#{event_type}_by_#{originator_type}",
- "#{EVENT_PREFIX}_#{event_scope}_#{event_type}"
- ]
+module Packages
+ class Event
+ UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze
+ EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001, dependency_proxy: 1002).freeze
+
+ EVENT_PREFIX = "i_package"
+
+ EVENT_TYPES = %i[
+ push_package
+ delete_package
+ pull_package
+ search_package
+ list_package
+ list_repositories
+ delete_repository
+ delete_tag
+ delete_tag_bulk
+ list_tags
+ create_tag
+ cli_metadata
+ pull_symbol_package
+ push_symbol_package
+ pull_manifest
+ pull_manifest_from_cache
+ pull_blob
+ pull_blob_from_cache
+ ].freeze
+
+ ORIGINATOR_TYPES = %i[user deploy_token guest].freeze
+
+ # Remove some of the events, for now, so we don't hammer Redis too hard.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770
+ def self.event_allowed?(event_type)
+ return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym)
+
+ false
+ end
+
+ # counter names for unique user tracking (for MAU)
+ def self.unique_counters_for(event_scope, event_type, originator_type)
+ return [] unless event_allowed?(event_type)
+ return [] if originator_type.to_s == 'guest'
+
+ ["#{EVENT_PREFIX}_#{event_scope}_#{originator_type}"]
+ end
+
+ # total counter names for tracking number of events
+ def self.counters_for(event_scope, event_type, originator_type)
+ return [] unless event_allowed?(event_type)
+
+ [
+ "#{EVENT_PREFIX}_#{event_type}",
+ "#{EVENT_PREFIX}_#{event_type}_by_#{originator_type}",
+ "#{EVENT_PREFIX}_#{event_scope}_#{event_type}"
+ ]
+ end
end
end
diff --git a/app/models/packages/npm/metadata_cache.rb b/app/models/packages/npm/metadata_cache.rb
new file mode 100644
index 00000000000..2d116f2e9c0
--- /dev/null
+++ b/app/models/packages/npm/metadata_cache.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class MetadataCache < ApplicationRecord
+ belongs_to :project, inverse_of: :npm_metadata_caches
+
+ validates :file, :package_name, :project, :size, presence: true
+ validates :package_name, uniqueness: { scope: :project_id }
+ validates :package_name, format: { with: Gitlab::Regex.package_name_regex }
+ validates :package_name, format: { with: Gitlab::Regex.npm_package_name_regex }
+ end
+ end
+end
diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb
index 7388c4bdbd2..a856cd7225f 100644
--- a/app/models/packages/npm/metadatum.rb
+++ b/app/models/packages/npm/metadatum.rb
@@ -9,6 +9,8 @@ class Packages::Npm::Metadatum < ApplicationRecord
validate :ensure_npm_package_type
validate :ensure_package_json_size
+ scope :package_id_in, ->(package_ids) { where(package_id: package_ids) }
+
private
def ensure_npm_package_type
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 970538b45e7..a8eb990b914 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -31,6 +31,8 @@ class Packages::Package < ApplicationRecord
belongs_to :project
belongs_to :creator, class_name: 'User'
+ after_create_commit :publish_creation_event, if: :generic?
+
# package_files must be destroyed by ruby code in order to properly remove carrierwave uploads and update project statistics
has_many :package_files, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# TODO: put the installable default scope on the :package_files association once the dependent: :destroy is removed
@@ -70,9 +72,8 @@ class Packages::Package < ApplicationRecord
scope: %i[project_id version package_type],
conditions: -> { not_pending_destruction }
},
- unless: -> { pending_destruction? || conan? || debian_package? }
+ unless: -> { pending_destruction? || conan? }
- validate :unique_debian_package_name, if: :debian_package?
validate :valid_conan_package_recipe, if: :conan?
validate :valid_composer_global_name, if: :composer?
validate :npm_package_already_taken, if: :npm?
@@ -84,7 +85,7 @@ class Packages::Package < ApplicationRecord
validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module?
validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package?
- validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming?
+ validates :name, inclusion: { in: [Packages::Debian::INCOMING_PACKAGE_NAME] }, if: :debian_incoming?
validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget?
validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
@@ -222,6 +223,12 @@ class Packages::Package < ApplicationRecord
find_by!(name: name, version: version)
end
+ def self.existing_debian_packages_with(name:, version:)
+ debian.with_name(name)
+ .with_version(version)
+ .not_pending_destruction
+ end
+
def self.pluck_names
pluck(:name)
end
@@ -353,6 +360,18 @@ class Packages::Package < ApplicationRecord
end
end
+ def publish_creation_event
+ ::Gitlab::EventStore.publish(
+ ::Packages::PackageCreatedEvent.new(data: {
+ project_id: project_id,
+ id: id,
+ name: name,
+ version: version,
+ package_type: package_type
+ })
+ )
+ end
+
private
def composer_tag_version?
@@ -404,19 +423,6 @@ class Packages::Package < ApplicationRecord
project.root_namespace.path == ::Packages::Npm.scope_of(name)
end
- def unique_debian_package_name
- return unless debian_publication&.distribution
-
- package_exists = debian_publication.distribution.packages
- .with_name(name)
- .with_version(version)
- .not_pending_destruction
- .id_not_in(id)
- .exists?
-
- errors.add(:base, _('Debian package already exists in Distribution')) if package_exists
- end
-
def forbidden_debian_changes
return unless persisted?
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index e1486c11298..c164d150bce 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -85,6 +85,13 @@ class Packages::PackageFile < ApplicationRecord
.where(packages_debian_file_metadata: { architecture: architecture_name })
end
+ scope :with_debian_unknown_since, ->(updated_before) do
+ file_metadata = Packages::Debian::FileMetadatum.with_file_type(:unknown)
+ .updated_before(updated_before)
+ .where('packages_package_files.id = packages_debian_file_metadata.package_file_id')
+ where('EXISTS (?)', file_metadata.select(1))
+ end
+
scope :with_conan_package_reference, ->(conan_package_reference) do
joins(:conan_file_metadatum)
.where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference })
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 222cde19da7..864ea04c019 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -54,12 +54,19 @@ module Pages
end
strong_memoize_attr :prefix
- def unique_domain
+ def unique_host
return unless project.project_setting.pages_unique_domain_enabled?
- project.project_setting.pages_unique_domain
+ project.pages_unique_host
end
- strong_memoize_attr :unique_domain
+ strong_memoize_attr :unique_host
+
+ def root_directory
+ return unless deployment
+
+ deployment.root_directory
+ end
+ strong_memoize_attr :root_directory
private
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index da6ef035c54..fa29cbf8352 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -4,6 +4,7 @@
class PagesDeployment < ApplicationRecord
include EachBatch
include FileStoreMounter
+ include Gitlab::Utils::StrongMemoize
MIGRATED_FILE_NAME = "_migrated.zip"
@@ -28,15 +29,29 @@ class PagesDeployment < ApplicationRecord
mount_file_store_uploader ::Pages::DeploymentUploader
+ skip_callback :save, :after, :store_file!, if: :store_after_commit?
+ after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit?
+
def migrated?
file.filename == MIGRATED_FILE_NAME
end
+ def store_after_commit?
+ Feature.enabled?(:pages_deploy_upload_file_outside_transaction, project)
+ end
+ strong_memoize_attr :store_after_commit?
+
private
def set_size
self.size = file.size
end
+
+ def store_file_after_commit!
+ return unless previous_changes.key?(:file)
+
+ store_file_now!
+ end
end
PagesDeployment.prepend_mod
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 2e613768873..3ebb2126f4d 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -41,8 +41,8 @@ class PersonalAccessToken < ApplicationRecord
scope :for_users, -> (users) { where(user: users) }
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) }
- scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) }
- scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) }
+ scope :project_access_token, -> { includes(:user).references(:user).merge(User.project_bot) }
+ scope :owner_is_human, -> { includes(:user).references(:user).merge(User.human) }
scope :last_used_before, -> (date) { where("last_used_at <= ?", date) }
scope :last_used_after, -> (date) { where("last_used_at >= ?", date) }
diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb
index 2a3175be420..7ee0ec0ca43 100644
--- a/app/models/preloaders/labels_preloader.rb
+++ b/app/models/preloaders/labels_preloader.rb
@@ -20,25 +20,31 @@ module Preloaders
def preload_all
ActiveRecord::Associations::Preloader.new(
- records: labels,
- associations: { parent_container: :route }
- ).call
-
- ActiveRecord::Associations::Preloader.new(
- records: labels.select { |l| l.is_a? ProjectLabel },
+ records: project_labels,
associations: { project: [:project_feature, namespace: :route] }
).call
ActiveRecord::Associations::Preloader.new(
- records: labels.select { |l| l.is_a? GroupLabel },
+ records: group_labels,
associations: { group: :route }
).call
+ Preloaders::UserMaxAccessLevelInProjectsPreloader.new(project_labels.map(&:project), user).execute
labels.each do |label|
label.lazy_subscription(user)
label.lazy_subscription(user, project) if project.present?
end
end
+
+ private
+
+ def group_labels
+ @group_labels ||= labels.select { |l| l.is_a? GroupLabel }
+ end
+
+ def project_labels
+ @project_labels ||= labels.select { |l| l.is_a? ProjectLabel }
+ end
end
end
diff --git a/app/models/preloaders/runner_machine_policy_preloader.rb b/app/models/preloaders/runner_machine_policy_preloader.rb
deleted file mode 100644
index 52864eeba8d..00000000000
--- a/app/models/preloaders/runner_machine_policy_preloader.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Preloaders
- class RunnerMachinePolicyPreloader
- def initialize(runner_machines, current_user)
- @runner_machines = runner_machines
- @current_user = current_user
- end
-
- def execute
- return if runner_machines.is_a?(ActiveRecord::NullRelation)
-
- ActiveRecord::Associations::Preloader.new(
- records: runner_machines,
- associations: [:runner]
- ).call
- end
-
- private
-
- attr_reader :runner_machines, :current_user
- end
-end
diff --git a/app/models/preloaders/runner_manager_policy_preloader.rb b/app/models/preloaders/runner_manager_policy_preloader.rb
new file mode 100644
index 00000000000..788a3d25a87
--- /dev/null
+++ b/app/models/preloaders/runner_manager_policy_preloader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class RunnerManagerPolicyPreloader
+ def initialize(runner_managers, current_user)
+ @runner_managers = runner_managers
+ @current_user = current_user
+ end
+
+ def execute
+ return if runner_managers.is_a?(ActiveRecord::NullRelation)
+
+ ActiveRecord::Associations::Preloader.new(
+ records: runner_managers,
+ associations: [:runner]
+ ).call
+ end
+
+ private
+
+ attr_reader :runner_managers, :current_user
+ end
+end
diff --git a/app/models/preloaders/users_max_access_level_by_project_preloader.rb b/app/models/preloaders/users_max_access_level_by_project_preloader.rb
new file mode 100644
index 00000000000..37842665e7d
--- /dev/null
+++ b/app/models/preloaders/users_max_access_level_by_project_preloader.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the max access level (role) for the users within the given projects and
+ # stores the values in requests store via the ProjectTeam class.
+ class UsersMaxAccessLevelByProjectPreloader
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(project_users:)
+ @project_users = project_users.transform_values { |users| Array.wrap(users) }
+ end
+
+ def execute
+ return unless @project_users.present?
+
+ all_users = @project_users.values.flatten.uniq
+ preload_users_namespace_bans(all_users)
+
+ @project_users.each do |project, users|
+ users.each do |user|
+ access_level = access_levels.fetch([project.id, user.id], Gitlab::Access::NO_ACCESS)
+ project.team.write_member_access_for_user_id(user.id, access_level)
+ end
+ end
+ end
+
+ private
+
+ def access_levels
+ query = ProjectAuthorization.none
+
+ @project_users.each do |project, users|
+ query = query.or(
+ ProjectAuthorization
+ .where(project_id: project.id, user_id: users.map(&:id))
+ )
+ end
+
+ query
+ .group(:project_id, :user_id)
+ .maximum(:access_level)
+ end
+ strong_memoize_attr :access_levels
+
+ def preload_users_namespace_bans(_users)
+ # overridden in EE
+ end
+ end
+end
+
+Preloaders::UsersMaxAccessLevelByProjectPreloader.prepend_mod
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
deleted file mode 100644
index f32184f168d..00000000000
--- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-module Preloaders
- # This class preloads the max access level (role) for the users within the given projects and
- # stores the values in requests store via the ProjectTeam class.
- class UsersMaxAccessLevelInProjectsPreloader
- def initialize(projects:, users:)
- @projects = projects
- @users = users
- end
-
- def execute
- return unless @projects.present? && @users.present?
-
- preload_users_namespace_bans(@users)
-
- access_levels.each do |(project_id, user_id), access_level|
- project = projects_by_id[project_id]
-
- project.team.write_member_access_for_user_id(user_id, access_level)
- end
- end
-
- private
-
- def access_levels
- ProjectAuthorization
- .where(project_id: project_ids, user_id: user_ids)
- .group(:project_id, :user_id)
- .maximum(:access_level)
- end
-
- # Use reselect to override the existing select to prevent
- # the error `subquery has too many columns`
- # NotificationsController passes in an Array so we need to check the type
- def project_ids
- @projects.is_a?(ActiveRecord::Relation) ? @projects.reselect(:id) : @projects
- end
-
- def user_ids
- @users.is_a?(ActiveRecord::Relation) ? @users.reselect(:id) : @users
- end
-
- def projects_by_id
- @projects_by_id ||= @projects.index_by(&:id)
- end
-
- 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 cb218c0a49f..146747eb57a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -42,6 +42,7 @@ class Project < ApplicationRecord
include BlocksUnsafeSerialization
include Subquery
include IssueParent
+ include UpdatedAtFilterable
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -168,6 +169,7 @@ class Project < ApplicationRecord
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
+ has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project
has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event'
has_many :boards
@@ -222,6 +224,7 @@ class Project < ApplicationRecord
has_one :zentao_integration, class_name: 'Integrations::Zentao'
has_one :wiki_repository, class_name: 'Projects::WikiRepository', inverse_of: :project
+ has_one :design_management_repository, class_name: 'DesignManagement::Repository', inverse_of: :project
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
inverse_of: :root_project,
@@ -258,6 +261,8 @@ class Project < ApplicationRecord
has_many :debian_distributions,
class_name: 'Packages::Debian::ProjectDistribution',
dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :npm_metadata_caches,
+ class_name: 'Packages::Npm::MetadataCache'
has_one :packages_cleanup_policy,
class_name: 'Packages::Cleanup::Policy',
inverse_of: :project
@@ -275,6 +280,7 @@ class Project < ApplicationRecord
has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
has_one :service_desk_setting, class_name: 'ServiceDeskSetting'
has_one :service_desk_custom_email_verification, class_name: 'ServiceDesk::CustomEmailVerification'
+ has_one :service_desk_custom_email_credential, class_name: 'ServiceDesk::CustomEmailCredential'
# Merge requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -491,6 +497,7 @@ class Project < ApplicationRecord
to: :project_setting, allow_nil: true
delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?,
+ :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=,
to: :project_setting
delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
@@ -499,7 +506,7 @@ class Project < ApplicationRecord
delegate :previous_default_branch, :previous_default_branch=, to: :project_setting
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
- delegate :add_member, :add_members, to: :team
+ delegate :add_member, :add_members, :member?, 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, to: :namespace, allow_nil: true
@@ -1579,7 +1586,7 @@ class Project < ApplicationRecord
end
def new_issuable_address(author, address_type)
- return unless Gitlab::IncomingEmail.supports_issue_creation? && author
+ return unless Gitlab::Email::IncomingEmail.supports_issue_creation? && author
# check since this can come from a request parameter
return unless %w(issue merge_request).include?(address_type)
@@ -1590,7 +1597,7 @@ class Project < ApplicationRecord
# example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com
# example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com
- Gitlab::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}")
+ Gitlab::Email::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}")
end
def build_commit_note(commit)
@@ -1624,7 +1631,7 @@ class Project < ApplicationRecord
end
def external_issue_reference_pattern
- external_issue_tracker.class.reference_pattern(only_long: issues_enabled?)
+ external_issue_tracker.reference_pattern(only_long: issues_enabled?)
end
def default_issues_tracker?
@@ -1664,9 +1671,7 @@ class Project < ApplicationRecord
end
def disabled_integrations
- disabled_integrations = []
- disabled_integrations << 'google_play' unless Feature.enabled?(:google_play_integration, self)
- disabled_integrations
+ []
end
def find_or_initialize_integration(name)
@@ -2060,7 +2065,7 @@ class Project < ApplicationRecord
end
def group_runners
- @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none
+ @group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_groups_of_project(self.id) : Ci::Runner.none
end
def all_runners
@@ -2160,6 +2165,10 @@ class Project < ApplicationRecord
pages_url_for(project_setting.pages_unique_domain)
end
+ def pages_unique_host
+ URI(pages_unique_url).host
+ end
+
def pages_namespace_url
pages_url_for(pages_subdomain)
end
@@ -2237,7 +2246,7 @@ class Project < ApplicationRecord
wiki.repository.expire_content_cache
DetectRepositoryLanguagesWorker.perform_async(id)
- ProjectCacheWorker.perform_async(self.id, [], [:repository_size])
+ ProjectCacheWorker.perform_async(self.id, [], [:repository_size, :wiki_size])
AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(id)
enqueue_record_project_target_platforms
@@ -2399,6 +2408,8 @@ class Project < ApplicationRecord
.append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host)
.append(key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s)
.append(key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol)
+ .append(key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s)
+ .append(key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s)
.append(key: 'CI_SERVER_NAME', value: 'GitLab')
.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s)
@@ -2419,6 +2430,7 @@ class Project < ApplicationRecord
def api_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url)
+ variables.append(key: 'CI_API_GRAPHQL_URL', value: Gitlab::Routing.url_helpers.api_graphql_url)
end
end
@@ -2832,13 +2844,17 @@ class Project < ApplicationRecord
end
def all_protected_branches
- if Feature.enabled?(:group_protected_branches, group)
+ if allow_protected_branches_for_group?
@all_protected_branches ||= ProtectedBranch.from_union([protected_branches, group_protected_branches])
else
protected_branches
end
end
+ def allow_protected_branches_for_group?
+ Feature.enabled?(:group_protected_branches, group) || Feature.enabled?(:allow_protected_branches_for_group, group)
+ end
+
def self_monitoring?
Gitlab::CurrentSettings.self_monitoring_project_id == id
end
@@ -2891,11 +2907,11 @@ class Project < ApplicationRecord
end
def service_desk_custom_address
- return unless Gitlab::ServiceDeskEmail.enabled?
+ return unless Gitlab::Email::ServiceDeskEmail.enabled?
key = service_desk_setting&.project_key || default_service_desk_suffix
- Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}")
+ Gitlab::Email::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}")
end
def default_service_desk_suffix
@@ -3083,6 +3099,10 @@ class Project < ApplicationRecord
pending_delete? || hidden?
end
+ def content_editor_on_issues_feature_flag_enabled?
+ group&.content_editor_on_issues_feature_flag_enabled? || Feature.enabled?(:content_editor_on_issues, self)
+ end
+
def work_items_feature_flag_enabled?
group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self)
end
@@ -3142,10 +3162,16 @@ class Project < ApplicationRecord
false
end
+ def crm_enabled?
+ return false unless group
+
+ group.crm_enabled?
+ end
+
private
def pages_unique_domain_enabled?
- Feature.enabled?(:pages_unique_domain) &&
+ Feature.enabled?(:pages_unique_domain, self) &&
project_setting.pages_unique_domain_enabled?
end
diff --git a/app/models/project_label.rb b/app/models/project_label.rb
index dc647901b46..05d7b7429ff 100644
--- a/app/models/project_label.rb
+++ b/app/models/project_label.rb
@@ -23,6 +23,10 @@ class ProjectLabel < Label
super(project, target_project: target_project, format: format, full: full)
end
+ def preloaded_parent_container
+ association(:project).loaded? ? project : parent_container
+ end
+
private
def title_must_not_exist_at_group_level
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 379b94b3af5..6a60015cc26 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -65,6 +65,10 @@ class ProjectSetting < ApplicationRecord
end
strong_memoize_attr :show_diff_preview_in_email?
+ def runner_registration_enabled
+ Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && read_attribute(:runner_registration_enabled)
+ end
+
private
def validates_mr_default_target_self
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 5641fbfb867..dd200aec807 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -121,7 +121,7 @@ class ProjectTeam
target_project = project
source_members = source_project.project_members.to_a
- target_user_ids = target_project.project_members.pluck(:user_id)
+ target_user_ids = target_project.project_members.pluck_user_ids
source_members.reject! do |member|
# Skip if user already present in team
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index ffffa803011..e64892dfa03 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -12,6 +12,13 @@ class ProjectWiki < Wiki
container.disk_path + '.wiki'
end
+ override :create_wiki_repository
+ def create_wiki_repository
+ super
+
+ track_wiki_repository
+ end
+
override :after_wiki_activity
def after_wiki_activity
# Update activity columns, this is done synchronously to avoid
@@ -28,6 +35,16 @@ class ProjectWiki < Wiki
# the activity columns for Git pushes as well.
after_wiki_activity
end
+
+ private
+
+ def track_wiki_repository
+ return unless ::Gitlab::Database.read_write?
+ return if container.wiki_repository
+
+ # This is the ActiveRecord auto-generated method for a Project's has_one :wiki_repository
+ container.create_wiki_repository!
+ end
end
# TODO: Remove this once we implement ES support for group wikis.
diff --git a/app/models/projects/data_transfer.rb b/app/models/projects/data_transfer.rb
index faab0bb6db2..c7f5132fbc7 100644
--- a/app/models/projects/data_transfer.rb
+++ b/app/models/projects/data_transfer.rb
@@ -13,6 +13,14 @@ module Projects
belongs_to :namespace
scope :current_month, -> { where(date: beginning_of_month) }
+ scope :with_project_between_dates, ->(project, from, to) {
+ where(project: project, date: from..to)
+ }
+ scope :with_namespace_between_dates, ->(namespace, from, to) {
+ where(namespace: namespace, date: from..to)
+ .group(:date, :namespace_id)
+ .order(date: :desc)
+ }
counter_attribute :repository_egress, returns_current: true
counter_attribute :artifacts_egress, returns_current: true
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 22eaac94897..01bdbba1955 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -8,6 +8,8 @@ class ProtectedBranch < ApplicationRecord
belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches
validate :validate_either_project_or_top_group
+ validates :name, presence: true
+ validates :name, uniqueness: { scope: [:project_id, :namespace_id] }, if: :name_changed?
scope :requiring_code_owner_approval, -> { where(code_owner_approval_required: true) }
scope :allowing_force_push, -> { where(allow_force_push: true) }
@@ -43,7 +45,7 @@ class ProtectedBranch < ApplicationRecord
end
def self.allow_force_push?(project, ref_name)
- if Feature.enabled?(:group_protected_branches, project.group)
+ if allow_protected_branches_for_group?(project.group)
protected_branches = project.all_protected_branches.matching(ref_name)
project_protected_branches, group_protected_branches = protected_branches.partition(&:project_id)
@@ -58,6 +60,10 @@ class ProtectedBranch < ApplicationRecord
end
end
+ def self.allow_protected_branches_for_group?(group)
+ Feature.enabled?(:group_protected_branches, group) || Feature.enabled?(:allow_protected_branches_for_group, group)
+ end
+
def self.any_protected?(project, ref_names)
protected_refs(project).any? do |protected_ref|
ref_names.any? do |ref_name|
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 587b71315c2..b4a0eaf0324 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -199,7 +199,7 @@ class Repository
def list_commits_by(query, ref, author: nil, before: nil, after: nil, limit: 1000)
return [] unless exists?
return [] unless has_visible_content?
- return [] unless query.present? && ref.present?
+ return [] unless ref.present?
commits = raw_repository.list_commits_by(
query, ref, author: author, before: before, after: after, limit: limit).map do |c|
diff --git a/app/models/resource_events/issue_assignment_event.rb b/app/models/resource_events/issue_assignment_event.rb
new file mode 100644
index 00000000000..b24f181bc48
--- /dev/null
+++ b/app/models/resource_events/issue_assignment_event.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ class IssueAssignmentEvent < ApplicationRecord
+ self.table_name = :issue_assignment_events
+
+ belongs_to :user, optional: true
+ belongs_to :issue
+
+ validates :issue, presence: true
+
+ enum action: { add: 1, remove: 2 }
+ end
+end
diff --git a/app/models/resource_events/merge_request_assignment_event.rb b/app/models/resource_events/merge_request_assignment_event.rb
new file mode 100644
index 00000000000..898594b7008
--- /dev/null
+++ b/app/models/resource_events/merge_request_assignment_event.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ class MergeRequestAssignmentEvent < ApplicationRecord
+ self.table_name = :merge_request_assignment_events
+
+ belongs_to :user, optional: true
+ belongs_to :merge_request
+
+ validates :merge_request, presence: true
+
+ enum action: { add: 1, remove: 2 }
+ end
+end
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index f3301ee2051..61129bbc9d8 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -4,6 +4,9 @@ class ResourceMilestoneEvent < ResourceTimeboxEvent
belongs_to :milestone
scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
+ scope :aliased_for_timebox_report, -> do
+ select("'timebox' AS event_type", "id", "created_at", "milestone_id AS value", "action", "issue_id")
+ end
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states)
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 134f71e35ad..e2ac762b1cd 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -13,6 +13,10 @@ class ResourceStateEvent < ResourceEvent
after_create :issue_usage_metrics
+ scope :aliased_for_timebox_report, -> do
+ select("'state' AS event_type", "id", "created_at", "state AS value", "NULL AS action", "issue_id")
+ end
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 1a0a65df6a3..8a3449e8f7c 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -16,8 +16,6 @@ class SentNotification < ApplicationRecord
validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true }
validate :note_valid
- ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
-
after_save :keep_around_commit, if: :for_commit?
class << self
diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb
new file mode 100644
index 00000000000..8ccdd6f2261
--- /dev/null
+++ b/app/models/service_desk/custom_email_credential.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ class CustomEmailCredential < ApplicationRecord
+ attr_encrypted :smtp_username,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: false,
+ encode_iv: false
+ attr_encrypted :smtp_password,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: false,
+ encode_iv: false
+
+ belongs_to :project
+
+ validates :project, presence: true
+
+ validates :smtp_address,
+ presence: true,
+ length: { maximum: 255 },
+ hostname: { allow_numeric_hostname: true }
+ validate :validate_smtp_address
+
+ validates :smtp_port,
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0 }
+
+ validates :smtp_username,
+ presence: true,
+ length: { maximum: 255 }
+ validates :smtp_password,
+ presence: true,
+ length: { minimum: 8, maximum: 128 }
+
+ delegate :service_desk_setting, to: :project
+
+ def delivery_options
+ {
+ user_name: smtp_username,
+ password: smtp_password,
+ address: smtp_address,
+ domain: Mail::Address.new(service_desk_setting.custom_email).domain,
+ port: smtp_port || 587
+ }
+ end
+
+ private
+
+ def validate_smtp_address
+ # Addressable::URI always needs a scheme otherwise it interprets the host as the path
+ Gitlab::UrlBlocker.validate!("smtp://#{smtp_address}",
+ schemes: %w[smtp],
+ ascii_only: true,
+ enforce_sanitization: true,
+ allow_localhost: false,
+ allow_local_network: false
+ )
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ errors.add(:smtp_address, e)
+ end
+ end
+end
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index 69afb445734..4216ad7e70f 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -2,16 +2,19 @@
class ServiceDeskSetting < ApplicationRecord
include Gitlab::Utils::StrongMemoize
+ include IgnorableColumns
CUSTOM_EMAIL_VERIFICATION_SUBADDRESS = '+verify'
+ ignore_columns %i[
+ custom_email_smtp_address
+ custom_email_smtp_port
+ custom_email_smtp_username
+ encrypted_custom_email_smtp_password
+ encrypted_custom_email_smtp_password_iv
+ ], remove_with: '16.1', remove_after: '2023-05-22'
+
attribute :custom_email_enabled, default: false
- attr_encrypted :custom_email_smtp_password,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32,
- encode: false,
- encode_iv: false
belongs_to :project
@@ -20,48 +23,32 @@ class ServiceDeskSetting < ApplicationRecord
validate :valid_project_key
validates :outgoing_name, length: { maximum: 255 }, allow_blank: true
validates :project_key,
- length: { maximum: 255 },
- allow_blank: true,
- format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } }
+ length: { maximum: 255 },
+ allow_blank: true,
+ format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } }
validates :custom_email,
- length: { maximum: 255 },
- uniqueness: true,
- allow_nil: true,
- format: /\A[\w\-._]+@[\w\-.]+\.{1}[a-zA-Z]{2,}\z/
- validates :custom_email_smtp_address, length: { maximum: 255 }
- validates :custom_email_smtp_username, length: { maximum: 255 }
-
+ length: { maximum: 255 },
+ uniqueness: true,
+ allow_nil: true,
+ format: /\A[\w\-._]+@[\w\-.]+\.{1}[a-zA-Z]{2,}\z/
+
+ validates :custom_email_credential,
+ presence: true,
+ if: :needs_custom_email_credentials?
validates :custom_email,
- presence: true,
- devise_email: true,
- if: :needs_custom_email_smtp_credentials?
- validates :custom_email_smtp_address,
- presence: true,
- hostname: { allow_numeric_hostname: true, require_valid_tld: true },
- if: :needs_custom_email_smtp_credentials?
- validates :custom_email_smtp_username,
- presence: true,
- if: :needs_custom_email_smtp_credentials?
- validates :custom_email_smtp_port,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 },
- if: :needs_custom_email_smtp_credentials?
+ presence: true,
+ devise_email: true,
+ if: :needs_custom_email_credentials?
scope :with_project_key, ->(key) { where(project_key: key) }
- def custom_email_verification
- project&.service_desk_custom_email_verification
+ def custom_email_credential
+ project&.service_desk_custom_email_credential
end
- def custom_email_delivery_options
- {
- user_name: custom_email_smtp_username,
- password: custom_email_smtp_password,
- address: custom_email_smtp_address,
- domain: Mail::Address.new(custom_email).domain,
- port: custom_email_smtp_port || 587
- }
+ def custom_email_verification
+ project&.service_desk_custom_email_verification
end
def custom_email_address_for_verification
@@ -116,7 +103,7 @@ class ServiceDeskSetting < ApplicationRecord
end
end
- def needs_custom_email_smtp_credentials?
+ def needs_custom_email_credentials?
custom_email_enabled? || custom_email_verification.present?
end
end
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 8a207c891e2..93c128c989c 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -8,6 +8,8 @@ module Terraform
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
+ self.locking_column = :activerecord_lock_version
+
belongs_to :project
belongs_to :locked_by_user, class_name: 'User'
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index d6a16ad5b99..6727c81f17b 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -5,7 +5,7 @@ module Terraform
include EachBatch
include FileStoreMounter
- belongs_to :terraform_state, class_name: 'Terraform::State', optional: false
+ belongs_to :terraform_state, class_name: 'Terraform::State', optional: false, touch: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :build, class_name: 'Ci::Build', optional: true, foreign_key: :ci_build_id
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 62252912c32..ac41b5d0b2c 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -76,7 +76,7 @@ class Todo < ApplicationRecord
scope :for_target, -> (id) { where(target_id: id) }
scope :for_commit, -> (id) { where(commit_id: id) }
scope :with_entity_associations, -> do
- preload(:target, :author, :note, group: :route, project: [:route, { namespace: [:route, :owner] }, :project_setting])
+ preload(:target, :author, :note, group: :route, project: [:route, :group, { namespace: [:route, :owner] }, :project_setting])
end
scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) }
scope :for_internal_notes, -> { joins(:note).where(note: { confidential: true }) }
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index ba6c1ee6af1..81415eb383b 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -5,9 +5,6 @@
class U2fRegistration < ApplicationRecord
belongs_to :user
- after_create :create_webauthn_registration
- after_update :update_webauthn_registration, if: :saved_change_to_counter?
-
def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id)
registration = self.new
@@ -43,25 +40,4 @@ class U2fRegistration < ApplicationRecord
rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error
false
end
-
- private
-
- def create_webauthn_registration
- converter = Gitlab::Auth::U2fWebauthnConverter.new(self)
- WebauthnRegistration.create!(converter.convert)
- rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, u2f_registration_id: self.id)
- end
-
- def update_webauthn_registration
- # When we update the sign count of this registration
- # we need to update the sign count of the corresponding webauthn registration
- # as well if it exists already
- WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)
- &.update_attribute(:counter, counter)
- end
-
- def webauthn_credential_xid
- Base64.strict_encode64(Base64.urlsafe_decode64(key_handle))
- end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 3bd8a035357..96223ac5027 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -9,7 +9,6 @@ class User < ApplicationRecord
include Gitlab::SQL::Pattern
include AfterCommitQueue
include Avatarable
- include Awareness
include Referable
include Sortable
include CaseSensitivity
@@ -220,6 +219,7 @@ class User < ApplicationRecord
has_many :abuse_reports, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :abuse_trust_scores, class_name: 'Abuse::TrustScore', foreign_key: :user_id
has_many :builds, class_name: 'Ci::Build'
has_many :pipelines, class_name: 'Ci::Pipeline'
has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -266,6 +266,8 @@ class User < ApplicationRecord
has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
+ has_many :issue_assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
+ has_many :merge_request_assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail'
has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user
@@ -355,6 +357,7 @@ class User < ApplicationRecord
:time_format_in_24h, :time_format_in_24h=,
:show_whitespace_in_diffs, :show_whitespace_in_diffs=,
:view_diffs_file_by_file, :view_diffs_file_by_file=,
+ :pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=,
:tab_width, :tab_width=,
:sourcegraph_enabled, :sourcegraph_enabled=,
:gitpod_enabled, :gitpod_enabled=,
@@ -366,6 +369,8 @@ class User < ApplicationRecord
:diffs_addition_color, :diffs_addition_color=,
:use_legacy_web_ide, :use_legacy_web_ide=,
:use_new_navigation, :use_new_navigation=,
+ :pinned_nav_items, :pinned_nav_items=,
+ :achievements_enabled, :achievements_enabled=,
to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true
@@ -923,6 +928,17 @@ class User < ApplicationRecord
end
end
+ def llm_bot
+ email_pattern = "llm-bot%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(user_type: :llm_bot), 'GitLab-Llm-Bot', email_pattern) do |u|
+ u.bio = 'The Gitlab LLM bot used for fetching LLM-generated content'
+ u.name = 'GitLab LLM Bot'
+ u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for llm-bot
+ u.confirmed_at = Time.zone.now
+ end
+ end
+
def admin_bot
email_pattern = "admin-bot%s@#{Settings.gitlab.host}"
@@ -1074,8 +1090,6 @@ class User < ApplicationRecord
end
def two_factor_webauthn_enabled?
- return false unless Feature.enabled?(:webauthn)
-
(webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?)
end
@@ -1703,10 +1717,11 @@ class User < ApplicationRecord
def forkable_namespaces
strong_memoize(:forkable_namespaces) do
personal_namespace = Namespace.where(id: namespace_id)
+ groups_allowing_project_creation = Groups::AcceptingProjectCreationsFinder.new(self).execute
Namespace.from_union(
[
- manageable_groups(include_groups_with_developer_maintainer_access: true),
+ groups_allowing_project_creation,
personal_namespace
])
end
@@ -1972,7 +1987,7 @@ class User < ApplicationRecord
end
def enabled_incoming_email_token
- incoming_email_token if Gitlab::IncomingEmail.supports_issue_creation?
+ incoming_email_token if Gitlab::Email::IncomingEmail.supports_issue_creation?
end
def sync_attribute?(attribute)
@@ -2198,6 +2213,10 @@ class User < ApplicationRecord
namespace_commit_emails.find_by(namespace: project.root_namespace)
end
+ def trust_scores_for_source(source)
+ abuse_trust_scores.where(source: source)
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index bc2c6b526b8..2519db825c0 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -24,6 +24,10 @@ class UserPreference < ApplicationRecord
allow_blank: true
validates :use_legacy_web_ide, allow_nil: false, inclusion: { in: [true, false] }
+ validates :pass_user_identities_to_ci_jwt, allow_nil: false, inclusion: { in: [true, false] }
+
+ validates :pinned_nav_items, json_schema: { filename: 'pinned_nav_items' }
+
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT }
diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb
index c73b3a4ee71..e7229abd147 100644
--- a/app/models/users/project_callout.rb
+++ b/app/models/users/project_callout.rb
@@ -15,7 +15,8 @@ module Users
storage_enforcement_banner_first_enforcement_threshold: 4, # EE-only
storage_enforcement_banner_second_enforcement_threshold: 5, # EE-only
storage_enforcement_banner_third_enforcement_threshold: 6, # EE-only
- storage_enforcement_banner_fourth_enforcement_threshold: 7 # EE-only
+ storage_enforcement_banner_fourth_enforcement_threshold: 7, # EE-only
+ license_check_deprecation_alert: 8 # EE-only
}
validates :project, presence: true
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index 8bb598ee316..650e8942132 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -7,6 +7,8 @@ class Vulnerability < ApplicationRecord
alias_attribute :vulnerability_id, :id
+ scope :with_projects, -> { includes(:project) }
+
def self.link_reference_pattern
nil
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index a7cd522f023..10476339ca9 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -67,6 +67,16 @@ class WorkItem < Issue
end
end
+ # Returns widget object if available
+ # type parameter can be a symbol, for example, `:description`.
+ def get_widget(type)
+ widgets.find do |widget|
+ widget.instance_of?(WorkItems::Widgets.const_get(type.to_s.camelize, false))
+ end
+ rescue NameError
+ nil
+ end
+
def ancestors
hierarchy.ancestors(hierarchy_order: :asc)
end
@@ -130,6 +140,75 @@ class WorkItem < Issue
::Gitlab::WorkItems::WorkItemHierarchy.new(base, options: options)
end
+
+ override :allowed_work_item_type_change
+ def allowed_work_item_type_change
+ return unless work_item_type_id_changed?
+
+ child_links = WorkItems::ParentLink.for_parents(id)
+ parent_link = ::WorkItems::ParentLink.find_by(work_item: self)
+
+ validate_parent_restrictions(parent_link)
+ validate_child_restrictions(child_links)
+ validate_depth(parent_link, child_links)
+ end
+
+ def validate_parent_restrictions(parent_link)
+ return unless parent_link
+
+ parent_link.work_item.work_item_type_id = work_item_type_id
+
+ unless parent_link.valid?
+ errors.add(
+ :work_item_type_id,
+ format(
+ _('cannot be changed to %{new_type} with %{parent_type} as parent type.'),
+ new_type: work_item_type.name, parent_type: parent_link.work_item_parent.work_item_type.name
+ )
+ )
+ end
+ end
+
+ def validate_child_restrictions(child_links)
+ return if child_links.empty?
+
+ child_type_ids = child_links.joins(:work_item).select(self.class.arel_table[:work_item_type_id]).distinct
+ restrictions = ::WorkItems::HierarchyRestriction.where(
+ parent_type_id: work_item_type_id,
+ child_type_id: child_type_ids
+ )
+
+ # We expect a restriction for every child type
+ if restrictions.size < child_type_ids.size
+ errors.add(
+ :work_item_type_id,
+ format(_('cannot be changed to %{new_type} with these child item types.'), new_type: work_item_type.name)
+ )
+ end
+ end
+
+ def validate_depth(parent_link, child_links)
+ restriction = ::WorkItems::HierarchyRestriction.find_by_parent_type_id_and_child_type_id(
+ work_item_type_id,
+ work_item_type_id
+ )
+ return unless restriction&.maximum_depth
+
+ children_with_new_type = self.class.where(id: child_links.select(:work_item_id))
+ .where(work_item_type_id: work_item_type_id)
+ max_child_depth = ::Gitlab::WorkItems::WorkItemHierarchy.new(children_with_new_type).max_descendants_depth.to_i
+
+ ancestor_depth =
+ if parent_link&.work_item_parent && parent_link.work_item_parent.work_item_type_id == work_item_type_id
+ parent_link.work_item_parent.same_type_base_and_ancestors.count
+ else
+ 0
+ end
+
+ if max_child_depth + ancestor_depth > restriction.maximum_depth - 1
+ errors.add(:work_item_type_id, _('reached maximum depth'))
+ end
+ end
end
WorkItem.prepend_mod
diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb
index 21e31980fda..5dff9e8e8d5 100644
--- a/app/models/work_items/parent_link.rb
+++ b/app/models/work_items/parent_link.rb
@@ -41,6 +41,10 @@ module WorkItems
def relative_positioning_parent_column
:work_item_parent_id
end
+
+ def for_work_item(work_item)
+ find_or_initialize_by(work_item: work_item)
+ end
end
private
diff --git a/app/models/work_items/resource_link_event.rb b/app/models/work_items/resource_link_event.rb
new file mode 100644
index 00000000000..64d51b2743c
--- /dev/null
+++ b/app/models/work_items/resource_link_event.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class ResourceLinkEvent < ResourceEvent
+ belongs_to :child_work_item, class_name: 'WorkItem'
+
+ validates :child_work_item, presence: true
+
+ enum action: {
+ add: 1,
+ remove: 2
+ }
+ end
+end
diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb
index 9e8c421d740..763b1a79069 100644
--- a/app/models/work_items/widget_definition.rb
+++ b/app/models/work_items/widget_definition.rb
@@ -29,7 +29,9 @@ module WorkItems
status: 11, # EE-only
requirement_legacy: 12, # EE-only
test_reports: 13, # EE-only
- notifications: 14
+ notifications: 14,
+ current_user_todos: 15,
+ award_emoji: 16
}
def self.available_widgets
diff --git a/app/models/work_items/widgets/award_emoji.rb b/app/models/work_items/widgets/award_emoji.rb
new file mode 100644
index 00000000000..3c862d7c267
--- /dev/null
+++ b/app/models/work_items/widgets/award_emoji.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class AwardEmoji < Base
+ delegate :award_emoji, :downvotes, :upvotes, to: :work_item
+ end
+ end
+end
diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb
index 3a5b03bd514..b54b84f1e1b 100644
--- a/app/models/work_items/widgets/base.rb
+++ b/app/models/work_items/widgets/base.rb
@@ -15,6 +15,12 @@ module WorkItems
[]
end
+ def self.callback_class
+ Issuable::Callbacks.const_get(name.demodulize, false)
+ rescue NameError
+ nil
+ end
+
def type
self.class.type
end
diff --git a/app/models/work_items/widgets/current_user_todos.rb b/app/models/work_items/widgets/current_user_todos.rb
new file mode 100644
index 00000000000..61c4fcb453b
--- /dev/null
+++ b/app/models/work_items/widgets/current_user_todos.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class CurrentUserTodos < Base
+ end
+ end
+end
diff --git a/app/policies/achievements/user_achievement_policy.rb b/app/policies/achievements/user_achievement_policy.rb
index b500d0a25c8..05650a05490 100644
--- a/app/policies/achievements/user_achievement_policy.rb
+++ b/app/policies/achievements/user_achievement_policy.rb
@@ -3,5 +3,10 @@
module Achievements
class UserAchievementPolicy < ::BasePolicy
delegate { @subject.achievement.namespace }
+ delegate { @subject.user }
+
+ rule { can?(:read_user_profile) | can?(:admin_achievement) }.enable :read_user_achievement
+
+ rule { ~can?(:read_achievement) }.prevent :read_user_achievement
end
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 1ce866bd910..7c745c5731f 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -39,6 +39,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:automation_bot) { @user&.automation_bot? }
+ desc "User is llm bot"
+ with_options scope: :user, score: 0
+ condition(:llm_bot) { @user&.llm_bot? }
+
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
@@ -63,7 +67,7 @@ class BasePolicy < DeclarativePolicy::Base
end
rule { admin }.policy do
- # Only for actual administrator accounts, behaviour affected by admin mode application setting
+ # Only for actual administrator accounts, behavior affected by admin mode application setting
enable :admin_all_resources
# Policy extended in EE to also enable auditors
enable :read_all_resources
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index ca0b51e1385..fc154e6b465 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -71,6 +71,10 @@ module Ci
can?(:developer_access, @subject.project)
end
+ # Use admin_ci_minutes for detailed quota and usage reporting
+ # this is limited to total usage and total quota for a builds namespace
+ rule { can_read_project_build }.enable :read_ci_minutes_limited_summary
+
rule { can_read_project_build }.enable :read_build_trace
rule { debug_mode & ~project_update_build }.prevent :read_build_trace
diff --git a/app/policies/ci/runner_machine_policy.rb b/app/policies/ci/runner_manager_policy.rb
index 9893d7dee14..43e81e373fc 100644
--- a/app/policies/ci/runner_machine_policy.rb
+++ b/app/policies/ci/runner_manager_policy.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class RunnerMachinePolicy < BasePolicy
+ class RunnerManagerPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:can_read_runner, scope: :subject) do
@@ -12,7 +12,7 @@ module Ci
rule { can_read_runner }.policy do
enable :read_builds
- enable :read_runner_machine
+ enable :read_runner_manager
end
end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index b64e7e16433..09e41e0bfbf 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -121,11 +121,11 @@ class GlobalPolicy < BasePolicy
enable :approve_user
enable :reject_user
enable :read_usage_trends_measurement
- enable :create_instance_runners
+ enable :create_instance_runner
end
rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_instance_runners
+ prevent :create_instance_runner
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb
index 4a848e44fec..08d811d3dfa 100644
--- a/app/policies/group_label_policy.rb
+++ b/app/policies/group_label_policy.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class GroupLabelPolicy < BasePolicy
- delegate { @subject.parent_container }
+ delegate { @subject.preloaded_parent_container }
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index ee1140b8405..1f8e003b09a 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -165,7 +165,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :developer_access
enable :admin_crm_organization
enable :admin_crm_contact
- enable :read_cluster
+ enable :read_cluster # Deprecated as certificate-based cluster integration (`Clusters::Cluster`).
+ enable :read_cluster_agent
enable :read_group_all_available_runners
enable :use_k8s_proxies
end
@@ -190,6 +191,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :destroy_package
enable :admin_package
enable :create_projects
+ enable :import_projects
enable :admin_pipeline
enable :admin_build
enable :add_cluster
@@ -213,7 +215,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group_runners
enable :admin_group_runners
enable :register_group_runners
- enable :create_group_runners
+ enable :create_runner
enable :set_note_created_at
enable :set_emails_disabled
@@ -260,14 +262,20 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
end.enable :change_share_with_group_lock
rule { developer & developer_maintainer_access }.enable :create_projects
- rule { create_projects_disabled }.prevent :create_projects
+ rule { create_projects_disabled }.policy do
+ prevent :create_projects
+ prevent :import_projects
+ end
rule { owner | admin }.policy do
enable :owner_access
enable :read_statistics
end
- rule { maintainer & can?(:create_projects) }.enable :transfer_projects
+ rule { maintainer & can?(:create_projects) }.policy do
+ enable :transfer_projects
+ enable :import_projects
+ end
rule { read_package_registry_deploy_token }.policy do
enable :read_package
@@ -324,7 +332,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { ~admin & ~group_runner_registration_allowed }.policy do
prevent :register_group_runners
- prevent :create_group_runners
+ prevent :create_runner
end
rule { migration_bot }.policy do
@@ -341,7 +349,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
end
rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_group_runners
+ prevent :create_runner
end
# Should be matched with ProjectPolicy#read_internal_note
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 496708a9737..c9b936f9b06 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
class IssuablePolicy < BasePolicy
- delegate { @subject.project }
+ delegate { subject_container }
condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
- condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
+ condition(:is_project_member) { @user && subject_container.member?(@user) }
condition(:can_read_issuable) { can?(:"read_#{@subject.to_ability_name}") }
desc "User is the assignee or author"
@@ -57,6 +57,10 @@ class IssuablePolicy < BasePolicy
enable :read_issuable
enable :read_issuable_participables
end
+
+ def subject_container
+ @subject.project || @subject.try(:namespace)
+ end
end
IssuablePolicy.prepend_mod_with('IssuablePolicy')
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 804709ed072..538959c92bd 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -14,8 +14,8 @@ class IssuePolicy < IssuablePolicy
desc "Project belongs to a group, crm is enabled and user can read contacts in the root group"
condition(:can_read_crm_contacts, scope: :subject) do
- subject.project.group&.crm_enabled? &&
- (@user&.can?(:read_crm_contact, @subject.project.root_ancestor) || @user&.support_bot?)
+ subject_container&.crm_enabled? &&
+ (@user&.can?(:read_crm_contact, subject_container.root_ancestor) || @user&.support_bot?)
end
desc "Issue is confidential"
@@ -43,6 +43,7 @@ class IssuePolicy < IssuablePolicy
rule { confidential & ~can_read_confidential }.policy do
prevent(*create_read_update_admin_destroy(:issue))
+ prevent(*create_read_update_admin_destroy(:work_item))
prevent :read_issue_iid
end
diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb
index bfb1706bc5a..2214839fb62 100644
--- a/app/policies/namespaces/group_project_namespace_shared_policy.rb
+++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb
@@ -17,5 +17,16 @@ module Namespaces
rule { can?(:reporter_access) }.policy do
enable :read_timelog_category
end
+
+ rule { can?(:guest_access) }.policy do
+ enable :create_work_item
+ enable :read_work_item
+ enable :read_issue
+ enable :read_namespace
+ end
+
+ rule { can?(:create_work_item) }.enable :create_task
end
end
+
+Namespaces::GroupProjectNamespaceSharedPolicy.prepend_mod
diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb
index 6656d5990a5..3b125429510 100644
--- a/app/policies/project_label_policy.rb
+++ b/app/policies/project_label_policy.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class ProjectLabelPolicy < BasePolicy
- delegate { @subject.parent_container }
+ delegate { @subject.preloaded_parent_container }
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index e2daa8b88a7..30958757011 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -222,8 +222,8 @@ class ProjectPolicy < BasePolicy
condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) }
end
- condition(:project_runner_registration_allowed) do
- Gitlab::CurrentSettings.valid_runner_registrars.include?('project')
+ condition(:project_runner_registration_allowed, scope: :subject) do
+ Gitlab::CurrentSettings.valid_runner_registrars.include?('project') && @subject.runner_registration_enabled
end
condition :registry_enabled do
@@ -242,6 +242,8 @@ class ProjectPolicy < BasePolicy
Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace)
end
+ condition(:namespace_catalog_available) { namespace_catalog_available? }
+
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should
# not.
rule { guest | admin }.enable :read_project_for_iids
@@ -261,7 +263,6 @@ class ProjectPolicy < BasePolicy
enable :reporter_access
enable :developer_access
enable :maintainer_access
- enable :add_catalog_resource
enable :change_namespace
enable :change_visibility_level
@@ -279,9 +280,6 @@ class ProjectPolicy < BasePolicy
enable :set_show_default_award_emojis
enable :set_show_diff_preview_in_email
enable :set_warn_about_potentially_unwanted_characters
-
- enable :register_project_runners
- enable :create_project_runners
enable :manage_owners
end
@@ -354,7 +352,6 @@ class ProjectPolicy < BasePolicy
enable :metrics_dashboard
enable :read_confidential_issues
enable :read_package
- enable :read_product_analytics
enable :read_ci_cd_analytics
enable :read_external_emails
enable :read_grafana
@@ -464,7 +461,8 @@ class ProjectPolicy < BasePolicy
enable :destroy_environment
enable :create_deployment
enable :update_deployment
- enable :read_cluster
+ enable :read_cluster # Deprecated as certificate-based cluster integration (`Clusters::Cluster`).
+ enable :read_cluster_agent
enable :use_k8s_proxies
enable :create_release
enable :update_release
@@ -537,7 +535,9 @@ class ProjectPolicy < BasePolicy
enable :destroy_freeze_period
enable :admin_feature_flags_client
enable :register_project_runners
- enable :create_project_runners
+ enable :create_runner
+ enable :admin_project_runners
+ enable :read_project_runners
enable :update_runners_registration_token
enable :admin_project_google_cloud
enable :admin_project_aws
@@ -844,7 +844,7 @@ class ProjectPolicy < BasePolicy
rule { ~admin & ~project_runner_registration_allowed }.policy do
prevent :register_project_runners
- prevent :create_project_runners
+ prevent :create_runner
end
rule { can?(:admin_project_member) }.policy do
@@ -870,12 +870,20 @@ class ProjectPolicy < BasePolicy
end
rule { ~create_runner_workflow_enabled }.policy do
- prevent :create_project_runners
+ prevent :create_runner
end
# Should be matched with GroupPolicy#read_internal_note
rule { admin | can?(:reporter_access) }.enable :read_internal_note
+ rule { can?(:developer_access) & namespace_catalog_available }.policy do
+ enable :read_namespace_catalog
+ end
+
+ rule { can?(:owner_access) & namespace_catalog_available }.policy do
+ enable :add_catalog_resource
+ end
+
private
def user_is_user?
@@ -969,6 +977,10 @@ class ProjectPolicy < BasePolicy
def project
@subject
end
+
+ def namespace_catalog_available?
+ false
+ end
end
ProjectPolicy.prepend_mod_with('ProjectPolicy')
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index b8f0be9b4c5..e11c1a39757 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -25,10 +25,12 @@ class ProjectSnippetPolicy < BasePolicy
# is used to hide/show various snippet-related controls, so we can't just
# move all of the handling here.
rule do
- all?(private_snippet | (internal_snippet & external_user),
- ~project.guest,
- ~is_author,
- ~can?(:read_all_resources))
+ all?(
+ private_snippet | (internal_snippet & external_user),
+ ~project.guest,
+ ~is_author,
+ ~can?(:read_all_resources)
+ )
end.prevent :read_snippet
rule { internal_snippet & ~is_author & ~admin & ~project.maintainer }.policy do
diff --git a/app/presenters/ml/candidates_csv_presenter.rb b/app/presenters/ml/candidates_csv_presenter.rb
new file mode 100644
index 00000000000..8e2baf6bd28
--- /dev/null
+++ b/app/presenters/ml/candidates_csv_presenter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Ml
+ class CandidatesCsvPresenter
+ CANDIDATE_ASSOCIATIONS = [:latest_metrics, :params, :experiment].freeze
+ # This file size limit is mainly to avoid the generation to hog resources from the server. The value is arbitrary
+ # can be update once we have better insight into usage.
+ TARGET_FILESIZE = 2.megabytes
+
+ def initialize(candidates)
+ @candidates = candidates
+ end
+
+ def present
+ CsvBuilder.new(@candidates, headers, CANDIDATE_ASSOCIATIONS).render(TARGET_FILESIZE)
+ end
+
+ private
+
+ def headers
+ metric_names = columns_names(&:metrics)
+ param_names = columns_names(&:params)
+
+ candidate_to_metrics = @candidates.to_h do |candidate|
+ [candidate.id, candidate.latest_metrics.to_h { |m| [m.name, m.value] }]
+ end
+
+ candidate_to_params = @candidates.to_h do |candidate|
+ [candidate.id, candidate.params.to_h { |m| [m.name, m.value] }]
+ end
+
+ {
+ project_id: 'project_id',
+ experiment_iid: ->(c) { c.experiment.iid },
+ candidate_iid: 'internal_id',
+ name: 'name',
+ external_id: 'eid',
+ start_time: 'start_time',
+ end_time: 'end_time',
+ **param_names.index_with { |name| ->(c) { candidate_to_params.dig(c.id, name) } },
+ **metric_names.index_with { |name| ->(c) { candidate_to_metrics.dig(c.id, name) } }
+ }
+ end
+
+ def columns_names(&selector)
+ @candidates.flat_map(&selector).map(&:name).uniq
+ end
+ end
+end
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
index 57bdd373309..42f61182ab8 100644
--- a/app/presenters/packages/npm/package_presenter.rb
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -3,94 +3,25 @@
module Packages
module Npm
class PackagePresenter
- include API::Helpers::RelatedResourcesHelpers
-
- # Allowed fields are those defined in the abbreviated form
- # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
- # except: name, version, dist, dependencies and xDependencies. Those are generated by this presenter.
- PACKAGE_JSON_ALLOWED_FIELDS = %w[deprecated bin directories dist engines _hasShrinkwrap].freeze
-
- attr_reader :name, :packages
+ def initialize(metadata)
+ @metadata = metadata
+ end
- def initialize(name, packages)
- @name = name
- @packages = packages
+ def name
+ metadata[:name]
end
def versions
- package_versions = {}
-
- packages.each_batch do |relation|
- batched_packages = relation.including_dependency_links
- .preload_files
- .preload_npm_metadatum
-
- batched_packages.each do |package|
- package_file = package.installable_package_files.last
-
- next unless package_file
-
- package_versions[package.version] = build_package_version(package, package_file)
- end
- end
-
- package_versions
+ metadata[:versions]
end
def dist_tags
- build_package_tags.tap { |t| t["latest"] ||= sorted_versions.last }
+ metadata[:dist_tags]
end
private
- def build_package_tags
- package_tags.to_h { |tag| [tag.name, tag.package.version] }
- end
-
- def build_package_version(package, package_file)
- abbreviated_package_json(package).merge(
- name: package.name,
- version: package.version,
- dist: {
- shasum: package_file.file_sha1,
- tarball: tarball_url(package, package_file)
- }
- ).tap do |package_version|
- package_version.merge!(build_package_dependencies(package))
- end
- end
-
- def tarball_url(package, package_file)
- expose_url "#{api_v4_projects_path(id: package.project_id)}" \
- "/packages/npm/#{package.name}" \
- "/-/#{package_file.file_name}"
- end
-
- def build_package_dependencies(package)
- dependencies = Hash.new { |h, key| h[key] = {} }
-
- package.dependency_links.each do |dependency_link|
- dependency = dependency_link.dependency
- dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern
- end
-
- dependencies
- end
-
- def sorted_versions
- versions = packages.pluck_versions.compact
- VersionSorter.sort(versions)
- end
-
- def package_tags
- Packages::Tag.for_package_ids(packages.last_of_each_version_ids)
- .preload_package
- end
-
- def abbreviated_package_json(package)
- json = package.npm_metadatum&.package_json || {}
- json.slice(*PACKAGE_JSON_ALLOWED_FIELDS)
- end
+ attr_reader :metadata
end
end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index c02f3021069..856eba5aadc 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -182,7 +182,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(
true,
- statistic_icon('deployments') +
+ statistic_icon('rocket-launch') +
n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % {
release_count: number_with_delimiter(releases_count),
strong_start: '<strong class="project-stat-value">'.html_safe,
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
index d7d959217b0..91e67c379c4 100644
--- a/app/presenters/search_service_presenter.rb
+++ b/app/presenters/search_service_presenter.rb
@@ -2,6 +2,7 @@
class SearchServicePresenter < Gitlab::View::Presenter::Delegated
include RendersCommits
+ include RendersProjectsList
presents ::SearchService, as: :search_service
@@ -28,6 +29,8 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated
objects.respond_to?(:eager_load) ? objects.eager_load(:status) : objects # rubocop:disable CodeReuse/ActiveRecord
when 'commits'
prepare_commits_for_rendering(objects)
+ when 'projects'
+ prepare_projects_for_rendering(objects)
else
objects
end
diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb
index a550763f0ff..54916d02ecb 100644
--- a/app/serializers/admin/abuse_report_entity.rb
+++ b/app/serializers/admin/abuse_report_entity.rb
@@ -2,15 +2,47 @@
module Admin
class AbuseReportEntity < Grape::Entity
+ include RequestAwareEntity
+ include MarkupHelper
+
expose :category
+ expose :created_at
expose :updated_at
expose :reported_user do |report|
- UserEntity.represent(report.user, only: [:name])
+ UserEntity.represent(report.user, only: [:name, :created_at])
end
expose :reporter do |report|
UserEntity.represent(report.reporter, only: [:name])
end
+
+ expose :reported_user_path do |report|
+ user_path(report.user)
+ end
+
+ expose :reporter_path do |report|
+ user_path(report.reporter)
+ end
+
+ expose :user_blocked do |report|
+ report.user.blocked?
+ end
+
+ expose :block_user_path do |report|
+ block_admin_user_path(report.user)
+ end
+
+ expose :remove_report_path do |report|
+ admin_abuse_report_path(report)
+ end
+
+ expose :remove_user_and_report_path do |report|
+ admin_abuse_report_path(report, remove_user: true)
+ end
+
+ expose :message do |report|
+ markdown_field(report, :message)
+ end
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 9b21fc57b9e..a34f329e9ec 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -74,8 +74,7 @@ class BuildDetailsEntity < Ci::JobEntity
end
expose :path do |build|
- project_merge_request_path(build.merge_request.project,
- build.merge_request)
+ project_merge_request_path(build.merge_request.project, build.merge_request)
end
end
diff --git a/app/serializers/deploy_keys/basic_deploy_key_entity.rb b/app/serializers/deploy_keys/basic_deploy_key_entity.rb
index 9184bc5f0ce..4a3dd3c8f08 100644
--- a/app/serializers/deploy_keys/basic_deploy_key_entity.rb
+++ b/app/serializers/deploy_keys/basic_deploy_key_entity.rb
@@ -10,6 +10,7 @@ module DeployKeys
expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
expose :almost_orphaned?, as: :almost_orphaned
expose :created_at
+ expose :expires_at
expose :updated_at
expose :can_edit
expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) }
diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb
index ed8ac9f40f7..1f1a805af67 100644
--- a/app/serializers/detailed_status_entity.rb
+++ b/app/serializers/detailed_status_entity.rb
@@ -35,7 +35,7 @@ class DetailedStatusEntity < Grape::Entity
expose :favicon,
documentation: { type: 'string',
example: '/assets/ci_favicons/favicon_status_success.png' } do |status|
- Gitlab::Favicon.status_overlay(status.favicon)
+ Gitlab::Favicon.ci_status_overlay(status.favicon)
end
expose :action, if: -> (status, _) { status.has_action? } do
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index aa43b9861d3..97ab9c83d71 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -55,7 +55,19 @@ class DiffFileEntity < DiffFileBaseEntity
end
# Used for inline diffs
- expose :diff_lines_for_serializer, as: :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? }
+ expose :diff_lines_for_serializer, as: :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { display_highlighted_diffs?(diff_file, options) }
+
+ expose :viewer do |diff_file, options|
+ whitespace_only = if !display_highlighted_diffs?(diff_file, options)
+ nil
+ elsif whitespace_only_change?(diff_file)
+ true
+ else
+ false
+ end
+
+ DiffViewerEntity.represent diff_file.viewer, options.merge(whitespace_only: whitespace_only)
+ end
expose :fully_expanded?, as: :is_fully_expanded
@@ -68,6 +80,19 @@ class DiffFileEntity < DiffFileBaseEntity
private
+ def whitespace_only_change?(diff_file)
+ !diff_file.collapsed? &&
+ diff_file.diff_lines_for_serializer.nil? &&
+ (
+ diff_file.added_lines != 0 ||
+ diff_file.removed_lines != 0
+ )
+ end
+
+ def display_highlighted_diffs?(diff_file, options)
+ inline_diff_view?(options) && diff_file.text?
+ end
+
def parallel_diff_view?(options)
diff_view(options) == :parallel
end
diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb
index 45faca6cb2f..8ff9d9612c6 100644
--- a/app/serializers/diff_viewer_entity.rb
+++ b/app/serializers/diff_viewer_entity.rb
@@ -5,4 +5,7 @@ class DiffViewerEntity < Grape::Entity
expose :render_error, as: :error
expose :render_error_message, as: :error_message
expose :collapsed?, as: :collapsed
+ expose :whitespace_only, if: ->(_, _) { Feature.enabled?(:add_ignore_all_white_spaces) } do |_, options|
+ options[:whitespace_only]
+ end
end
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 46d5a488aea..21ffdce155f 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -35,8 +35,10 @@ class EnvironmentSerializer < BaseSerializer
def itemize(resource)
items = resource.order('folder ASC')
.group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)')
- .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder',
- 'COUNT(*) AS size', 'MAX(id) AS last_id')
+ .select(
+ 'COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS folder',
+ 'COUNT(*) AS size', 'MAX(id) AS last_id'
+ )
# It makes a difference when you call `paginate` method, because
# although `page` is effective at the end, it calls counting methods
diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb
index d3b38a24316..4f90eeaa92b 100644
--- a/app/serializers/error_tracking/detailed_error_entity.rb
+++ b/app/serializers/error_tracking/detailed_error_entity.rb
@@ -3,29 +3,29 @@
module ErrorTracking
class DetailedErrorEntity < Grape::Entity
expose :count,
- :culprit,
- :external_base_url,
- :external_url,
- :first_release_last_commit,
- :first_release_short_version,
- :gitlab_commit,
- :gitlab_commit_path,
- :first_seen,
- :frequency,
- :gitlab_issue,
- :id,
- :last_release_last_commit,
- :last_release_short_version,
- :last_seen,
- :message,
- :project_id,
- :project_name,
- :project_slug,
- :short_id,
- :status,
- :tags,
- :title,
- :type,
- :user_count
+ :culprit,
+ :external_base_url,
+ :external_url,
+ :first_release_last_commit,
+ :first_release_short_version,
+ :gitlab_commit,
+ :gitlab_commit_path,
+ :first_seen,
+ :frequency,
+ :gitlab_issue,
+ :id,
+ :last_release_last_commit,
+ :last_release_short_version,
+ :last_seen,
+ :message,
+ :project_id,
+ :project_name,
+ :project_slug,
+ :short_id,
+ :status,
+ :tags,
+ :title,
+ :type,
+ :user_count
end
end
diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb
index 997abb0f148..c305e53eacf 100644
--- a/app/serializers/fork_namespace_entity.rb
+++ b/app/serializers/fork_namespace_entity.rb
@@ -6,7 +6,7 @@ class ForkNamespaceEntity < Grape::Entity
include MarkupHelper
expose :id, :name, :description, :visibility, :full_name,
- :created_at, :updated_at, :avatar_url
+ :created_at, :updated_at, :avatar_url
expose :fork_path do |namespace, options|
project_forks_path(options[:project], namespace_key: namespace.id)
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
index 08070c03bf8..669ade079e1 100644
--- a/app/serializers/group_child_entity.rb
+++ b/app/serializers/group_child_entity.rb
@@ -6,7 +6,7 @@ class GroupChildEntity < Grape::Entity
include MarkupHelper
expose :id, :name, :description, :visibility, :full_name,
- :created_at, :updated_at, :avatar_url
+ :created_at, :updated_at, :avatar_url
expose :type do |instance|
type
@@ -35,12 +35,10 @@ class GroupChildEntity < Grape::Entity
# Project only attributes
expose :last_activity_at, if: lambda { |instance| project? }
- expose :star_count, :archived,
- if: lambda { |_instance, _options| project? }
+ expose :star_count, :archived, if: lambda { |_instance, _options| project? }
# Group only attributes
- expose :children_count, :parent_id,
- unless: lambda { |_instance, _options| project? }
+ expose :children_count, :parent_id, unless: lambda { |_instance, _options| project? }
expose :subgroup_count, if: lambda { |group| access_group_counts?(group) }
diff --git a/app/serializers/group_deploy_key_entity.rb b/app/serializers/group_deploy_key_entity.rb
index c0bb0448a51..9e7be6de35d 100644
--- a/app/serializers/group_deploy_key_entity.rb
+++ b/app/serializers/group_deploy_key_entity.rb
@@ -7,6 +7,7 @@ class GroupDeployKeyEntity < Grape::Entity
expose :fingerprint
expose :fingerprint_sha256
expose :created_at
+ expose :expires_at
expose :updated_at
expose :group_deploy_keys_groups, using: GroupDeployKeysGroupEntity do |group_deploy_key|
group_deploy_key.group_deploy_keys_groups_for_user(options[:user])
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
index ebd0f037160..c99a771bb11 100644
--- a/app/serializers/issue_board_entity.rb
+++ b/app/serializers/issue_board_entity.rb
@@ -57,9 +57,9 @@ class IssueBoardEntity < Grape::Entity
end
expose :issue_type,
- as: :type,
- format_with: :upcase,
- documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
+ as: :type,
+ format_with: :upcase,
+ documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
end
IssueBoardEntity.prepend_mod_with('IssueBoardEntity')
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index 340fd8803af..657af578c7f 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -99,9 +99,9 @@ class IssueEntity < IssuableEntity
end
expose :issue_type,
- as: :type,
- format_with: :upcase,
- documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
+ as: :type,
+ format_with: :upcase,
+ documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
end
IssueEntity.prepend_mod_with('IssueEntity')
diff --git a/app/serializers/linked_issue_entity.rb b/app/serializers/linked_issue_entity.rb
index 4a28213fbac..8ed72472b6c 100644
--- a/app/serializers/linked_issue_entity.rb
+++ b/app/serializers/linked_issue_entity.rb
@@ -26,9 +26,9 @@ class LinkedIssueEntity < Grape::Entity
end
expose :issue_type,
- as: :type,
- format_with: :upcase,
- documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
+ as: :type,
+ format_with: :upcase,
+ documentation: { type: "String", desc: "One of #{::WorkItems::Type.base_types.keys.map(&:upcase)}" }
expose :relation_path
diff --git a/app/serializers/merge_request_metrics_helper.rb b/app/serializers/merge_request_metrics_helper.rb
index fb1769d0aa6..05333b1bef2 100644
--- a/app/serializers/merge_request_metrics_helper.rb
+++ b/app/serializers/merge_request_metrics_helper.rb
@@ -20,9 +20,11 @@ module MergeRequestMetricsHelper
closed_event = merge_request.closed_event
merge_event = merge_request.merge_event
- MergeRequest::Metrics.new(latest_closed_at: closed_event&.updated_at,
- latest_closed_by: closed_event&.author,
- merged_at: merge_event&.updated_at,
- merged_by: merge_event&.author)
+ MergeRequest::Metrics.new(
+ latest_closed_at: closed_event&.updated_at,
+ latest_closed_by: closed_event&.author,
+ merged_at: merge_event&.updated_at,
+ merged_by: merge_event&.author
+ )
end
end
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 33079905ed2..a9c17402515 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -153,6 +153,19 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
end
end
+ expose :favicon_overlay_path,
+ documentation: { type: 'string',
+ example: '/assets/ci_favicons/favicon_status_success.png' } do |merge_request|
+ if merge_request.state == 'merged'
+ status_name = "favicon_status_#{merge_request.state}"
+ Gitlab::Favicon.mr_status_overlay(status_name)
+ else
+ pipeline = merge_request.actual_head_pipeline
+ status = pipeline&.detailed_status(request.current_user)
+ Gitlab::Favicon.ci_status_overlay(status.favicon) if status
+ end
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb
index f432fe98289..467174ac6d3 100644
--- a/app/serializers/rollout_status_entity.rb
+++ b/app/serializers/rollout_status_entity.rb
@@ -14,5 +14,5 @@ class RolloutStatusEntity < Grape::Entity
expose :completion, if: -> (rollout_status, _) { rollout_status.found? }
expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? }
expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false,
- if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? }
+ if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? }
end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index f278ccfce73..f8f5315d0d0 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -13,15 +13,11 @@ class StageEntity < Grape::Entity
if: -> (_, opts) { opts[:grouped] },
with: JobGroupEntity
- expose :latest_statuses,
- if: -> (_, opts) { opts[:details] },
- with: Ci::JobEntity do |stage|
+ expose :latest_statuses, if: -> (_, opts) { opts[:details] }, with: Ci::JobEntity do |stage|
latest_statuses
end
- expose :retried,
- if: -> (_, opts) { opts[:retried] },
- with: Ci::JobEntity do |stage|
+ expose :retried, if: -> (_, opts) { opts[:retried] }, with: Ci::JobEntity do |stage|
retried_statuses
end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index 1a872274cbf..00b022b1c07 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -5,7 +5,7 @@ class TestCaseEntity < Grape::Entity
expose :status, documentation: { type: 'string', example: 'success' }
expose :name, default: "(No name)",
- documentation: { type: 'string', example: 'Security Reports can create an auto-remediation MR' }
+ documentation: { type: 'string', example: 'Security Reports can create an auto-remediation MR' }
expose :classname, documentation: { type: 'string', example: 'vulnerability_management_spec' }
expose :file, documentation: { type: 'string', example: './spec/test_spec.rb' }
expose :execution_time, documentation: { type: 'integer', example: 180 }
diff --git a/app/services/achievements/award_service.rb b/app/services/achievements/award_service.rb
index 674bb8837fb..3cefb0442d5 100644
--- a/app/services/achievements/award_service.rb
+++ b/app/services/achievements/award_service.rb
@@ -22,6 +22,7 @@ module Achievements
awarded_by_user: current_user)
return error_awarding(user_achievement) unless user_achievement.persisted?
+ NotificationService.new.new_achievement_email(recipient, achievement).deliver_later
ServiceResponse.success(payload: user_achievement)
rescue ActiveRecord::RecordNotFound => e
error(e.message)
diff --git a/app/services/achievements/destroy_service.rb b/app/services/achievements/destroy_service.rb
new file mode 100644
index 00000000000..3204adb8e89
--- /dev/null
+++ b/app/services/achievements/destroy_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Achievements
+ class DestroyService
+ attr_reader :current_user, :achievement
+
+ def initialize(current_user, achievement)
+ @current_user = current_user
+ @achievement = achievement
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ achievement.delete
+ ServiceResponse.success(payload: achievement)
+ end
+
+ private
+
+ def allowed?
+ current_user&.can?(:admin_achievement, achievement)
+ end
+
+ def error_no_permissions
+ error('You have insufficient permissions to delete this achievement')
+ end
+
+ def error(message)
+ ServiceResponse.error(message: Array(message))
+ end
+ end
+end
diff --git a/app/services/achievements/update_service.rb b/app/services/achievements/update_service.rb
new file mode 100644
index 00000000000..dcadae8dc3b
--- /dev/null
+++ b/app/services/achievements/update_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Achievements
+ class UpdateService
+ attr_reader :current_user, :achievement, :params
+
+ def initialize(current_user, achievement, params)
+ @current_user = current_user
+ @achievement = achievement
+ @params = params
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ if achievement.update(params)
+ ServiceResponse.success(payload: achievement)
+ else
+ error_updating
+ end
+ end
+
+ private
+
+ def allowed?
+ current_user&.can?(:admin_achievement, achievement)
+ end
+
+ def error_no_permissions
+ error('You have insufficient permission to update this achievement')
+ end
+
+ def error(message)
+ ServiceResponse.error(payload: achievement, message: Array(message))
+ end
+
+ def error_updating
+ error(achievement&.errors&.full_messages || 'Failed to update achievement')
+ end
+ end
+end
diff --git a/app/services/branches/validate_new_service.rb b/app/services/branches/validate_new_service.rb
index e45183d160f..0bee7ffaa66 100644
--- a/app/services/branches/validate_new_service.rb
+++ b/app/services/branches/validate_new_service.rb
@@ -29,3 +29,5 @@ module Branches
end
end
end
+
+Branches::ValidateNewService.prepend_mod
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index ac019d9ec5b..4c9c59ac504 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-# Entry point of the BulkImport feature.
+# Entry point of the BulkImport/Direct Transfer feature.
# This service receives a Gitlab Instance connection params
-# and a list of groups to be imported.
+# and a list of groups or projects to be imported.
#
# Process topography:
#
@@ -15,18 +15,24 @@
# P1 (sync)
#
# - Create a BulkImport record
-# - Create a BulkImport::Entity for each group to be imported
-# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities)
+# - Create a BulkImport::Entity for each group or project (entities) to be imported
+# - Enqueue a BulkImportWorker job (P2) to import the given entity
#
# Pn (async)
#
# - For each group to be imported (BulkImport::Entity.with_status(:created))
# - Import the group data
# - Create entities for each subgroup of the imported group
-# - Enqueue a BulkImports::CreateService job (Pn) to import the new entities (subgroups)
-#
+# - Create entities for each project of the imported group
+# - Enqueue a BulkImportWorker job (Pn) to import the new entities
+
module BulkImports
class CreateService
+ ENTITY_TYPES_MAPPING = {
+ 'group_entity' => 'groups',
+ 'project_entity' => 'projects'
+ }.freeze
+
attr_reader :current_user, :params, :credentials
def initialize(current_user, params, credentials)
@@ -40,7 +46,12 @@ module BulkImports
bulk_import = create_bulk_import
- Gitlab::Tracking.event(self.class.name, 'create', label: 'bulk_import_group')
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'bulk_import_group',
+ extra: { source_equals_destination: source_equals_destination? }
+ )
BulkImportWorker.perform_async(bulk_import.id)
@@ -57,6 +68,7 @@ module BulkImports
def validate!
client.validate_instance_version!
+ validate_setting_enabled!
client.validate_import_scopes!
end
@@ -73,6 +85,8 @@ module BulkImports
Array.wrap(params).each do |entity_params|
track_access_level(entity_params)
+ validate_destination_namespace(entity_params)
+ validate_destination_slug(entity_params[:destination_slug] || entity_params[:destination_name])
validate_destination_full_path(entity_params)
BulkImports::Entity.create!(
@@ -88,6 +102,28 @@ module BulkImports
end
end
+ def validate_setting_enabled!
+ source_full_path, source_type = Array.wrap(params)[0].values_at(:source_full_path, :source_type)
+ entity_type = ENTITY_TYPES_MAPPING.fetch(source_type)
+ if source_full_path =~ /^[0-9]+$/
+ query = query_type(entity_type)
+ response = graphql_client.execute(
+ graphql_client.parse(query.to_s),
+ { full_path: source_full_path }
+ ).original_hash
+
+ source_entity_identifier = ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id
+ else
+ source_entity_identifier = ERB::Util.url_encode(source_full_path)
+ end
+
+ client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status")
+ # the source instance will return a 404 if the feature is disabled as the endpoint won't be available
+ rescue Gitlab::HTTP::BlockedUrlError
+ rescue BulkImports::NetworkError
+ raise ::BulkImports::Error.setting_not_enabled
+ end
+
def track_access_level(entity_params)
Gitlab::Tracking.event(
self.class.name,
@@ -98,6 +134,30 @@ module BulkImports
)
end
+ def source_equals_destination?
+ credentials[:url].starts_with?(Settings.gitlab.base_url)
+ end
+
+ def validate_destination_namespace(entity_params)
+ destination_namespace = entity_params[:destination_namespace]
+ source_type = entity_params[:source_type]
+
+ return if destination_namespace.blank?
+
+ group = Group.find_by_full_path(destination_namespace)
+ if group.nil? ||
+ (source_type == 'group_entity' && !current_user.can?(:create_subgroup, group)) ||
+ (source_type == 'project_entity' && !current_user.can?(:import_projects, group))
+ raise BulkImports::Error.destination_namespace_validation_failure(destination_namespace)
+ end
+ end
+
+ def validate_destination_slug(destination_slug)
+ return if destination_slug =~ Gitlab::Regex.oci_repository_path_regex
+
+ raise BulkImports::Error.destination_slug_validation_failure
+ end
+
def validate_destination_full_path(entity_params)
source_type = entity_params[:source_type]
@@ -140,5 +200,20 @@ module BulkImports
token: @credentials[:access_token]
)
end
+
+ def graphql_client
+ @graphql_client ||= BulkImports::Clients::Graphql.new(
+ url: @credentials[:url],
+ token: @credentials[:access_token]
+ )
+ end
+
+ def query_type(entity_type)
+ if entity_type == 'groups'
+ BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil)
+ else
+ BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil)
+ end
+ end
end
end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 4b62580e670..f2ace1f1590 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -45,29 +45,12 @@ module Ci
return
end
- # TODO: Remove this logging once we confirmed new live trace architecture is functional.
- # See https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/4667.
- unless job.has_live_trace?
- Sidekiq.logger.warn(class: worker_name,
- message: 'The job does not have live trace but going to be archived.',
- job_id: job.id)
- return
- end
-
job.trace.archive!
job.remove_pending_state!
if job.job_artifacts_trace.present?
job.project.execute_integrations(Gitlab::DataBuilder::ArchiveTrace.build(job), :archive_trace_hooks)
end
-
- # TODO: Remove this logging once we confirmed new live trace architecture is functional.
- # See https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/4667.
- unless job.has_archived_trace?
- Sidekiq.logger.warn(class: worker_name,
- message: 'The job does not have archived trace after archiving.',
- job_id: job.id)
- end
rescue ::Gitlab::Ci::Trace::AlreadyArchivedError
# It's already archived, thus we can safely ignore this exception.
rescue StandardError => e
diff --git a/app/services/ci/catalog/add_resource_service.rb b/app/services/ci/catalog/add_resource_service.rb
deleted file mode 100644
index 1f53513b7d1..00000000000
--- a/app/services/ci/catalog/add_resource_service.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module Catalog
- class AddResourceService
- include Gitlab::Allowable
-
- attr_reader :project, :current_user
-
- def initialize(project, user)
- @current_user = user
- @project = project
- end
-
- def execute
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :add_catalog_resource, project)
-
- validation_response = Ci::Catalog::ValidateResourceService.new(project, project.default_branch).execute
-
- if validation_response.success?
- create_catalog_resource
- else
- ServiceResponse.error(message: validation_response.message)
- end
- end
-
- private
-
- def create_catalog_resource
- catalog_resource = Ci::Catalog::Resource.new(project: project)
-
- if catalog_resource.valid?
- catalog_resource.save!
- ServiceResponse.success(payload: catalog_resource)
- else
- ServiceResponse.error(message: catalog_resource.errors.full_messages.join(', '))
- end
- end
- end
- end
-end
diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb
index 1c6aaa9d1ff..56e22a64529 100644
--- a/app/services/ci/generate_kubeconfig_service.rb
+++ b/app/services/ci/generate_kubeconfig_service.rb
@@ -41,7 +41,7 @@ module Ci
attr_reader :pipeline, :token, :environment, :template
def agent_authorizations
- ::Clusters::Agents::FilterAuthorizationsService.new(
+ ::Clusters::Agents::Authorizations::CiAccess::FilterService.new(
pipeline.cluster_agent_authorizations,
environment: environment
).execute
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index 30d310dec7f..1de2424924a 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -39,14 +39,18 @@ module Ci
return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file)
- artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file)
- result = parse_artifact(artifact)
+ build_result = build_artifact(artifacts_file, params, metadata_file)
+ return build_result unless build_result[:status] == :success
+
+ artifact = build_result[:artifact]
+ artifact_metadata = build_result[:artifact_metadata]
track_artifact_uploader(artifact)
- return result unless result[:status] == :success
+ parse_result = parse_artifact(artifact)
+ return parse_result unless parse_result[:status] == :success
- persist_artifact(artifact, artifact_metadata, params)
+ persist_artifact(artifact, artifact_metadata)
end
private
@@ -76,40 +80,44 @@ module Ci
end
def build_artifact(artifacts_file, params, metadata_file)
- expire_in = params['expire_in'] ||
- Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
-
artifact_attributes = {
job: job,
project: project,
- expire_in: expire_in
+ expire_in: expire_in(params),
+ accessibility: accessibility(params),
+ locked: pipeline.locked
}
- artifact_attributes[:locked] = pipeline.locked
+ file_attributes = {
+ file_type: params[:artifact_type],
+ file_format: params[:artifact_format],
+ file_sha256: artifacts_file.sha256,
+ file: artifacts_file
+ }
- artifact = Ci::JobArtifact.new(
- artifact_attributes.merge(
- file: artifacts_file,
- file_type: params[:artifact_type],
- file_format: params[:artifact_format],
- file_sha256: artifacts_file.sha256,
- accessibility: accessibility(params)
- )
- )
+ artifact = Ci::JobArtifact.new(artifact_attributes.merge(file_attributes))
- artifact_metadata = if metadata_file
- Ci::JobArtifact.new(
- artifact_attributes.merge(
- file: metadata_file,
- file_type: :metadata,
- file_format: :gzip,
- file_sha256: metadata_file.sha256,
- accessibility: accessibility(params)
- )
- )
- end
+ artifact_metadata = build_metadata_artifact(artifact, metadata_file) if metadata_file
+
+ success(artifact: artifact, artifact_metadata: artifact_metadata)
+ end
+
+ def build_metadata_artifact(job_artifact, metadata_file)
+ Ci::JobArtifact.new(
+ job: job_artifact.job,
+ project: job_artifact.project,
+ expire_at: job_artifact.expire_at,
+ locked: job_artifact.locked,
+ file: metadata_file,
+ file_type: :metadata,
+ file_format: :gzip,
+ file_sha256: metadata_file.sha256,
+ accessibility: job_artifact.accessibility
+ )
+ end
- [artifact, artifact_metadata]
+ def expire_in(params)
+ params['expire_in'] || Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
end
def accessibility(params)
@@ -129,8 +137,8 @@ module Ci
end
end
- def persist_artifact(artifact, artifact_metadata, params)
- Ci::JobArtifact.transaction do
+ def persist_artifact(artifact, artifact_metadata)
+ job.transaction do
# NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
# Running it first because in migrations we lock the `ci_builds` table
# first and then the others. This reduces the chances of deadlocks.
@@ -142,13 +150,13 @@ module Ci
success(artifact: artifact)
rescue ActiveRecord::RecordNotUnique => error
- track_exception(error, params)
+ track_exception(error, artifact.file_type)
error('another artifact of the same type already exists', :bad_request)
rescue *OBJECT_STORAGE_ERRORS => error
- track_exception(error, params)
+ track_exception(error, artifact.file_type)
error(error.message, :service_unavailable)
rescue StandardError => error
- track_exception(error, params)
+ track_exception(error, artifact.file_type)
error(error.message, :bad_request)
end
@@ -159,11 +167,12 @@ module Ci
existing_artifact.file_sha256 == artifacts_file.sha256
end
- def track_exception(error, params)
- Gitlab::ErrorTracking.track_exception(error,
+ def track_exception(error, artifact_type)
+ Gitlab::ErrorTracking.track_exception(
+ error,
job_id: job.id,
project_id: job.project_id,
- uploading_type: params[:artifact_type]
+ uploading_type: artifact_type
)
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 4f2230ea1fc..4c087d23a53 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -34,7 +34,7 @@ module Ci
def process!
update_stages!
update_pipeline!
- update_statuses_processed!
+ update_jobs_processed!
Ci::ExpirePipelineCacheService.new.execute(pipeline)
@@ -46,60 +46,61 @@ module Ci
end
def update_stage!(stage)
- # Update processables for a given stage in bulk/slices
+ # Update jobs for a given stage in bulk/slices
@collection
- .created_processable_ids_in_stage(stage.position)
- .in_groups_of(BATCH_SIZE, false) { |ids| update_processables!(ids) }
+ .created_job_ids_in_stage(stage.position)
+ .in_groups_of(BATCH_SIZE, false) { |ids| update_jobs!(ids) }
status = @collection.status_of_stage(stage.position)
stage.set_status(status)
end
- def update_processables!(ids)
- created_processables = pipeline.processables.id_in(ids)
+ def update_jobs!(ids)
+ created_jobs = pipeline
+ .current_processable_jobs
+ .id_in(ids)
.with_project_preload
.created
- .latest
.ordered_by_stage
.select_with_aggregated_needs(project)
- created_processables.each { |processable| update_processable!(processable) }
+ created_jobs.each { |job| update_job!(job) }
end
def update_pipeline!
pipeline.set_status(@collection.status_of_all)
end
- def update_statuses_processed!
- processing = @collection.processing_processables
+ def update_jobs_processed!
+ processing = @collection.processing_jobs
processing.each_slice(BATCH_SIZE) do |slice|
- pipeline.statuses.match_id_and_lock_version(slice)
+ pipeline.all_jobs.match_id_and_lock_version(slice)
.update_as_processed!
end
end
- def update_processable!(processable)
- previous_status = status_of_previous_processables(processable)
- # We do not continue to process the processable if the previous status is not completed
+ def update_job!(job)
+ previous_status = status_of_previous_jobs(job)
+ # We do not continue to process the job if the previous status is not completed
return unless Ci::HasStatus::COMPLETED_STATUSES.include?(previous_status)
- Gitlab::OptimisticLocking.retry_lock(processable, name: 'atomic_processing_update_processable') do |subject|
+ Gitlab::OptimisticLocking.retry_lock(job, name: 'atomic_processing_update_job') do |subject|
Ci::ProcessBuildService.new(project, subject.user)
.execute(subject, previous_status)
- # update internal representation of status
- # to make the status change of processable to be taken into account during further processing
- @collection.set_processable_status(processable.id, processable.status, processable.lock_version)
+ # update internal representation of job
+ # to make the status change of job to be taken into account during further processing
+ @collection.set_job_status(job.id, job.status, job.lock_version)
end
end
- def status_of_previous_processables(processable)
- if processable.scheduling_type_dag?
- # Processable uses DAG, get status of all dependent needs
- @collection.status_of_processables(processable.aggregated_needs_names.to_a, dag: true)
+ def status_of_previous_jobs(job)
+ if job.scheduling_type_dag?
+ # job uses DAG, get status of all dependent needs
+ @collection.status_of_jobs(job.aggregated_needs_names.to_a)
else
- # Processable uses Stages, get status of prior stage
- @collection.status_of_processables_prior_to_stage(processable.stage_idx.to_i)
+ # job uses Stages, get status of prior stage
+ @collection.status_of_jobs_prior_to_stage(job.stage_idx.to_i)
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 9738e4e65b7..85646b79254 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
@@ -8,119 +8,113 @@ module Ci
attr_reader :pipeline
- # We use these columns to perform an efficient
- # calculation of a status
- STATUSES_COLUMNS = [
- :id, :name, :status, :allow_failure,
- :stage_idx, :processed, :lock_version
- ].freeze
-
def initialize(pipeline)
@pipeline = pipeline
- @stage_statuses = {}
- @prior_stage_statuses = {}
+ @stage_jobs = {}
+ @prior_stage_jobs = {}
end
# This method updates internal status for given ID
- def set_processable_status(id, status, lock_version)
- processable = all_statuses_by_id[id]
- return unless processable
+ def set_job_status(id, status, lock_version)
+ job = all_jobs_by_id[id]
+ return unless job
- processable[:status] = status
- processable[:lock_version] = lock_version
+ job[:status] = status
+ job[:lock_version] = lock_version
end
- # This methods gets composite status of all processables
+ # This methods gets composite status of all jobs
def status_of_all
- status_for_array(all_statuses, dag: false)
+ status_for_array(all_jobs)
end
- # This methods gets composite status for processables at a given stage
+ # This methods gets composite status for jobs at a given stage
def status_of_stage(stage_position)
strong_memoize("status_of_stage_#{stage_position}") do
- stage_statuses = all_statuses_grouped_by_stage_position[stage_position].to_a
+ stage_jobs = all_jobs_grouped_by_stage_position[stage_position].to_a
- status_for_array(stage_statuses.flatten, dag: false)
+ status_for_array(stage_jobs.flatten)
end
end
- # This methods gets composite status for processables with given names
- def status_of_processables(names, dag:)
- name_statuses = all_statuses_by_name.slice(*names)
+ # This methods gets composite status for jobs with given names
+ def status_of_jobs(names)
+ jobs = all_jobs_by_name.slice(*names)
- status_for_array(name_statuses.values, dag: dag)
+ status_for_array(jobs.values, dag: true)
end
- # This methods gets composite status for processables before given stage
- def status_of_processables_prior_to_stage(stage_position)
- strong_memoize("status_of_processables_prior_to_stage_#{stage_position}") do
- stage_statuses = all_statuses_grouped_by_stage_position
+ # This methods gets composite status for jobs before given stage
+ def status_of_jobs_prior_to_stage(stage_position)
+ strong_memoize("status_of_jobs_prior_to_stage_#{stage_position}") do
+ stage_jobs = all_jobs_grouped_by_stage_position
.select { |position, _| position < stage_position }
- status_for_array(stage_statuses.values.flatten, dag: false)
+ status_for_array(stage_jobs.values.flatten)
end
end
- # This methods gets a list of processables for a given stage
- def created_processable_ids_in_stage(stage_position)
- all_statuses_grouped_by_stage_position[stage_position]
+ # This methods gets a list of jobs for a given stage
+ def created_job_ids_in_stage(stage_position)
+ all_jobs_grouped_by_stage_position[stage_position]
.to_a
- .select { |processable| processable[:status] == 'created' }
- .map { |processable| processable[:id] }
+ .select { |job| job[:status] == 'created' }
+ .map { |job| job[:id] }
end
- # This method returns a list of all processable, that are to be processed
- def processing_processables
- all_statuses.lazy.reject { |status| status[:processed] }
+ # This method returns a list of all job, that are to be processed
+ def processing_jobs
+ all_jobs.lazy.reject { |job| job[:processed] }
end
private
- def status_for_array(statuses, dag:)
+ # We use these columns to perform an efficient calculation of a status
+ JOB_ATTRS = [
+ :id, :name, :status, :allow_failure,
+ :stage_idx, :processed, :lock_version
+ ].freeze
+
+ def status_for_array(jobs, dag: false)
result = Gitlab::Ci::Status::Composite
- .new(statuses, dag: dag)
+ .new(jobs, dag: dag, project: pipeline.project)
.status
result || 'success'
end
- def all_statuses_grouped_by_stage_position
- strong_memoize(:all_statuses_by_order) do
- all_statuses.group_by { |status| status[:stage_idx].to_i }
+ def all_jobs_grouped_by_stage_position
+ strong_memoize(:all_jobs_by_order) do
+ all_jobs.group_by { |job| job[:stage_idx].to_i }
end
end
- def all_statuses_by_id
- strong_memoize(:all_statuses_by_id) do
- all_statuses.index_by { |row| row[:id] }
+ def all_jobs_by_id
+ strong_memoize(:all_jobs_by_id) do
+ all_jobs.index_by { |row| row[:id] }
end
end
- def all_statuses_by_name
- strong_memoize(:statuses_by_name) do
- all_statuses.index_by { |row| row[:name] }
+ def all_jobs_by_name
+ strong_memoize(:jobs_by_name) do
+ all_jobs.index_by { |row| row[:name] }
end
end
# rubocop: disable CodeReuse/ActiveRecord
- def all_statuses
+ def all_jobs
# We fetch all relevant data in one go.
#
- # This is more efficient than relying
- # on PostgreSQL to calculate composite status
- # for us
+ # This is more efficient than relying on PostgreSQL to calculate composite status for us
#
- # Since we need to reprocess everything
- # we can fetch all of them and do processing
- # ourselves.
- strong_memoize(:all_statuses) do
- raw_statuses = pipeline
- .statuses
- .latest
+ # Since we need to reprocess everything we can fetch all of them and do processing ourselves.
+ strong_memoize(:all_jobs) do
+ raw_jobs = pipeline
+ .current_jobs
.ordered_by_stage
- .pluck(*STATUSES_COLUMNS)
+ .pluck(*JOB_ATTRS)
- raw_statuses.map do |row|
- STATUSES_COLUMNS.zip(row).to_h
+ raw_jobs.map do |row|
+ JOB_ATTRS.zip(row).to_h
end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 228a246f480..4b55ce149e1 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -6,7 +6,7 @@ module Ci
class RegisterJobService
include ::Gitlab::Ci::Artifacts::Logger
- attr_reader :runner, :runner_machine, :metrics
+ attr_reader :runner, :runner_manager, :metrics
TEMPORARY_LOCK_TIMEOUT = 3.seconds
@@ -18,9 +18,9 @@ module Ci
# affect 5% of the worst case scenarios.
MAX_QUEUE_DEPTH = 45
- def initialize(runner, runner_machine)
+ def initialize(runner, runner_manager)
@runner = runner
- @runner_machine = runner_machine
+ @runner_manager = runner_manager
@metrics = ::Gitlab::Ci::Queue::Metrics.new(runner)
end
@@ -255,7 +255,7 @@ module Ci
@metrics.increment_queue_operation(:runner_pre_assign_checks_success)
build.run!
- build.runner_machine = runner_machine if runner_machine
+ build.runner_manager = runner_manager if runner_manager
end
!failure_reason
diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb
index 5906cdce99d..ff4a33e431b 100644
--- a/app/services/ci/runners/create_runner_service.rb
+++ b/app/services/ci/runners/create_runner_service.rb
@@ -5,39 +5,44 @@ module Ci
class CreateRunnerService
RUNNER_CLASS_MAPPING = {
'instance_type' => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy,
- nil => Ci::Runners::RunnerCreationStrategies::InstanceRunnerStrategy
+ 'group_type' => Ci::Runners::RunnerCreationStrategies::GroupRunnerStrategy,
+ 'project_type' => Ci::Runners::RunnerCreationStrategies::ProjectRunnerStrategy
}.freeze
- attr_accessor :user, :type, :params, :strategy
-
- def initialize(user:, type:, params:)
+ def initialize(user:, params:)
@user = user
- @type = type
@params = params
- @strategy = RUNNER_CLASS_MAPPING[type].new(user: user, type: type, params: params)
+ @strategy = RUNNER_CLASS_MAPPING[params[:runner_type]].new(user: user, params: params)
end
def execute
normalize_params
- return ServiceResponse.error(message: 'Validation error') unless strategy.validate_params
- return ServiceResponse.error(message: 'Insufficient permissions') unless strategy.authorized_user?
+ error = strategy.validate_params
+ return ServiceResponse.error(message: error, reason: :validation_error) if error
+
+ unless strategy.authorized_user?
+ return ServiceResponse.error(message: _('Insufficient permissions'), reason: :forbidden)
+ end
runner = ::Ci::Runner.new(params)
return ServiceResponse.success(payload: { runner: runner }) if runner.save
- ServiceResponse.error(message: runner.errors.full_messages)
+ ServiceResponse.error(message: runner.errors.full_messages, reason: :save_error)
end
def normalize_params
params[:registration_type] = :authenticated_user
- params[:runner_type] = type
params[:active] = !params.delete(:paused) if params.key?(:paused)
params[:creator] = user
strategy.normalize_params
end
+
+ private
+
+ attr_reader :user, :params, :strategy
end
end
end
diff --git a/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb
new file mode 100644
index 00000000000..2eae5069046
--- /dev/null
+++ b/app/services/ci/runners/runner_creation_strategies/group_runner_strategy.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ module RunnerCreationStrategies
+ class GroupRunnerStrategy
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(user:, params:)
+ @user = user
+ @params = params
+ end
+
+ def normalize_params
+ params[:runner_type] = 'group_type'
+ params[:groups] = [scope]
+ end
+
+ def validate_params
+ _('Missing/invalid scope') unless scope.present?
+ end
+
+ def authorized_user?
+ user.present? && user.can?(:create_runner, scope)
+ end
+
+ private
+
+ attr_reader :user, :params
+
+ def scope
+ params.delete(:scope)
+ end
+ strong_memoize_attr :scope
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb
index f195c3e88f9..39719ad806f 100644
--- a/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb
+++ b/app/services/ci/runners/runner_creation_strategies/instance_runner_strategy.rb
@@ -4,25 +4,26 @@ module Ci
module Runners
module RunnerCreationStrategies
class InstanceRunnerStrategy
- attr_accessor :user, :type, :params
-
- def initialize(user:, type:, params:)
+ def initialize(user:, params:)
@user = user
- @type = type
@params = params
end
def normalize_params
- params[:runner_type] = :instance_type
+ params[:runner_type] = 'instance_type'
end
def validate_params
- true
+ _('Unexpected scope') if params[:scope]
end
def authorized_user?
- user.present? && user.can?(:create_instance_runners)
+ user.present? && user.can?(:create_instance_runner)
end
+
+ private
+
+ attr_reader :user, :params
end
end
end
diff --git a/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb b/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb
new file mode 100644
index 00000000000..487da996513
--- /dev/null
+++ b/app/services/ci/runners/runner_creation_strategies/project_runner_strategy.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ module RunnerCreationStrategies
+ class ProjectRunnerStrategy
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(user:, params:)
+ @user = user
+ @params = params
+ end
+
+ def normalize_params
+ params[:runner_type] = 'project_type'
+ params[:projects] = [scope]
+ end
+
+ def validate_params
+ _('Missing/invalid scope') unless scope.present?
+ end
+
+ def authorized_user?
+ user.present? && user.can?(:create_runner, scope)
+ end
+
+ private
+
+ attr_reader :user, :params
+
+ def scope
+ params.delete(:scope)
+ end
+ strong_memoize_attr :scope
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/stale_machines_cleanup_service.rb b/app/services/ci/runners/stale_managers_cleanup_service.rb
index 3e5706d24a6..b39f7315bc6 100644
--- a/app/services/ci/runners/stale_machines_cleanup_service.rb
+++ b/app/services/ci/runners/stale_managers_cleanup_service.rb
@@ -2,25 +2,25 @@
module Ci
module Runners
- class StaleMachinesCleanupService
+ class StaleManagersCleanupService
MAX_DELETIONS = 1000
def execute
ServiceResponse.success(payload: {
# the `stale` relationship can return duplicates, so we don't try to return a precise count here
- deleted_machines: delete_stale_runner_machines > 0
+ deleted_managers: delete_stale_runner_managers > 0
})
end
private
- def delete_stale_runner_machines
+ def delete_stale_runner_managers
total_deleted_count = 0
loop do
sub_batch_limit = [100, MAX_DELETIONS].min
# delete_all discards part of the `stale` scope query, so we expliclitly wrap it with a SELECT as a workaround
- deleted_count = Ci::RunnerMachine.id_in(Ci::RunnerMachine.stale.limit(sub_batch_limit)).delete_all
+ deleted_count = Ci::RunnerManager.id_in(Ci::RunnerManager.stale.limit(sub_batch_limit)).delete_all
total_deleted_count += deleted_count
break if deleted_count == 0 || total_deleted_count >= MAX_DELETIONS
diff --git a/app/services/ci/track_failed_build_service.rb b/app/services/ci/track_failed_build_service.rb
index 973c43a9445..cd7d548e102 100644
--- a/app/services/ci/track_failed_build_service.rb
+++ b/app/services/ci/track_failed_build_service.rb
@@ -6,7 +6,7 @@
# @param exit_code [Int] the resulting exit code.
module Ci
class TrackFailedBuildService
- SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-1'
+ SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-2'
def initialize(build:, exit_code:, failure_reason:)
@build = build
diff --git a/app/services/clusters/agents/authorizations/ci_access/filter_service.rb b/app/services/clusters/agents/authorizations/ci_access/filter_service.rb
new file mode 100644
index 00000000000..cd08aaa12d4
--- /dev/null
+++ b/app/services/clusters/agents/authorizations/ci_access/filter_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class FilterService
+ def initialize(authorizations, filter_params)
+ @authorizations = authorizations
+ @filter_params = filter_params
+
+ @environments_matcher = {}
+ end
+
+ def execute
+ filter_by_environment(authorizations)
+ end
+
+ private
+
+ attr_reader :authorizations, :filter_params
+
+ def filter_by_environment(auths)
+ return auths unless filter_by_environment?
+
+ auths.select do |auth|
+ next true if auth.config['environments'].blank?
+
+ auth.config['environments'].any? { |environment_pattern| matches_environment?(environment_pattern) }
+ end
+ end
+
+ def filter_by_environment?
+ filter_params.has_key?(:environment)
+ end
+
+ def environment_filter
+ @environment_filter ||= filter_params[:environment]
+ end
+
+ def matches_environment?(environment_pattern)
+ return false if environment_filter.nil?
+
+ environments_matcher(environment_pattern).match?(environment_filter)
+ end
+
+ def environments_matcher(environment_pattern)
+ @environments_matcher[environment_pattern] ||= ::Gitlab::Ci::EnvironmentMatcher.new(environment_pattern)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb b/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb
new file mode 100644
index 00000000000..047a0725a2c
--- /dev/null
+++ b/app/services/clusters/agents/authorizations/ci_access/refresh_service.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module CiAccess
+ class RefreshService
+ include Gitlab::Utils::StrongMemoize
+
+ AUTHORIZED_ENTITY_LIMIT = 100
+
+ delegate :project, to: :agent, private: true
+ delegate :root_ancestor, to: :project, private: true
+
+ def initialize(agent, config:)
+ @agent = agent
+ @config = config
+ end
+
+ def execute
+ refresh_projects!
+ refresh_groups!
+
+ true
+ end
+
+ private
+
+ attr_reader :agent, :config
+
+ def refresh_projects!
+ if allowed_project_configurations.present?
+ project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) }
+
+ agent.with_lock do
+ agent.ci_access_project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id])
+ agent.ci_access_project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
+ end
+ else
+ agent.ci_access_project_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def refresh_groups!
+ if allowed_group_configurations.present?
+ group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) }
+
+ agent.with_lock do
+ agent.ci_access_group_authorizations.upsert_all(allowed_group_configurations, unique_by: [:agent_id, :group_id])
+ agent.ci_access_group_authorizations.where.not(group_id: group_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
+ end
+ else
+ agent.ci_access_group_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def allowed_project_configurations
+ strong_memoize(:allowed_project_configurations) do
+ project_entries = extract_config_entries(entity: 'projects')
+
+ if project_entries
+ allowed_projects.where_full_path_in(project_entries.keys).map do |project|
+ { project_id: project.id, config: project_entries[project.full_path.downcase] }
+ end
+ end
+ end
+ end
+
+ def allowed_group_configurations
+ strong_memoize(:allowed_group_configurations) do
+ group_entries = extract_config_entries(entity: 'groups')
+
+ if group_entries
+ allowed_groups.where_full_path_in(group_entries.keys).map do |group|
+ { group_id: group.id, config: group_entries[group.full_path.downcase] }
+ end
+ end
+ end
+ end
+
+ def extract_config_entries(entity:)
+ config.dig('ci_access', entity)
+ &.first(AUTHORIZED_ENTITY_LIMIT)
+ &.index_by { |config| config.delete('id').downcase }
+ end
+
+ def allowed_projects
+ root_ancestor.all_projects
+ end
+
+ def allowed_groups
+ if group_root_ancestor?
+ root_ancestor.self_and_descendants
+ else
+ ::Group.none
+ end
+ end
+
+ def group_root_ancestor?
+ root_ancestor.group_namespace?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/authorizations/user_access/refresh_service.rb b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb
new file mode 100644
index 00000000000..04d6e04c54d
--- /dev/null
+++ b/app/services/clusters/agents/authorizations/user_access/refresh_service.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class RefreshService
+ include Gitlab::Utils::StrongMemoize
+
+ AUTHORIZED_ENTITY_LIMIT = 100
+
+ delegate :project, to: :agent, private: true
+ delegate :root_ancestor, to: :project, private: true
+
+ def initialize(agent, config:)
+ @agent = agent
+ @config = config
+ end
+
+ def execute
+ refresh_projects!
+ refresh_groups!
+
+ true
+ end
+
+ private
+
+ attr_reader :agent, :config
+
+ def refresh_projects!
+ if allowed_project_configurations.present?
+ project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) }
+
+ agent.with_lock do
+ agent.user_access_project_authorizations.upsert_configs(allowed_project_configurations)
+ agent.user_access_project_authorizations.delete_unlisted(project_ids)
+ end
+ else
+ agent.user_access_project_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def refresh_groups!
+ if allowed_group_configurations.present?
+ group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) }
+
+ agent.with_lock do
+ agent.user_access_group_authorizations.upsert_configs(allowed_group_configurations)
+ agent.user_access_group_authorizations.delete_unlisted(group_ids)
+ end
+ else
+ agent.user_access_group_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def allowed_project_configurations
+ project_entries = extract_config_entries(entity: 'projects')
+
+ return unless project_entries
+
+ allowed_projects.where_full_path_in(project_entries.keys).map do |project|
+ { project_id: project.id, config: user_access_as }
+ end
+ end
+ strong_memoize_attr :allowed_project_configurations
+
+ def allowed_group_configurations
+ group_entries = extract_config_entries(entity: 'groups')
+
+ return unless group_entries
+
+ allowed_groups.where_full_path_in(group_entries.keys).map do |group|
+ { group_id: group.id, config: user_access_as }
+ end
+ end
+ strong_memoize_attr :allowed_group_configurations
+
+ def extract_config_entries(entity:)
+ config.dig('user_access', entity)
+ &.first(AUTHORIZED_ENTITY_LIMIT)
+ &.index_by { |config| config.delete('id').downcase }
+ end
+
+ def allowed_projects
+ root_ancestor.all_projects
+ end
+
+ def allowed_groups
+ if group_root_ancestor?
+ root_ancestor.self_and_descendants
+ else
+ ::Group.none
+ end
+ end
+
+ def group_root_ancestor?
+ root_ancestor.group_namespace?
+ end
+
+ def user_access_as
+ @user_access_as ||= config['user_access']&.slice('access_as') || {}
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/agents/authorize_proxy_user_service.rb b/app/services/clusters/agents/authorize_proxy_user_service.rb
index ec6645b2db4..ba90d61a7ef 100644
--- a/app/services/clusters/agents/authorize_proxy_user_service.rb
+++ b/app/services/clusters/agents/authorize_proxy_user_service.rb
@@ -57,7 +57,7 @@ module Clusters
def authorized_projects(user_access)
strong_memoize_with(:authorized_projects, user_access) do
user_access.fetch(:projects, [])
- .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT)
+ .first(::Clusters::Agents::Authorizations::CiAccess::RefreshService::AUTHORIZED_ENTITY_LIMIT)
.map { |project| ::Project.find_by_full_path(project[:id]) }
.select { |project| current_user.can?(:use_k8s_proxies, project) }
end
@@ -66,7 +66,7 @@ module Clusters
def authorized_groups(user_access)
strong_memoize_with(:authorized_groups, user_access) do
user_access.fetch(:groups, [])
- .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT)
+ .first(::Clusters::Agents::Authorizations::CiAccess::RefreshService::AUTHORIZED_ENTITY_LIMIT)
.map { |group| ::Group.find_by_full_path(group[:id]) }
.select { |group| current_user.can?(:use_k8s_proxies, group) }
end
diff --git a/app/services/clusters/agents/filter_authorizations_service.rb b/app/services/clusters/agents/filter_authorizations_service.rb
deleted file mode 100644
index 68517ceec04..00000000000
--- a/app/services/clusters/agents/filter_authorizations_service.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- class FilterAuthorizationsService
- def initialize(authorizations, filter_params)
- @authorizations = authorizations
- @filter_params = filter_params
-
- @environments_matcher = {}
- end
-
- def execute
- filter_by_environment(authorizations)
- end
-
- private
-
- attr_reader :authorizations, :filter_params
-
- def filter_by_environment(auths)
- return auths unless filter_by_environment?
-
- auths.select do |auth|
- next true if auth.config['environments'].blank?
-
- auth.config['environments'].any? { |environment_pattern| matches_environment?(environment_pattern) }
- end
- end
-
- def filter_by_environment?
- filter_params.has_key?(:environment)
- end
-
- def environment_filter
- @environment_filter ||= filter_params[:environment]
- end
-
- def matches_environment?(environment_pattern)
- return false if environment_filter.nil?
-
- environments_matcher(environment_pattern).match?(environment_filter)
- end
-
- def environments_matcher(environment_pattern)
- @environments_matcher[environment_pattern] ||= ::Gitlab::Ci::EnvironmentMatcher.new(environment_pattern)
- end
- end
- end
-end
diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb
deleted file mode 100644
index 23ececef6a1..00000000000
--- a/app/services/clusters/agents/refresh_authorization_service.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Agents
- class RefreshAuthorizationService
- include Gitlab::Utils::StrongMemoize
-
- AUTHORIZED_ENTITY_LIMIT = 100
-
- delegate :project, to: :agent, private: true
- delegate :root_ancestor, to: :project, private: true
-
- def initialize(agent, config:)
- @agent = agent
- @config = config
- end
-
- def execute
- refresh_projects!
- refresh_groups!
-
- true
- end
-
- private
-
- attr_reader :agent, :config
-
- def refresh_projects!
- if allowed_project_configurations.present?
- project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) }
-
- agent.with_lock do
- agent.project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id])
- agent.project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
- end
- else
- agent.project_authorizations.delete_all(:delete_all)
- end
- end
-
- def refresh_groups!
- if allowed_group_configurations.present?
- group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) }
-
- agent.with_lock do
- agent.group_authorizations.upsert_all(allowed_group_configurations, unique_by: [:agent_id, :group_id])
- agent.group_authorizations.where.not(group_id: group_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
- end
- else
- agent.group_authorizations.delete_all(:delete_all)
- end
- end
-
- def allowed_project_configurations
- strong_memoize(:allowed_project_configurations) do
- project_entries = extract_config_entries(entity: 'projects')
-
- if project_entries
- allowed_projects.where_full_path_in(project_entries.keys).map do |project|
- { project_id: project.id, config: project_entries[project.full_path.downcase] }
- end
- end
- end
- end
-
- def allowed_group_configurations
- strong_memoize(:allowed_group_configurations) do
- group_entries = extract_config_entries(entity: 'groups')
-
- if group_entries
- allowed_groups.where_full_path_in(group_entries.keys).map do |group|
- { group_id: group.id, config: group_entries[group.full_path.downcase] }
- end
- end
- end
- end
-
- def extract_config_entries(entity:)
- config.dig('ci_access', entity)
- &.first(AUTHORIZED_ENTITY_LIMIT)
- &.index_by { |config| config.delete('id').downcase }
- end
-
- def allowed_projects
- root_ancestor.all_projects
- end
-
- def allowed_groups
- if group_root_ancestor?
- root_ancestor.self_and_descendants
- else
- ::Group.none
- end
- end
-
- def group_root_ancestor?
- root_ancestor.group_namespace?
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
deleted file mode 100644
index 0c9b41be8d2..00000000000
--- a/app/services/clusters/applications/base_helm_service.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class BaseHelmService
- attr_accessor :app
-
- def initialize(app)
- @app = app
- end
-
- protected
-
- def log_error(error)
- meta = {
- error_code: error.respond_to?(:error_code) ? error.error_code : nil,
- service: self.class.name,
- app_id: app.id,
- app_name: app.name,
- project_ids: app.cluster.project_ids,
- group_ids: app.cluster.group_ids
- }
-
- Gitlab::ErrorTracking.track_exception(error, meta)
- end
-
- def log_event(event)
- meta = {
- service: self.class.name,
- app_id: app.id,
- app_name: app.name,
- project_ids: app.cluster.project_ids,
- group_ids: app.cluster.group_ids,
- event: event
- }
-
- logger.info(meta)
- end
-
- def logger
- @logger ||= Gitlab::Kubernetes::Logger.build
- end
-
- def cluster
- app.cluster
- end
-
- def kubeclient
- cluster.kubeclient
- end
-
- def helm_api
- @helm_api ||= Gitlab::Kubernetes::Helm::API.new(kubeclient)
- end
-
- def install_command
- @install_command ||= app.install_command
- end
-
- def update_command
- @update_command ||= app.update_command
- end
-
- def patch_command(new_values = "")
- app.patch_command(new_values)
- end
- end
- end
-end
diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb
index f0e9862ca30..5e87f610e4e 100644
--- a/app/services/concerns/issues/resolve_discussions.rb
+++ b/app/services/concerns/issues/resolve_discussions.rb
@@ -16,7 +16,11 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord
def merge_request_to_resolve_discussions_of
strong_memoize(:merge_request_to_resolve_discussions_of) do
- MergeRequestsFinder.new(current_user, project_id: project.id)
+ # sometimes this will be a Group, when work item is created at group level.
+ # Not sure if we will need to handle resolving an MR with an issue at group level?
+ next unless container.is_a?(Project)
+
+ MergeRequestsFinder.new(current_user, project_id: container.id)
.find_by(iid: merge_request_to_resolve_discussions_of_iid)
end
end
diff --git a/app/services/concerns/work_items/widgetable_service.rb b/app/services/concerns/work_items/widgetable_service.rb
index 24ade9336b2..9d1132b1aba 100644
--- a/app/services/concerns/work_items/widgetable_service.rb
+++ b/app/services/concerns/work_items/widgetable_service.rb
@@ -2,9 +2,29 @@
module WorkItems
module WidgetableService
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def initialize_callbacks!(work_item)
+ @callbacks = work_item.widgets.filter_map do |widget|
+ callback_class = widget.class.try(:callback_class)
+ callback_params = @widget_params[widget.class.api_symbol]
+
+ if new_type_excludes_widget?(widget)
+ callback_params = {} if callback_params.nil?
+ callback_params[:excluded_in_new_type] = true
+ end
+
+ next if callback_class.nil? || callback_params.blank?
+
+ callback_class.new(issuable: work_item, current_user: current_user, params: callback_params)
+ end
+
+ @callbacks.each(&:after_initialize)
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
def execute_widgets(work_item:, callback:, widget_params: {}, service_params: {})
work_item.widgets.each do |widget|
- widget_service(widget, service_params).try(callback, params: widget_params[widget.class.api_symbol])
+ widget_service(widget, service_params).try(callback, params: widget_params[widget.class.api_symbol] || {})
end
end
@@ -26,5 +46,13 @@ module WorkItems
rescue NameError
nil
end
+
+ private
+
+ def new_type_excludes_widget?(widget)
+ return false unless params[:work_item_type]
+
+ params[:work_item_type].widgets.exclude?(widget.class)
+ end
end
end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 6087efce9fd..2ead2e2a113 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -156,7 +156,7 @@ module Git
def enqueue_jira_connect_sync_messages
return unless project.jira_subscription_exists?
- branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractor.has_keys?(branch_name)
+ branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractors::Branch.has_keys?(project, branch_name)
commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha)
if branch_to_sync || commits_to_sync.any?
diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb
index 1d27a5811c7..70834b8a85a 100644
--- a/app/services/import_csv/base_service.rb
+++ b/app/services/import_csv/base_service.rb
@@ -42,6 +42,7 @@ module ImportCsv
def validate_structure!
header_line = csv_data.lines.first
+ raise CSV::MalformedCSVError.new('File is empty, no headers found', 1) if header_line.blank?
validate_headers_presence!(header_line)
detect_col_sep
diff --git a/app/services/issuable/callbacks/base.rb b/app/services/issuable/callbacks/base.rb
new file mode 100644
index 00000000000..3fabce2c949
--- /dev/null
+++ b/app/services/issuable/callbacks/base.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Issuable
+ module Callbacks
+ class Base
+ include Gitlab::Allowable
+
+ def initialize(issuable:, current_user:, params:)
+ @issuable = issuable
+ @current_user = current_user
+ @params = params
+ end
+
+ def after_initialize; end
+ def after_update_commit; end
+ def after_save_commit; end
+
+ private
+
+ attr_reader :issuable, :current_user, :params
+
+ def excluded_in_new_type?
+ params.key?(:excluded_in_new_type) && params[:excluded_in_new_type]
+ end
+
+ def has_permission?(permission)
+ can?(current_user, permission, issuable)
+ end
+ end
+ end
+end
diff --git a/app/services/issuable/callbacks/milestone.rb b/app/services/issuable/callbacks/milestone.rb
new file mode 100644
index 00000000000..7f922c26e07
--- /dev/null
+++ b/app/services/issuable/callbacks/milestone.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Issuable
+ module Callbacks
+ class Milestone < Base
+ ALLOWED_PARAMS = %i[milestone milestone_id skip_milestone_email].freeze
+
+ def after_initialize
+ params[:milestone_id] = nil if excluded_in_new_type?
+ return unless params.key?(:milestone_id) && has_permission?(:"set_#{issuable.to_ability_name}_metadata")
+
+ @old_milestone = issuable.milestone
+
+ if params[:milestone_id].blank? || params[:milestone_id].to_s == IssuableFinder::Params::NONE
+ issuable.milestone = nil
+
+ return
+ end
+
+ resource_group = issuable.project&.group || issuable.try(:namespace)
+ project_ids = [issuable.project&.id].compact
+
+ milestone = MilestonesFinder.new({
+ project_ids: project_ids,
+ group_ids: resource_group&.self_and_ancestors&.select(:id),
+ ids: [params[:milestone_id]]
+ }).execute.first
+
+ issuable.milestone = milestone if milestone
+ end
+
+ def after_update_commit
+ return unless issuable.previous_changes.include?('milestone_id')
+
+ update_usage_data_counters
+ send_milestone_change_notification
+
+ GraphqlTriggers.issuable_milestone_updated(issuable)
+ end
+
+ def after_save_commit
+ return unless issuable.previous_changes.include?('milestone_id')
+
+ invalidate_milestone_counters
+ end
+
+ private
+
+ def invalidate_milestone_counters
+ [@old_milestone, issuable.milestone].compact.each do |milestone|
+ case issuable
+ when Issue
+ ::Milestones::ClosedIssuesCountService.new(milestone).delete_cache
+ ::Milestones::IssuesCountService.new(milestone).delete_cache
+ when MergeRequest
+ ::Milestones::MergeRequestsCountService.new(milestone).delete_cache
+ end
+ end
+ end
+
+ def update_usage_data_counters
+ return unless issuable.is_a?(MergeRequest)
+
+ Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
+ .track_milestone_changed_action(user: current_user)
+ end
+
+ def send_milestone_change_notification
+ return if params[:skip_milestone_email]
+
+ notification_service = NotificationService.new.async
+
+ if issuable.milestone.nil?
+ notification_service.removed_milestone(issuable, current_user)
+ else
+ notification_service.changed_milestone(issuable, issuable.milestone, current_user)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index c630d01cd84..e9312bd6b31 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -3,6 +3,31 @@
class IssuableBaseService < ::BaseContainerService
private
+ def available_callbacks
+ [
+ Issuable::Callbacks::Milestone
+ ].freeze
+ end
+
+ def initialize_callbacks!(issuable)
+ @callbacks = available_callbacks.filter_map do |callback_class|
+ callback_params = params.slice(*callback_class::ALLOWED_PARAMS)
+
+ next if callback_params.empty?
+
+ callback_class.new(issuable: issuable, current_user: current_user, params: callback_params)
+ end
+
+ remove_callback_params
+ @callbacks.each(&:after_initialize)
+ end
+
+ def remove_callback_params
+ available_callbacks.each do |callback_class|
+ callback_class::ALLOWED_PARAMS.each { |p| params.delete(p) }
+ end
+ end
+
def self.constructor_container_arg(value)
# TODO: Dynamically determining the type of a constructor arg based on the class is an antipattern,
# but the root cause is that Epics::BaseService has some issues that inheritance may not be the
@@ -13,14 +38,12 @@ class IssuableBaseService < ::BaseContainerService
{ container: value }
end
- attr_accessor :params, :skip_milestone_email
+ attr_accessor :params
def initialize(container:, current_user: nil, params: {})
# we need to exclude project params since they may come from external requests. project should always
# be passed as part of the service's initializer
super(container: container, current_user: current_user, params: params.except(:project, :project_id))
-
- @skip_milestone_email = @params.delete(:skip_milestone_email)
end
def can_admin_issuable?(issuable)
@@ -36,10 +59,7 @@ class IssuableBaseService < ::BaseContainerService
end
def filter_params(issuable)
- params.delete(:milestone)
-
unless can_set_issuable_metadata?(issuable)
- params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
params.delete(:add_labels)
@@ -63,7 +83,6 @@ class IssuableBaseService < ::BaseContainerService
params.delete(:remove_contacts) unless can?(current_user, :set_issue_crm_contacts, issuable)
filter_assignees(issuable)
- filter_milestone
filter_labels
filter_severity(issuable)
filter_escalation_status(issuable)
@@ -104,19 +123,6 @@ class IssuableBaseService < ::BaseContainerService
can?(user, ability_name, resource)
end
- def filter_milestone
- milestone_id = params[:milestone_id]
- return unless milestone_id
-
- params[:milestone_id] = '' if milestone_id == IssuableFinder::Params::NONE
- groups = project.group&.self_and_ancestors&.select(:id)
-
- milestone =
- Milestone.for_projects_and_groups([project.id], groups).find_by_id(milestone_id)
-
- params[:milestone_id] = '' unless milestone
- end
-
def filter_labels
label_ids_to_filter(:add_label_ids, :add_labels, false)
label_ids_to_filter(:remove_label_ids, :remove_labels, true)
@@ -208,6 +214,8 @@ class IssuableBaseService < ::BaseContainerService
end
def create(issuable, skip_system_notes: false)
+ initialize_callbacks!(issuable)
+
handle_quick_actions(issuable)
filter_params(issuable)
@@ -231,6 +239,8 @@ class IssuableBaseService < ::BaseContainerService
end
if issuable_saved
+ @callbacks.each(&:after_save_commit)
+
create_system_notes(issuable, is_update: false) unless skip_system_notes
handle_changes(issuable, { params: params })
@@ -280,19 +290,22 @@ class IssuableBaseService < ::BaseContainerService
end
def update(issuable)
+ old_associations = associations_before_update(issuable)
+
+ initialize_callbacks!(issuable)
+
prepare_update_params(issuable)
handle_quick_actions(issuable)
filter_params(issuable)
change_additional_attributes(issuable)
- old_associations = associations_before_update(issuable)
assign_requested_labels(issuable)
assign_requested_assignees(issuable)
assign_requested_crm_contacts(issuable)
widget_params = filter_widget_params
- if issuable.changed? || params.present? || widget_params.present?
+ if issuable.changed? || params.present? || widget_params.present? || @callbacks.present?
issuable.assign_attributes(allowed_update_params(params))
if issuable.description_changed?
@@ -309,13 +322,15 @@ class IssuableBaseService < ::BaseContainerService
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
update_project_counters = issuable.project && update_project_counter_caches?(issuable)
- ensure_milestone_available(issuable)
issuable_saved = issuable.with_transaction_returning_status do
transaction_update(issuable, { save_with_touch: should_touch })
end
if issuable_saved
+ @callbacks.each(&:after_update_commit)
+ @callbacks.each(&:after_save_commit)
+
create_system_notes(
issuable, old_labels: old_associations[:labels], old_milestone: old_associations[:milestone]
)
@@ -586,14 +601,6 @@ class IssuableBaseService < ::BaseContainerService
project
end
- # we need to check this because milestone from milestone_id param is displayed on "new" page
- # where private project milestone could leak without this check
- def ensure_milestone_available(issuable)
- return unless issuable.supports_milestone? && issuable.milestone_id.present?
-
- issuable.milestone_id = nil unless issuable.milestone_available?
- end
-
def update_timestamp?(issuable)
issuable.changes.keys != ["relative_position"]
end
diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb
index 5d10eca2979..e996724ebd6 100644
--- a/app/services/issues/after_create_service.rb
+++ b/app/services/issues/after_create_service.rb
@@ -4,7 +4,6 @@ module Issues
class AfterCreateService < Issues::BaseService
def execute(issue)
todo_service.new_issue(issue, current_user)
- delete_milestone_total_issue_counter_cache(issue.milestone)
track_incident_action(current_user, issue, :incident_created)
end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 75ef9f735ab..05090efe260 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -53,6 +53,10 @@ module Issues
params.delete(:issue_type) unless create_issue_type_allowed?(issue, params[:issue_type])
+ if params[:work_item_type].present? && !create_issue_type_allowed?(project, params[:work_item_type].base_type)
+ params.delete(:work_item_type)
+ end
+
moved_issue = params.delete(:moved_issue)
# Setting created_at, updated_at and iid is allowed only for admins and owners or
@@ -103,8 +107,8 @@ module Issues
def execute_hooks(issue, action = 'open', old_associations: {})
issue_data = Gitlab::Lazy.new { hook_data(issue, action, old_associations: old_associations) }
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
- issue.project.execute_hooks(issue_data, hooks_scope)
- issue.project.execute_integrations(issue_data, hooks_scope)
+ issue.namespace.execute_hooks(issue_data, hooks_scope)
+ issue.namespace.execute_integrations(issue_data, hooks_scope)
execute_incident_hooks(issue, issue_data) if issue.incident?
end
@@ -114,29 +118,12 @@ module Issues
def execute_incident_hooks(issue, issue_data)
issue_data[:object_kind] = 'incident'
issue_data[:event_type] = 'incident'
- issue.project.execute_integrations(issue_data, :incident_hooks)
+ issue.namespace.execute_integrations(issue_data, :incident_hooks)
end
def update_project_counter_caches?(issue)
super || issue.confidential_changed?
end
-
- def delete_milestone_closed_issue_counter_cache(milestone)
- return unless milestone
-
- Milestones::ClosedIssuesCountService.new(milestone).delete_cache
- end
-
- def delete_milestone_total_issue_counter_cache(milestone)
- return unless milestone
-
- Milestones::IssuesCountService.new(milestone).delete_cache
- end
-
- override :allowed_create_params
- def allowed_create_params(params)
- super(params).except(:work_item_type_id, :work_item_type)
- end
end
end
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 75bd2b88e86..cb90aca5800 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -4,11 +4,20 @@ module Issues
class BuildService < Issues::BaseService
include ResolveDiscussions
- def execute
+ def execute(initialize_callbacks: true)
filter_resolve_discussion_params
- @issue = model_klass.new(issue_params.merge(project: project)).tap do |issue|
- ensure_milestone_available(issue)
+ container_param = case container
+ when Project
+ { project: project }
+ when Namespaces::ProjectNamespace
+ { project: container.project }
+ else
+ { namespace: container }
+ end
+
+ @issue = model_klass.new(issue_params.merge(container_param)).tap do |issue|
+ initialize_callbacks!(issue) if initialize_callbacks
end
end
@@ -61,18 +70,6 @@ module Issues
def issue_params
@issue_params ||= build_issue_params
-
- if @issue_params[:work_item_type].present?
- @issue_params[:issue_type] = @issue_params[:work_item_type].base_type
- else
- # If :issue_type is nil then params[:issue_type] was either nil
- # or not permitted. Either way, the :issue_type will default
- # to the column default of `issue`. And that means we need to
- # ensure the work_item_type_id is set
- @issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type])
- end
-
- @issue_params
end
private
@@ -89,11 +86,7 @@ module Issues
:confidential
]
- 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
-
- 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)
+ public_issue_params << :issue_type if create_issue_type_allowed?(container, params[:issue_type])
params.slice(*public_issue_params)
end
@@ -104,10 +97,6 @@ module Issues
.merge(public_params)
.with_indifferent_access
end
-
- def get_work_item_type_id(issue_type = :issue)
- find_work_item_type_id(issue_type)
- end
end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 4f6a859e20e..87e27ef2763 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -43,7 +43,7 @@ module Issues
Onboarding::ProgressService.new(project.namespace).execute(action: :issue_auto_closed)
end
- delete_milestone_closed_issue_counter_cache(issue.milestone)
+ Milestones::ClosedIssuesCountService.new(issue.milestone).delete_cache if issue.milestone
end
issue
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index ec5f9ea8167..2a3f0abf4cb 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -22,9 +22,13 @@ module Issues
end
def execute(skip_system_notes: false)
- return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, @project)
+ return error(_('Operation not allowed'), 403) unless @current_user.can?(authorization_action, container)
+
+ # We should not initialize the callback classes during the build service execution because these will be
+ # initialized when we call #create below
+ @issue = @build_service.execute(initialize_callbacks: false)
+ set_work_item_type(@issue)
- @issue = @build_service.execute
# issue_type is set in BuildService, so we can delete it from params, in later phase
# it can be set also from quick actions - in that case work_item_id is synced later again
params.delete(:issue_type)
@@ -60,7 +64,8 @@ module Issues
issue.run_after_commit do
NewIssueWorker.perform_async(issue.id, user.id, issue.class.to_s)
Issues::PlacementWorker.perform_async(nil, issue.project_id)
- Onboarding::IssueCreatedWorker.perform_async(issue.project.namespace_id)
+ # issue.namespace_id can point to either a project through project namespace or a group.
+ Onboarding::IssueCreatedWorker.perform_async(issue.namespace_id)
end
end
@@ -72,7 +77,6 @@ module Issues
handle_escalation_status_change(issue)
create_timeline_event(issue)
try_to_associate_contacts(issue)
- change_additional_attributes(issue)
super
end
@@ -89,6 +93,7 @@ module Issues
return if issue.assignees == old_assignees
create_assignee_note(issue, old_assignees)
+ Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: issue, old_assignees: old_assignees).record
end
def resolve_discussions_with_issue(issue)
@@ -101,12 +106,24 @@ module Issues
private
- def handle_quick_actions(issue)
- # Do not handle quick actions unless the work item is the default Issue.
- # The available quick actions for a work item depend on its type and widgets.
- return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue)
+ def set_work_item_type(issue)
+ work_item_type = if params[:work_item_type_id].present?
+ params.delete(:work_item_type)
+ WorkItems::Type.find_by(id: params.delete(:work_item_type_id)) # rubocop: disable CodeReuse/ActiveRecord
+ else
+ params.delete(:work_item_type)
+ end
+
+ base_type = work_item_type&.base_type
+ if create_issue_type_allowed?(container, base_type)
+ issue.work_item_type = work_item_type
+ # Up to this point issue_type might be set to the default, so we need to sync if a work item type is provided
+ issue.issue_type = work_item_type.base_type
+ end
- super
+ # If no work item type was provided, we need to set it to whatever issue_type was up to this point,
+ # and that includes the column default
+ issue.work_item_type = WorkItems::Type.default_by_type(issue.issue_type)
end
def authorization_action
@@ -140,15 +157,6 @@ module Issues
set_crm_contacts(issue, contacts)
end
-
- override :change_additional_attributes
- def change_additional_attributes(issue)
- super
-
- # issue_type can be still set through quick actions, in that case
- # we have to make sure to re-sync work_item_type with it
- issue.work_item_type_id = find_work_item_type_id(params[:issue_type]) if params[:issue_type]
- end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index f4f81e9455a..3330c462947 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -13,7 +13,7 @@ module Issues
execute_hooks(issue, 'reopen')
invalidate_cache_counts(issue, users: issue.assignees)
issue.update_project_counter_caches
- delete_milestone_closed_issue_counter_cache(issue.milestone)
+ Milestones::ClosedIssuesCountService.new(issue.milestone).delete_cache if issue.milestone
track_incident_action(current_user, issue, :incident_reopened)
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 322065c5b7c..2cf3f36eef1 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -64,7 +64,6 @@ module Issues
handle_assignee_changes(issue, old_assignees)
handle_confidential_change(issue)
handle_added_labels(issue, old_labels)
- handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users)
handle_severity_change(issue, old_severity)
handle_escalation_status_change(issue)
@@ -76,6 +75,7 @@ module Issues
return if issue.assignees == old_assignees
create_assignee_note(issue, old_assignees)
+ Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: issue, old_assignees: old_assignees).record
notification_service.async.reassigned_issue(issue, current_user, old_assignees)
todo_service.reassigned_assignable(issue, current_user, old_assignees)
track_incident_action(current_user, issue, :incident_assigned)
@@ -116,14 +116,6 @@ module Issues
attr_reader :spam_params
- def handle_quick_actions(issue)
- # Do not handle quick actions unless the work item is the default Issue.
- # The available quick actions for a work item depend on its type and widgets.
- return unless issue.work_item_type.default_issue?
-
- super
- end
-
def handle_date_changes(issue)
return unless issue.previous_changes.slice('due_date', 'start_date').any?
@@ -166,35 +158,6 @@ module Issues
end
end
- def handle_milestone_change(issue)
- return unless issue.previous_changes.include?('milestone_id')
-
- invalidate_milestone_issue_counters(issue)
- send_milestone_change_notification(issue)
- GraphqlTriggers.issuable_milestone_updated(issue)
- end
-
- def invalidate_milestone_issue_counters(issue)
- issue.previous_changes['milestone_id'].each do |milestone_id|
- next unless milestone_id
-
- milestone = Milestone.find_by_id(milestone_id)
-
- delete_milestone_closed_issue_counter_cache(milestone)
- delete_milestone_total_issue_counter_cache(milestone)
- end
- end
-
- def send_milestone_change_notification(issue)
- return if skip_milestone_email
-
- if issue.milestone.nil?
- notification_service.async.removed_milestone(issue, current_user)
- else
- notification_service.async.changed_milestone(issue, issue.milestone, current_user)
- end
- end
-
def handle_added_mentions(issue, old_mentioned_users)
added_mentions = issue.mentioned_users(current_user) - old_mentioned_users
@@ -220,7 +183,7 @@ module Issues
end
def do_handle_issue_type_change(issue)
- SystemNoteService.change_issue_type(issue, current_user)
+ SystemNoteService.change_issue_type(issue, current_user, issue.issue_type_before_last_save)
::IncidentManagement::IssuableEscalationStatuses::CreateService.new(issue).execute if issue.supports_escalation?
end
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index 3ce8390d07d..699c5b94c53 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -13,8 +13,29 @@ module Members
Gitlab::Access.sym_options_with_owner
end
- def add_members( # rubocop:disable Metrics/ParameterLists
- source,
+ # Add members to sources with passed access option
+ #
+ # access can be an integer representing a access code
+ # or symbol like :maintainer representing role
+ #
+ # Ex.
+ # add_members(
+ # sources,
+ # user_ids,
+ # Member::MAINTAINER
+ # )
+ #
+ # add_members(
+ # sources,
+ # user_ids,
+ # :maintainer
+ # )
+ #
+ # @param sources [Group, Project, Array<Group>, Array<Project>, Group::ActiveRecord_Relation,
+ # Project::ActiveRecord_Relation] - Can't be an array of source ids because we don't know the type of source.
+ # @return Array<Member>
+ def add_members(
+ sources,
invitees,
access_level,
current_user: nil,
@@ -22,52 +43,58 @@ module Members
tasks_to_be_done: [],
tasks_project_id: nil,
ldap: nil
- )
+ ) # rubocop:disable Metrics/ParameterLists
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, invitees)
+ sources = Array.wrap(sources) if sources.is_a?(ApplicationRecord) # For single source
Member.transaction do
- 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
- }
-
- members = emails.map do |email|
- new(invitee: email, builder: InviteMemberBuilder, **common_arguments).execute
- end
+ sources.flat_map do |source|
+ # If this user is attempting to manage Owner members and doesn't have permission, do not allow
+ next [] if managing_owners?(current_user, access_level) && cannot_manage_owners?(source, current_user)
+
+ emails, users, existing_members = parse_users_list(source, invitees)
+
+ 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
+ }
+
+ 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 += users.map do |user|
+ new(invitee: user, **common_arguments).execute
+ end
- members
+ members
+ end
end
end
- def add_member( # rubocop:disable Metrics/ParameterLists
+ def add_member(
source,
invitee,
access_level,
current_user: nil,
expires_at: nil,
ldap: nil
- )
- add_members(source,
- [invitee],
- access_level,
- current_user: current_user,
- expires_at: expires_at,
- ldap: ldap).first
+ ) # rubocop:disable Metrics/ParameterLists
+ add_members(
+ source,
+ [invitee],
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at,
+ ldap: ldap
+ ).first
end
private
@@ -217,8 +244,7 @@ module Members
end
def approve_request
- ::Members::ApproveAccessRequestService.new(current_user,
- access_level: access_level)
+ ::Members::ApproveAccessRequestService.new(current_user, access_level: access_level)
.execute(
member,
skip_authorization: ldap || skip_authorization?,
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 11251e56ee3..f3b1c663fa2 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -39,8 +39,6 @@ module MergeRequests
Gitlab::UsageDataCounters::MergeRequestCounter.count(:create)
link_lfs_objects(merge_request)
-
- delete_milestone_total_merge_requests_counter_cache(merge_request.milestone)
end
def link_lfs_objects(merge_request)
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 97ca96043fb..0d59e442dce 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -263,12 +263,6 @@ module MergeRequests
merge_request.update(merge_error: message) if save_message_on_model
end
- def delete_milestone_total_merge_requests_counter_cache(milestone)
- return unless milestone
-
- Milestones::MergeRequestsCountService.new(milestone).delete_cache
- end
-
def trigger_merge_request_reviewers_updated(merge_request)
GraphqlTriggers.merge_request_reviewers_updated(merge_request)
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index b9a681f29db..d5b109a764d 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -16,6 +16,8 @@ module MergeRequests
merge_request.source_project = find_source_project
merge_request.target_project = find_target_project
+ initialize_callbacks!(merge_request)
+
process_params
merge_request.compare_commits = []
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
index 51be4690af4..835d56a7070 100644
--- a/app/services/merge_requests/handle_assignees_change_service.rb
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -15,6 +15,7 @@ module MergeRequests
def execute(merge_request, old_assignees, options = {})
create_assignee_note(merge_request, old_assignees)
notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees.to_a)
+ Gitlab::ResourceEvents::AssignmentEventRecorder.new(parent: merge_request, old_assignees: old_assignees).record
todo_service.reassigned_assignable(merge_request, current_user, old_assignees)
new_assignees = merge_request.assignees - old_assignees
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 792f1728b88..6248baea4ea 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -63,3 +63,5 @@ module MergeRequests
end
end
end
+
+::MergeRequests::RebaseService.prepend_mod
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 255d96f4969..642cffa6c0d 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -36,7 +36,6 @@ module MergeRequests
end
handle_target_branch_change(merge_request)
- handle_milestone_change(merge_request)
handle_draft_status_change(merge_request, changed_fields)
track_title_and_desc_edits(changed_fields)
@@ -204,25 +203,6 @@ module MergeRequests
)
end
- def handle_milestone_change(merge_request)
- return if skip_milestone_email
-
- return unless merge_request.previous_changes.include?('milestone_id')
-
- merge_request_activity_counter.track_milestone_changed_action(user: current_user)
-
- previous_milestone = Milestone.find_by_id(merge_request.previous_changes['milestone_id'].first)
- delete_milestone_total_merge_requests_counter_cache(previous_milestone)
-
- if merge_request.milestone.nil?
- notification_service.async.removed_milestone(merge_request, current_user)
- else
- notification_service.async.changed_milestone(merge_request, merge_request.milestone, current_user)
-
- delete_milestone_total_merge_requests_counter_cache(merge_request.milestone)
- end
- end
-
def create_branch_change_note(issuable, branch_type, event_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type, event_type,
diff --git a/app/services/metrics/global_metrics_update_service.rb b/app/services/metrics/global_metrics_update_service.rb
new file mode 100644
index 00000000000..356de58ba2e
--- /dev/null
+++ b/app/services/metrics/global_metrics_update_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Metrics
+ # Update metrics regarding GitLab instance wide
+ #
+ # Anything that is not specific to a machine, process, request or any other context
+ # can be updated from this services.
+ #
+ # Examples of metrics that qualify:
+ # * Global counters (instance users, instance projects...)
+ # * State of settings stored in the database (whether a feature is active or not, tuning values...)
+ #
+ class GlobalMetricsUpdateService
+ def execute
+ return unless ::Gitlab::Metrics.prometheus_metrics_enabled?
+
+ maintenance_mode_metric.set({}, (::Gitlab.maintenance_mode? ? 1 : 0))
+ end
+
+ def maintenance_mode_metric
+ ::Gitlab::Metrics.gauge(:gitlab_maintenance_mode, 'Is GitLab Maintenance Mode enabled?')
+ end
+ end
+end
diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb
index f1fd93d7816..e2978c16b2f 100644
--- a/app/services/ml/experiment_tracking/candidate_repository.rb
+++ b/app/services/ml/experiment_tracking/candidate_repository.rb
@@ -10,14 +10,15 @@ module Ml
@user = user
end
- def by_iid(iid)
- ::Ml::Candidate.with_project_id_and_iid(project.id, iid)
+ def by_eid(eid)
+ ::Ml::Candidate.with_project_id_and_eid(project.id, eid)
end
def create!(experiment, start_time, tags = nil, name = nil)
candidate = experiment.candidates.create!(
user: user,
name: candidate_name(name, tags),
+ project: project,
start_time: start_time || 0
)
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 8898f7feb17..39d0d0a7923 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -164,19 +164,17 @@ module Notes
track_note_creation_in_ipynb(note)
track_note_creation_visual_review(note)
- if Feature.enabled?(:route_hll_to_snowplow_phase4, project&.namespace) && note.for_commit?
- metric_key_path = 'counts.commit_comment'
-
- Gitlab::Tracking.event(
- 'Notes::CreateService',
- 'create_commit_comment',
- project: project,
- namespace: project&.namespace,
- user: user,
- label: metric_key_path,
- context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context]
- )
- end
+ metric_key_path = 'counts.commit_comment'
+
+ Gitlab::Tracking.event(
+ 'Notes::CreateService',
+ 'create_commit_comment',
+ project: project,
+ namespace: project&.namespace,
+ user: user,
+ label: metric_key_path,
+ context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_key_path).to_context]
+ )
end
def tracking_data_for(note)
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 47bc36fce70..b93b44ce797 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -492,6 +492,18 @@ class NotificationService
mailer.member_access_denied_email(member.real_source_type, member.source_id, member.user_id).deliver_later
end
+ def decline_invite(member)
+ # Must always send, regardless of project/namespace configuration since it's a
+ # response to the user's action.
+
+ mailer.member_invite_declined_email(
+ member.real_source_type,
+ member.source.id,
+ member.invite_email,
+ member.created_by_id
+ ).deliver_later
+ end
+
# Project invite
def invite_project_member(project_member, token)
return true unless project_member.notifiable?(:subscription)
@@ -505,18 +517,6 @@ class NotificationService
mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later
end
- def decline_project_invite(project_member)
- # Must always send, regardless of project/namespace configuration since it's a
- # response to the user's action.
-
- mailer.member_invite_declined_email(
- project_member.real_source_type,
- project_member.project.id,
- project_member.invite_email,
- project_member.created_by_id
- ).deliver_later
- end
-
def new_project_member(project_member)
return true unless project_member.notifiable?(:mention, skip_read_ability: true)
@@ -542,18 +542,6 @@ class NotificationService
mailer.member_invite_accepted_email(group_member.real_source_type, group_member.id).deliver_later
end
- def decline_group_invite(group_member)
- # Must always send, regardless of project/namespace configuration since it's a
- # response to the user's action.
-
- mailer.member_invite_declined_email(
- group_member.real_source_type,
- group_member.group.id,
- group_member.invite_email,
- group_member.created_by_id
- ).deliver_later
- end
-
def new_group_member(group_member)
return true unless group_member.notifiable?(:mention)
@@ -810,6 +798,10 @@ class NotificationService
end
end
+ def new_achievement_email(user, achievement)
+ mailer.new_achievement_email(user, achievement)
+ end
+
protected
def new_resource_email(target, current_user, method)
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
index 82c4292fca8..8eac30f0022 100644
--- a/app/services/packages/create_event_service.rb
+++ b/app/services/packages/create_event_service.rb
@@ -10,15 +10,6 @@ module Packages
::Packages::Event.counters_for(event_scope, event_name, originator_type).each do |event_name|
::Gitlab::UsageDataCounters::PackageEventCounter.count(event_name)
end
-
- if Feature.enabled?(:collect_package_events) && Gitlab::Database.read_write?
- ::Packages::Event.create!(
- event_type: event_name,
- originator: current_user&.id,
- originator_type: originator_type,
- event_scope: event_scope
- )
- end
end
def originator_type
diff --git a/app/services/packages/debian/find_or_create_incoming_service.rb b/app/services/packages/debian/find_or_create_incoming_service.rb
index 2d29ba5f3c3..fae87f09d41 100644
--- a/app/services/packages/debian/find_or_create_incoming_service.rb
+++ b/app/services/packages/debian/find_or_create_incoming_service.rb
@@ -4,7 +4,7 @@ module Packages
module Debian
class FindOrCreateIncomingService < ::Packages::CreatePackageService
def execute
- find_or_create_package!(:debian, name: 'incoming', version: nil)
+ find_or_create_package!(:debian, name: ::Packages::Debian::INCOMING_PACKAGE_NAME, version: nil)
end
end
end
diff --git a/app/services/packages/debian/find_or_create_package_service.rb b/app/services/packages/debian/find_or_create_package_service.rb
index cb765e956e7..a9481504d2b 100644
--- a/app/services/packages/debian/find_or_create_package_service.rb
+++ b/app/services/packages/debian/find_or_create_package_service.rb
@@ -6,13 +6,19 @@ module Packages
include Gitlab::Utils::StrongMemoize
def execute
- package = project.packages
- .debian
- .with_name(params[:name])
- .with_version(params[:version])
- .with_debian_codename_or_suite(params[:distribution_name])
- .not_pending_destruction
- .first
+ packages = project.packages
+ .existing_debian_packages_with(name: params[:name], version: params[:version])
+
+ package = packages.with_debian_codename_or_suite(params[:distribution_name]).first
+
+ unless package
+ package_in_other_distribution = packages.first
+
+ if package_in_other_distribution
+ raise ArgumentError, "Debian package #{params[:name]} #{params[:version]} exists " \
+ "in distribution #{package_in_other_distribution.debian_distribution.codename}"
+ end
+ end
package ||= create_package!(
:debian,
@@ -25,13 +31,12 @@ module Packages
private
def distribution
- strong_memoize(:distribution) do
- Packages::Debian::DistributionsFinder.new(
- project,
- codename_or_suite: params[:distribution_name]
- ).execute.last!
- end
+ Packages::Debian::DistributionsFinder.new(
+ project,
+ codename_or_suite: params[:distribution_name]
+ ).execute.last!
end
+ strong_memoize_attr :distribution
end
end
end
diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb
index ee43fe208c9..0740da6c07e 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -196,7 +196,7 @@ module Packages
file: CarrierWaveStringFile.new(content),
file_md5: file_md5,
file_sha256: file_sha256,
- size: content.size
+ size: content.bytesize
)
end
diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb
index dc16d38902b..f4fcd3a563c 100644
--- a/app/services/packages/debian/process_package_file_service.rb
+++ b/app/services/packages/debian/process_package_file_service.rb
@@ -6,7 +6,7 @@ module Packages
include ExclusiveLeaseGuard
include Gitlab::Utils::StrongMemoize
- SOURCE_FIELD_SPLIT_REGEX = /[ ()]/.freeze
+ SOURCE_FIELD_SPLIT_REGEX = /[ ()]/
# used by ExclusiveLeaseGuard
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
@@ -54,14 +54,21 @@ module Packages
strong_memoize_attr :file_metadata
def package
- package = temp_package.project
- .packages
- .debian
- .with_name(package_name)
- .with_version(package_version)
- .with_debian_codename_or_suite(@distribution_name)
- .not_pending_destruction
- .last
+ packages = temp_package.project
+ .packages
+ .existing_debian_packages_with(name: package_name, version: package_version)
+ package = packages.with_debian_codename_or_suite(@distribution_name)
+ .first
+
+ unless package
+ package_in_other_distribution = packages.first
+
+ if package_in_other_distribution
+ raise ArgumentError, "Debian package #{package_name} #{package_version} exists " \
+ "in distribution #{package_in_other_distribution.debian_distribution.codename}"
+ end
+ end
+
package || temp_package
end
strong_memoize_attr :package
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index dd074f7472b..33a7736dc95 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -3,15 +3,27 @@ module Packages
module Npm
class CreatePackageService < ::Packages::CreatePackageService
include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
- PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename].freeze
+ PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText].freeze
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i
def execute
return error('Version is empty.', 400) if version.blank?
return error('Package already exists.', 403) if current_package_exists?
return error('File is too large.', 400) if file_size_exceeded?
- ApplicationRecord.transaction { create_npm_package! }
+ if Feature.enabled?(:npm_obtain_lease_to_create_package, project)
+ package = try_obtain_lease do
+ ApplicationRecord.transaction { create_npm_package! }
+ end
+
+ return error('Could not obtain package lease.', 400) unless package
+
+ package
+ else
+ ApplicationRecord.transaction { create_npm_package! }
+ end
end
private
@@ -103,6 +115,16 @@ module Packages
def file_size_exceeded?
project.actual_limits.exceeded?(:npm_max_file_size, calculated_package_file_size)
end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ "packages:npm:create_package_service:packages:#{project.id}_#{name}_#{version}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
end
end
end
diff --git a/app/services/packages/npm/deprecate_package_service.rb b/app/services/packages/npm/deprecate_package_service.rb
new file mode 100644
index 00000000000..2633e9f877c
--- /dev/null
+++ b/app/services/packages/npm/deprecate_package_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class DeprecatePackageService < BaseService
+ Deprecated = Struct.new(:package_id, :message)
+ BATCH_SIZE = 50
+
+ def initialize(project, params)
+ super(project, nil, params)
+ end
+
+ def execute(async: false)
+ return ::Packages::Npm::DeprecatePackageWorker.perform_async(project.id, filtered_params) if async
+
+ packages.select(:id, :version).each_batch(of: BATCH_SIZE) do |relation|
+ deprecated_metadatum = handle_batch(relation)
+ update_metadatum(deprecated_metadatum)
+ end
+ end
+
+ private
+
+ # To avoid passing the whole metadata to the worker
+ def filtered_params
+ {
+ package_name: params[:package_name],
+ versions: params[:versions].transform_values { |version| version.slice(:deprecated) }
+ }
+ end
+
+ def packages
+ ::Packages::Npm::PackageFinder
+ .new(params['package_name'], project: project, last_of_each_version: false)
+ .execute
+ end
+
+ def handle_batch(relation)
+ relation
+ .preload_npm_metadatum
+ .filter_map { |package| deprecate(package) }
+ end
+
+ def deprecate(package)
+ deprecation_message = params.dig('versions', package.version, 'deprecated')
+ return if deprecation_message.nil?
+
+ npm_metadatum = package.npm_metadatum
+ return if identical?(npm_metadatum.package_json['deprecated'], deprecation_message)
+
+ Deprecated.new(npm_metadatum.package_id, deprecation_message)
+ end
+
+ def identical?(package_json_deprecated, deprecation_message)
+ package_json_deprecated == deprecation_message ||
+ (package_json_deprecated.nil? && deprecation_message.empty?)
+ end
+
+ def update_metadatum(deprecated_metadatum)
+ return if deprecated_metadatum.empty?
+
+ deprecation_message = deprecated_metadatum.first.message
+
+ ::Packages::Npm::Metadatum
+ .package_id_in(deprecated_metadatum.map(&:package_id))
+ .update_all(update_clause(deprecation_message))
+ end
+
+ def update_clause(deprecation_message)
+ if deprecation_message.empty?
+ "package_json = package_json - 'deprecated'"
+ else
+ ["package_json = jsonb_set(package_json, '{deprecated}', ?)", deprecation_message.to_json]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/npm/generate_metadata_service.rb b/app/services/packages/npm/generate_metadata_service.rb
new file mode 100644
index 00000000000..800c3ce19b4
--- /dev/null
+++ b/app/services/packages/npm/generate_metadata_service.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class GenerateMetadataService
+ include API::Helpers::RelatedResourcesHelpers
+
+ # Allowed fields are those defined in the abbreviated form
+ # defined here: https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
+ # except: name, version, dist, dependencies and xDependencies. Those are generated by this service.
+ PACKAGE_JSON_ALLOWED_FIELDS = %w[deprecated bin directories dist engines _hasShrinkwrap].freeze
+
+ def initialize(name, packages)
+ @name = name
+ @packages = packages
+ end
+
+ def execute(only_dist_tags: false)
+ ServiceResponse.success(payload: metadata(only_dist_tags))
+ end
+
+ private
+
+ attr_reader :name, :packages
+
+ def metadata(only_dist_tags)
+ result = { dist_tags: dist_tags }
+
+ unless only_dist_tags
+ result[:name] = name
+ result[:versions] = versions
+ end
+
+ result
+ end
+
+ def versions
+ package_versions = {}
+
+ packages.each_batch do |relation|
+ batched_packages = relation.including_dependency_links
+ .preload_files
+ .preload_npm_metadatum
+
+ batched_packages.each do |package|
+ package_file = package.installable_package_files.last
+
+ next unless package_file
+
+ package_versions[package.version] = build_package_version(package, package_file)
+ end
+ end
+
+ package_versions
+ end
+
+ def dist_tags
+ build_package_tags.tap { |t| t['latest'] ||= sorted_versions.last }
+ end
+
+ def build_package_tags
+ package_tags.to_h { |tag| [tag.name, tag.package.version] }
+ end
+
+ def build_package_version(package, package_file)
+ abbreviated_package_json(package).merge(
+ name: package.name,
+ version: package.version,
+ dist: {
+ shasum: package_file.file_sha1,
+ tarball: tarball_url(package, package_file)
+ }
+ ).tap do |package_version|
+ package_version.merge!(build_package_dependencies(package))
+ end
+ end
+
+ def tarball_url(package, package_file)
+ expose_url api_v4_projects_packages_npm_package_name___file_name_path(
+ { id: package.project_id, package_name: package.name, file_name: package_file.file_name }, true
+ )
+ end
+
+ def build_package_dependencies(package)
+ dependencies = Hash.new { |h, key| h[key] = {} }
+
+ package.dependency_links.each do |dependency_link|
+ dependency = dependency_link.dependency
+ dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern
+ end
+
+ dependencies
+ end
+
+ def sorted_versions
+ versions = packages.pluck_versions.compact
+ VersionSorter.sort(versions)
+ end
+
+ def package_tags
+ Packages::Tag.for_package_ids(packages.last_of_each_version_ids)
+ .preload_package
+ end
+
+ def abbreviated_package_json(package)
+ json = package.npm_metadatum&.package_json || {}
+ json.slice(*PACKAGE_JSON_ALLOWED_FIELDS)
+ end
+ end
+ end
+end
diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb
deleted file mode 100644
index 1ea16040655..00000000000
--- a/app/services/projects/blame_service.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-# frozen_string_literal: true
-
-# Service class to correctly initialize Gitlab::Blame and Kaminari pagination
-# objects
-module Projects
- class BlameService
- PER_PAGE = 1000
- STREAMING_FIRST_PAGE_SIZE = 200
- STREAMING_PER_PAGE = 2000
-
- def initialize(blob, commit, params)
- @blob = blob
- @commit = commit
- @streaming_enabled = streaming_state(params)
- @pagination_enabled = pagination_state(params)
- @page = extract_page(params)
- @params = params
- end
-
- attr_reader :page, :streaming_enabled
-
- def blame
- Gitlab::Blame.new(blob, commit, range: blame_range)
- end
-
- def pagination
- return unless pagination_enabled
-
- Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page)
- .tap { |pagination| pagination.max_paginates_per(per_page) }
- .page(page)
- end
-
- def per_page
- streaming_enabled ? STREAMING_PER_PAGE : PER_PAGE
- end
-
- def total_pages
- total = (blob_lines_count.to_f / per_page).ceil
- return total unless streaming_enabled
-
- ([blob_lines_count - STREAMING_FIRST_PAGE_SIZE, 0].max.to_f / per_page).ceil + 1
- end
-
- def total_extra_pages
- [total_pages - 1, 0].max
- end
-
- def streaming_possible
- Feature.enabled?(:blame_page_streaming, commit.project)
- end
-
- private
-
- attr_reader :blob, :commit, :pagination_enabled
-
- def blame_range
- return unless pagination_enabled || streaming_enabled
-
- first_line = (page - 1) * per_page + 1
-
- if streaming_enabled
- return 1..STREAMING_FIRST_PAGE_SIZE if page == 1
-
- first_line = STREAMING_FIRST_PAGE_SIZE + (page - 2) * per_page + 1
- end
-
- last_line = (first_line + per_page).to_i - 1
-
- first_line..last_line
- end
-
- def extract_page(params)
- page = params.fetch(:page, 1).to_i
-
- return 1 if page < 1 || overlimit?(page)
-
- page
- end
-
- def streaming_state(params)
- return false unless streaming_possible
-
- Gitlab::Utils.to_boolean(params[:streaming], default: false)
- end
-
- def pagination_state(params)
- return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false)
-
- Feature.enabled?(:blame_page_pagination, commit.project)
- end
-
- def overlimit?(page)
- page > total_pages
- end
-
- def blob_lines_count
- @blob_lines_count ||= blob.data.lines.count
- end
- end
-end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 94cc4700a49..cbea44d6aff 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -144,8 +144,10 @@ module Projects
# completes), and any other affected users in the background
def setup_authorizations
if @project.group
- group_access_level = @project.group.max_member_access_for_user(current_user,
- only_concrete_membership: true)
+ group_access_level = @project.group.max_member_access_for_user(
+ current_user,
+ only_concrete_membership: true
+ )
if group_access_level > GroupMember::NO_ACCESS
current_user.project_authorizations.safe_find_or_create_by!(
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 5fce816064b..aace8846afc 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -92,8 +92,10 @@ module Projects
def build_fork_network_member(fork_to_project)
if allowed_fork?
- fork_to_project.build_fork_network_member(forked_from_project: @project,
- fork_network: fork_network)
+ fork_to_project.build_fork_network_member(
+ forked_from_project: @project,
+ fork_network: fork_network
+ )
else
fork_to_project.errors.add(:forked_from_project_id, 'is forbidden')
end
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index 349d4d367be..6241a3e144f 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -9,7 +9,7 @@ module Projects
include Gitlab::ShellAdapter
attr_reader :old_disk_path, :new_disk_path, :old_storage_version,
- :logger, :move_wiki, :move_design
+ :logger, :move_wiki, :move_design
def initialize(project:, old_disk_path:, logger: nil)
@project = project
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index e6ccae0a22b..ceab7098b32 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -36,8 +36,11 @@ module Projects
)
message = Projects::ImportErrorFilter.filter_message(e.message)
- error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") %
- { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message })
+ error(
+ s_(
+ "ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}"
+ ) % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }
+ )
end
protected
diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
index f7de7f98768..a87996b70e8 100644
--- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
@@ -51,9 +51,7 @@ module Projects
end
def download_links_for(oids)
- response = Gitlab::HTTP.post(remote_uri,
- body: request_body(oids),
- headers: headers)
+ response = Gitlab::HTTP.post(remote_uri, body: request_body(oids), headers: headers)
raise DownloadLinksRequestEntityTooLargeError if response.request_entity_too_large?
raise DownloadLinksError, response.message unless response.success?
@@ -78,10 +76,12 @@ module Projects
raise DownloadLinkNotFound unless link
- link_list << LfsDownloadObject.new(oid: entry['oid'],
- size: entry['size'],
- headers: headers,
- link: add_credentials(link))
+ link_list << LfsDownloadObject.new(
+ oid: entry['oid'],
+ size: entry['size'],
+ headers: headers,
+ link: add_credentials(link)
+ )
rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError
log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.")
end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index d3fed43363c..aff258c418b 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -45,11 +45,13 @@ module Projects
duration = ::Gitlab::Metrics::System.monotonic_time - start_time
- Gitlab::AppJsonLogger.info(class: self.class.name,
- namespace_id: source_project.namespace_id,
- project_id: source_project.id,
- duration_s: duration.to_f,
- error: exception.class.name)
+ Gitlab::AppJsonLogger.info(
+ class: self.class.name,
+ namespace_id: source_project.namespace_id,
+ project_id: source_project.id,
+ duration_s: duration.to_f,
+ error: exception.class.name
+ )
end
def move_relationships_between(source_project, target_project)
@@ -83,9 +85,11 @@ module Projects
# we won't be able to query the database (only through its cached data),
# for its former relationships. That's why we're adding it to the network
# as a fork of the target project
- ForkNetworkMember.create!(fork_network: fork_network,
- project: source_project,
- forked_from_project: @project)
+ ForkNetworkMember.create!(
+ fork_network: fork_network,
+ project: source_project,
+ forked_from_project: @project
+ )
end
def remove_source_project_from_fork_network(source_project)
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 0fadd75669e..403f645392c 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -90,7 +90,8 @@ module Projects
file: file,
file_count: deployment_update.entries_count,
file_sha256: sha256,
- ci_build_id: build.id
+ ci_build_id: build.id,
+ root_directory: build.options[:publish]
)
break if deployment.size != file.size || deployment.file.size != file.size
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index aca6fa91eb1..b048ec128d8 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -75,12 +75,14 @@ module Projects
end
if message.present?
- Gitlab::AppJsonLogger.info(message: "Error synching remote mirror",
- project_id: project.id,
- project_path: project.full_path,
- remote_mirror_id: remote_mirror.id,
- lfs_sync_failed: lfs_sync_failed,
- divergent_ref_list: response.divergent_refs)
+ Gitlab::AppJsonLogger.info(
+ message: "Error synching remote mirror",
+ project_id: project.id,
+ project_path: project.full_path,
+ remote_mirror_id: remote_mirror.id,
+ lfs_sync_failed: lfs_sync_failed,
+ divergent_ref_list: response.divergent_refs
+ )
end
[failed, message]
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index bea994e8bb2..7f25ab5883f 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -51,7 +51,7 @@ module Projects
private
def add_pages_unique_domain
- if Feature.disabled?(:pages_unique_domain)
+ if Feature.disabled?(:pages_unique_domain, project)
params[:project_setting_attributes]&.delete(:pages_unique_domain_enabled)
return
@@ -120,6 +120,8 @@ module Projects
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project)
+
+ params.delete(:runner_registration_enabled) if Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project')
end
def after_update
diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb
index ac02bf25617..cb2977796d7 100644
--- a/app/services/protected_branches/cache_service.rb
+++ b/app/services/protected_branches/cache_service.rb
@@ -74,20 +74,24 @@ module ProtectedBranches
def redis_key
group = project_or_group.is_a?(Group) ? project_or_group : project_or_group.group
- @redis_key ||= if Feature.enabled?(:group_protected_branches, group)
+ @redis_key ||= if allow_protected_branches_for_group?(group)
[CACHE_ROOT_KEY, project_or_group.class.name, project_or_group.id].join(':')
else
[CACHE_ROOT_KEY, project_or_group.id].join(':')
end
end
+ def allow_protected_branches_for_group?(group)
+ Feature.enabled?(:group_protected_branches, group) ||
+ Feature.enabled?(:allow_protected_branches_for_group, group)
+ end
+
def metrics
@metrics ||= Gitlab::Cache::Metrics.new(cache_metadata)
end
def cache_metadata
Gitlab::Cache::Metadata.new(
- caller_id: Gitlab::ApplicationContext.current_context_attribute(:caller_id),
cache_identifier: "#{self.class}#fetch",
feature_category: :source_code_management,
backing_resource: :cpu
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index a3289f9e552..e5883ca06f4 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -18,6 +18,12 @@ module Releases
return tag unless tag.is_a?(Gitlab::Git::Tag)
+ if project.catalog_resource
+ response = Ci::Catalog::ValidateResourceService.new(project, ref).execute
+
+ return error(response.message) if response.error?
+ end
+
create_release(tag, evidence_pipeline)
end
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index 0534925aaec..b60a949fd4e 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -59,7 +59,8 @@ module Security
YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml
rescue Psych::BadAlias
raise Gitlab::Graphql::Errors::MutationError,
- ".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually."
+ Gitlab::Utils::ErrorMessage.to_user_facing(
+ _(".gitlab-ci.yml with aliases/anchors is not supported. Please change the CI configuration manually."))
rescue Psych::Exception => e
Gitlab::AppLogger.error("Failed to process existing .gitlab-ci.yml: #{e.message}")
raise Gitlab::Graphql::Errors::MutationError,
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 9de73a00eac..5f71b7ac9e9 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -388,8 +388,8 @@ module SystemNoteService
::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).log_resolving_alert(monitoring_tool)
end
- def change_issue_type(issue, author)
- ::SystemNotes::IssuablesService.new(noteable: issue, project: issue.project, author: author).change_issue_type
+ def change_issue_type(issue, author, previous_type)
+ ::SystemNotes::IssuablesService.new(noteable: issue, project: issue.project, author: author).change_issue_type(previous_type)
end
def add_timeline_event(timeline_event)
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index ad9f0dd0368..61a4316e8ae 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -456,8 +456,10 @@ module SystemNotes
create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
end
- def change_issue_type
- body = "changed issue type to #{noteable.issue_type.humanize(capitalize: false)}"
+ def change_issue_type(previous_type)
+ previous = previous_type.humanize(capitalize: false)
+ new = noteable.issue_type.humanize(capitalize: false)
+ body = "changed type from #{previous} to #{new}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'issue_type'))
end
diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb
index ba52e9abeb2..1c74e803e0b 100644
--- a/app/services/tasks_to_be_done/base_service.rb
+++ b/app/services/tasks_to_be_done/base_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module TasksToBeDone
- class BaseService < ::IssuableBaseService
+ class BaseService < ::BaseContainerService
LABEL_PREFIX = 'tasks to be done'
def initialize(container:, current_user:, assignee_ids: [])
@@ -19,8 +19,8 @@ module TasksToBeDone
update_service = Issues::UpdateService.new(container: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] })
update_service.execute(issue)
else
- build_service = Issues::BuildService.new(container: project, current_user: current_user, params: params)
- create(build_service.execute)
+ create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil)
+ create_service.execute
end
end
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
index 849afaddec6..f72bf0390e4 100644
--- a/app/services/terraform/remote_state_handler.rb
+++ b/app/services/terraform/remote_state_handler.rb
@@ -2,6 +2,8 @@
module Terraform
class RemoteStateHandler < BaseService
+ include Gitlab::OptimisticLocking
+
StateLockedError = Class.new(StandardError)
StateDeletedError = Class.new(StandardError)
UnauthorizedError = Class.new(StandardError)
@@ -59,7 +61,9 @@ module Terraform
private
def retrieve_with_lock(find_only: false)
- create_or_find!(find_only: find_only).tap { |state| state.with_lock { yield state } }
+ create_or_find!(find_only: find_only).tap do |state|
+ retry_lock(state, name: "Terraform state: #{state.id}") { yield state }
+ end
end
def create_or_find!(find_only:)
@@ -70,7 +74,7 @@ module Terraform
state = if find_only
find_state!(find_params)
else
- Terraform::State.create_or_find_by(find_params)
+ Terraform::State.safe_find_or_create_by(find_params)
end
raise StateDeletedError if state.deleted_at?
diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb
index 353456c545d..53ec37d0ff7 100644
--- a/app/services/users/approve_service.rb
+++ b/app/services/users/approve_service.rb
@@ -17,6 +17,11 @@ module Users
user.accept_pending_invitations! if user.active_for_authentication?
DeviseMailer.user_admin_approval(user).deliver_later
+ if user.created_by_id
+ reset_token = user.generate_reset_token
+ NotificationService.new.new_user(user, reset_token)
+ end
+
log_event(user)
after_approve_hook(user)
success(message: 'Success', http_status: :created)
diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb
index 959d4be3795..5ed31cdb778 100644
--- a/app/services/users/ban_service.rb
+++ b/app/services/users/ban_service.rb
@@ -17,3 +17,5 @@ module Users
end
end
end
+
+Users::BanService.prepend_mod_with('Users::BanService')
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index d01fa29d8d4..b1ffd006795 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -92,5 +92,3 @@ module Users
end
end
end
-
-Users::RefreshAuthorizedProjectsService.prepend_mod
diff --git a/app/services/users/unban_service.rb b/app/services/users/unban_service.rb
index 753a02fa752..2019f7e82e1 100644
--- a/app/services/users/unban_service.rb
+++ b/app/services/users/unban_service.rb
@@ -17,3 +17,5 @@ module Users
end
end
end
+
+Users::UnbanService.prepend_mod_with('Users::UnbanService')
diff --git a/app/services/users/unblock_service.rb b/app/services/users/unblock_service.rb
index 1302395662f..d80f65b5757 100644
--- a/app/services/users/unblock_service.rb
+++ b/app/services/users/unblock_service.rb
@@ -27,3 +27,5 @@ module Users
end
end
end
+
+Users::UnblockService.prepend_mod_with('Users::UnblockService')
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index eff2132039f..ae355dc6d96 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -2,6 +2,7 @@
module WorkItems
class CreateService < Issues::CreateService
+ extend ::Gitlab::Utils::Override
include WidgetableService
def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {})
@@ -48,6 +49,15 @@ module WorkItems
private
+ override :handle_quick_actions
+ def handle_quick_actions(work_item)
+ # Do not handle quick actions unless the work item is the default Issue.
+ # The available quick actions for a work item depend on its type and widgets.
+ return if work_item.work_item_type != WorkItems::Type.default_by_type(:issue)
+
+ super
+ end
+
def authorization_action
:create_work_item
end
diff --git a/app/services/work_items/export_csv_service.rb b/app/services/work_items/export_csv_service.rb
index a715aab1b30..ee20a2832ce 100644
--- a/app/services/work_items/export_csv_service.rb
+++ b/app/services/work_items/export_csv_service.rb
@@ -17,18 +17,32 @@ module WorkItems
private
def associations_to_preload
- [:work_item_type, :author]
+ [:project, [work_item_type: :enabled_widget_definitions], :author]
end
def header_to_value_hash
{
'Id' => 'iid',
'Title' => 'title',
+ 'Description' => ->(work_item) { get_widget_value_for(work_item, :description) },
'Type' => ->(work_item) { work_item.work_item_type.name },
'Author' => 'author_name',
'Author Username' => ->(work_item) { work_item.author.username },
'Created At (UTC)' => ->(work_item) { work_item.created_at.to_s(:csv) }
}
end
+
+ def get_widget_value_for(work_item, field)
+ widget_name = field_to_widget_map[field]
+ widget = work_item.get_widget(widget_name)
+
+ widget.try(field)
+ end
+
+ def field_to_widget_map
+ {
+ description: :description
+ }
+ end
end
end
diff --git a/app/services/work_items/parent_links/base_service.rb b/app/services/work_items/parent_links/base_service.rb
new file mode 100644
index 00000000000..6f22e09a3fc
--- /dev/null
+++ b/app/services/work_items/parent_links/base_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module ParentLinks
+ class BaseService < IssuableLinks::CreateService
+ extend ::Gitlab::Utils::Override
+
+ private
+
+ def set_parent(issuable, work_item)
+ link = WorkItems::ParentLink.for_work_item(work_item)
+ link.work_item_parent = issuable
+ link
+ end
+
+ def create_notes(work_item)
+ SystemNoteService.relate_work_item(issuable, work_item, current_user)
+ end
+
+ def linkable_issuables(work_items)
+ @linkable_issuables ||= if can_admin_link?(issuable)
+ work_items.select { |work_item| linkable?(work_item) }
+ else
+ []
+ end
+ end
+
+ def linkable?(work_item)
+ can_admin_link?(work_item) && previous_related_issuables.exclude?(work_item)
+ end
+
+ def can_admin_link?(work_item)
+ can?(current_user, :admin_parent_link, work_item)
+ end
+
+ override :previous_related_issuables
+ def previous_related_issuables
+ @previous_related_issuables ||= issuable.work_item_children.to_a
+ end
+
+ override :target_issuable_type
+ def target_issuable_type
+ 'work item'
+ end
+
+ override :issuables_not_found_message
+ def issuables_not_found_message
+ format(_('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/create_service.rb b/app/services/work_items/parent_links/create_service.rb
index 85b470c47ca..4747d2f17e4 100644
--- a/app/services/work_items/parent_links/create_service.rb
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -2,59 +2,34 @@
module WorkItems
module ParentLinks
- class CreateService < IssuableLinks::CreateService
+ class CreateService < WorkItems::ParentLinks::BaseService
private
- # rubocop: disable CodeReuse/ActiveRecord
+ override :relate_issuables
def relate_issuables(work_item)
- link = WorkItems::ParentLink.find_or_initialize_by(work_item: work_item)
- link.work_item_parent = issuable
+ link = set_parent(issuable, work_item)
link.move_to_end
if link.changed? && link.save
- create_notes(work_item)
+ relate_child_note = create_notes(work_item)
+
+ ResourceLinkEvent.create(
+ user: current_user,
+ work_item: link.work_item_parent,
+ child_work_item: link.work_item,
+ action: ResourceLinkEvent.actions[:add],
+ system_note_metadata_id: relate_child_note&.system_note_metadata&.id
+ )
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
+ override :extract_references
def extract_references
params[:issuable_references]
end
-
- def create_notes(work_item)
- SystemNoteService.relate_work_item(issuable, work_item, current_user)
- end
-
- def target_issuable_type
- 'work item'
- 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
index 19770b3e4b5..97145d0b360 100644
--- a/app/services/work_items/parent_links/destroy_service.rb
+++ b/app/services/work_items/parent_links/destroy_service.rb
@@ -15,7 +15,15 @@ module WorkItems
private
def create_notes
- SystemNoteService.unrelate_work_item(parent, child, current_user)
+ unrelate_note = SystemNoteService.unrelate_work_item(parent, child, current_user)
+
+ ResourceLinkEvent.create(
+ user: @current_user,
+ work_item: @link.work_item_parent,
+ child_work_item: @link.work_item,
+ action: ResourceLinkEvent.actions[:remove],
+ system_note_metadata_id: unrelate_note&.system_note_metadata&.id
+ )
end
def not_found_message
diff --git a/app/services/work_items/parent_links/reorder_service.rb b/app/services/work_items/parent_links/reorder_service.rb
new file mode 100644
index 00000000000..0ee650bd8ab
--- /dev/null
+++ b/app/services/work_items/parent_links/reorder_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module ParentLinks
+ class ReorderService < WorkItems::ParentLinks::BaseService
+ private
+
+ override :relate_issuables
+ def relate_issuables(work_item)
+ notes_are_expected = work_item.work_item_parent != issuable
+ link = set_parent(issuable, work_item)
+ reorder(link, params[:adjacent_work_item], params[:relative_position])
+
+ create_notes(work_item) if link.save && notes_are_expected
+
+ link
+ end
+
+ def reorder(link, adjacent_work_item, relative_position)
+ WorkItems::ParentLink.move_nulls_to_end(RelativePositioning.mover.context(link).relative_siblings)
+
+ link.move_before(adjacent_work_item.parent_link) if relative_position == 'BEFORE'
+ link.move_after(adjacent_work_item.parent_link) if relative_position == 'AFTER'
+ end
+
+ override :render_conflict_error?
+ def render_conflict_error?
+ return false if params[:adjacent_work_item] && params[:relative_position]
+
+ super
+ end
+
+ override :linkable?
+ def linkable?(work_item)
+ can_admin_link?(work_item)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/prepare_import_csv_service.rb b/app/services/work_items/prepare_import_csv_service.rb
new file mode 100644
index 00000000000..a331b2870f4
--- /dev/null
+++ b/app/services/work_items/prepare_import_csv_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class PrepareImportCsvService < Import::PrepareService
+ extend ::Gitlab::Utils::Override
+
+ private
+
+ override :worker
+ def worker
+ ImportWorkItemsCsvWorker
+ end
+
+ override :success_message
+ def success_message
+ _("Your work items are being imported. Once finished, you'll receive a confirmation email.")
+ end
+ end
+end
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index d4acadbc851..defdeebfed8 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -2,6 +2,7 @@
module WorkItems
class UpdateService < ::Issues::UpdateService
+ extend Gitlab::Utils::Override
include WidgetableService
def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {})
@@ -26,6 +27,15 @@ module WorkItems
private
+ override :handle_quick_actions
+ def handle_quick_actions(work_item)
+ # Do not handle quick actions unless the work item is the default Issue.
+ # The available quick actions for a work item depend on its type and widgets.
+ return unless work_item.work_item_type.default_issue?
+
+ super
+ end
+
def prepare_update_params(work_item)
execute_widgets(
work_item: work_item,
diff --git a/app/services/work_items/widgets/assignees_service/update_service.rb b/app/services/work_items/widgets/assignees_service/update_service.rb
index 9176b71c85e..7a084917ea7 100644
--- a/app/services/work_items/widgets/assignees_service/update_service.rb
+++ b/app/services/work_items/widgets/assignees_service/update_service.rb
@@ -5,6 +5,8 @@ module WorkItems
module AssigneesService
class UpdateService < WorkItems::Widgets::BaseService
def before_update_in_transaction(params:)
+ params[:assignee_ids] = [] if new_type_excludes_widget?
+
return unless params.present? && params.has_key?(:assignee_ids)
return unless has_permission?(:set_work_item_metadata)
diff --git a/app/services/work_items/widgets/award_emoji_service/update_service.rb b/app/services/work_items/widgets/award_emoji_service/update_service.rb
new file mode 100644
index 00000000000..7c58c0c9af9
--- /dev/null
+++ b/app/services/work_items/widgets/award_emoji_service/update_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module AwardEmojiService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def before_update_in_transaction(params:)
+ return unless params.present? && params.key?(:name) && params.key?(:action)
+ return unless has_permission?(:award_emoji)
+
+ service_response!(service_result(params[:action], params[:name]))
+ end
+
+ private
+
+ def service_result(action, name)
+ class_name = {
+ add: ::AwardEmojis::AddService,
+ remove: ::AwardEmojis::DestroyService
+ }
+
+ return invalid_action_error(action) unless class_name.key?(action)
+
+ class_name[action].new(work_item, name, current_user).execute
+ end
+
+ def invalid_action_error(key)
+ error(format(_("%{key} is not a valid action."), key: key))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb
index 1ff03a09f9f..cae6ed7646f 100644
--- a/app/services/work_items/widgets/base_service.rb
+++ b/app/services/work_items/widgets/base_service.rb
@@ -16,9 +16,21 @@ module WorkItems
private
+ def new_type_excludes_widget?
+ return false unless service_params[:work_item_type]
+
+ service_params[:work_item_type].widgets.exclude?(@widget.class)
+ end
+
def has_permission?(permission)
can?(current_user, permission, widget.work_item)
end
+
+ def service_response!(result)
+ return result unless result[:status] == :error
+
+ raise WidgetError, result[:message]
+ end
end
end
end
diff --git a/app/services/work_items/widgets/current_user_todos_service/update_service.rb b/app/services/work_items/widgets/current_user_todos_service/update_service.rb
new file mode 100644
index 00000000000..38e2ae4de32
--- /dev/null
+++ b/app/services/work_items/widgets/current_user_todos_service/update_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module CurrentUserTodosService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def before_update_in_transaction(params:)
+ return unless params.present? && params.key?(:action)
+
+ case params[:action]
+ when "add"
+ add_todo
+ when "mark_as_done"
+ mark_as_done(params[:todo_id])
+ end
+ end
+
+ private
+
+ def add_todo
+ return unless has_permission?(:create_todo)
+
+ TodoService.new.mark_todo(work_item, current_user)&.first
+ end
+
+ def mark_as_done(todo_id)
+ todos = TodosFinder.new(current_user, state: :pending, target_id: work_item.id).execute
+ todos = todo_id ? todos.id_in(todo_id) : todos
+
+ return if todos.empty?
+
+ TodoService.new.resolve_todos(todos, current_user, resolved_by_action: :api_done)
+ end
+ 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
index fe591ba605e..2640c6132cd 100644
--- a/app/services/work_items/widgets/description_service/update_service.rb
+++ b/app/services/work_items/widgets/description_service/update_service.rb
@@ -5,6 +5,8 @@ module WorkItems
module DescriptionService
class UpdateService < WorkItems::Widgets::BaseService
def before_update_callback(params: {})
+ params[:description] = nil if new_type_excludes_widget?
+
return unless params.present? && params.key?(:description)
return unless has_permission?(:update_work_item)
diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb
index 236762d6937..45393eab58c 100644
--- a/app/services/work_items/widgets/hierarchy_service/base_service.rb
+++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb
@@ -63,9 +63,7 @@ module WorkItems
work_item.reload_work_item_parent
work_item.work_item_children.reset
- return result unless result[:status] == :error
-
- raise WidgetError, result[:message]
+ super
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
index 48b540f919e..00b45c04ffa 100644
--- a/app/services/work_items/widgets/hierarchy_service/update_service.rb
+++ b/app/services/work_items/widgets/hierarchy_service/update_service.rb
@@ -4,10 +4,68 @@ module WorkItems
module Widgets
module HierarchyService
class UpdateService < WorkItems::Widgets::HierarchyService::BaseService
+ INVALID_RELATIVE_POSITION_ERROR = 'Relative position is not valid.'
+ CHILDREN_REORDERING_ERROR = 'Relative position cannot be combined with childrenIds.'
+ UNRELATED_ADJACENT_HIERARCHY_ERROR = 'The adjacent work item\'s parent must match the new parent work item.'
+ INVALID_ADJACENT_PARENT_ERROR = 'The adjacent work item\'s parent must match the current parent work item.'
+
def before_update_in_transaction(params:)
return unless params.present?
- service_response!(handle_hierarchy_changes(params))
+ if positioning?(params)
+ service_response!(handle_positioning(params))
+ else
+ service_response!(handle_hierarchy_changes(params))
+ end
+ end
+
+ private
+
+ def handle_positioning(params)
+ validate_positioning!(params)
+
+ arguments = {
+ target_issuable: work_item,
+ adjacent_work_item: params.delete(:adjacent_work_item),
+ relative_position: params.delete(:relative_position)
+ }
+ work_item_parent = params.delete(:parent) || work_item.work_item_parent
+ ::WorkItems::ParentLinks::ReorderService.new(work_item_parent, current_user, arguments).execute
+ end
+
+ def positioning?(params)
+ params[:relative_position].present? || params[:adjacent_work_item].present?
+ end
+
+ def error!(message)
+ service_response!(error(_(message)))
+ end
+
+ def validate_positioning!(params)
+ error!(INVALID_RELATIVE_POSITION_ERROR) if incomplete_relative_position?(params)
+ error!(CHILDREN_REORDERING_ERROR) if positioning_children?(params)
+ error!(UNRELATED_ADJACENT_HIERARCHY_ERROR) if unrelated_adjacent_hierarchy?(params)
+ error!(INVALID_ADJACENT_PARENT_ERROR) if invalid_adjacent_parent?(params)
+ end
+
+ def positioning_children?(params)
+ params.key?(:children)
+ end
+
+ def incomplete_relative_position?(params)
+ params[:adjacent_work_item].blank? || params[:relative_position].blank?
+ end
+
+ def unrelated_adjacent_hierarchy?(params)
+ return false if params[:parent].blank?
+
+ params[:parent] != params[:adjacent_work_item].work_item_parent
+ end
+
+ def invalid_adjacent_parent?(params)
+ return false if params[:parent].present?
+
+ work_item.work_item_parent != params[:adjacent_work_item].work_item_parent
end
end
end
diff --git a/app/services/work_items/widgets/labels_service/update_service.rb b/app/services/work_items/widgets/labels_service/update_service.rb
index f00ea5c95ca..b880398677d 100644
--- a/app/services/work_items/widgets/labels_service/update_service.rb
+++ b/app/services/work_items/widgets/labels_service/update_service.rb
@@ -5,6 +5,11 @@ module WorkItems
module LabelsService
class UpdateService < WorkItems::Widgets::BaseService
def prepare_update_params(params: {})
+ if new_type_excludes_widget?
+ params[:remove_label_ids] = @work_item.labels.map(&:id)
+ params[:add_label_ids] = []
+ end
+
return if params.blank?
service_params.merge!(params.slice(:add_label_ids, :remove_label_ids))
diff --git a/app/services/work_items/widgets/milestone_service/base_service.rb b/app/services/work_items/widgets/milestone_service/base_service.rb
index f373e6daea3..e9dc56cb6df 100644
--- a/app/services/work_items/widgets/milestone_service/base_service.rb
+++ b/app/services/work_items/widgets/milestone_service/base_service.rb
@@ -20,12 +20,13 @@ module WorkItems
return
end
- project = work_item.project
+ resource_group = work_item.project&.group || work_item.namespace
+ project_ids = [work_item.project&.id].compact
milestone = MilestonesFinder.new({
- project_ids: [project.id],
- group_ids: project.group&.self_and_ancestors&.select(:id),
- ids: [params[:milestone_id]]
- }).execute.first
+ project_ids: project_ids,
+ group_ids: resource_group&.self_and_ancestors&.select(:id),
+ ids: [params[:milestone_id]]
+ }).execute.first
if milestone
work_item.milestone = milestone
diff --git a/app/services/work_items/widgets/milestone_service/create_service.rb b/app/services/work_items/widgets/milestone_service/create_service.rb
deleted file mode 100644
index e8d6bfe503c..00000000000
--- a/app/services/work_items/widgets/milestone_service/create_service.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module MilestoneService
- class CreateService < WorkItems::Widgets::MilestoneService::BaseService
- def before_create_callback(params:)
- handle_milestone_change(params: params)
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/milestone_service/update_service.rb b/app/services/work_items/widgets/milestone_service/update_service.rb
deleted file mode 100644
index 7ff0c2a5367..00000000000
--- a/app/services/work_items/widgets/milestone_service/update_service.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module MilestoneService
- class UpdateService < WorkItems::Widgets::MilestoneService::BaseService
- def before_update_callback(params:)
- handle_milestone_change(params: params)
- end
- end
- end
- end
-end
diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
index 6a5dc0d5ef3..0dbf3aa31d9 100644
--- a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
+++ b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb
@@ -5,6 +5,8 @@ module WorkItems
module StartAndDueDateService
class UpdateService < WorkItems::Widgets::BaseService
def before_update_callback(params: {})
+ return widget.work_item.assign_attributes({ start_date: nil, due_date: nil }) if new_type_excludes_widget?
+
return if params.blank?
widget.work_item.assign_attributes(params.slice(:start_date, :due_date))
diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb
index f1fe62e9db3..f39729357ed 100644
--- a/app/uploaders/object_storage/cdn/google_cdn.rb
+++ b/app/uploaders/object_storage/cdn/google_cdn.rb
@@ -28,7 +28,7 @@ module ObjectStorage
expiration = (Time.current + expiry).utc.to_i
uri = Addressable::URI.parse(cdn_url)
- uri.path = path
+ uri.path = Addressable::URI.encode_component(path, Addressable::URI::CharacterClasses::PATH)
# Use an Array to preserve order: Google CDN needs to have
# Expires, KeyName, and Signature in that order or it will return a 403 error:
# https://cloud.google.com/cdn/docs/troubleshooting-steps#signing
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 967fcdc704e..8561a72444d 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -27,10 +27,14 @@ module RecordsUploads
end
def readd_upload
- uploads.where(model: model, path: upload_path).delete_all
- upload.delete if upload
+ Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
+ %w[uploads], url: "https://gitlab.com/gitlab-org/gitlab/-/issues/398199"
+ ) do
+ uploads.where(model: model, path: upload_path).delete_all
+ upload.delete if upload
- self.upload = build_upload.tap(&:save!)
+ self.upload = build_upload.tap(&:save!)
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/validators/json_schemas/application_setting_database_apdex_settings.json b/app/validators/json_schemas/application_setting_database_apdex_settings.json
new file mode 100644
index 00000000000..8b58dd44586
--- /dev/null
+++ b/app/validators/json_schemas/application_setting_database_apdex_settings.json
@@ -0,0 +1,34 @@
+{
+ "description": "Database Apdex Settings",
+ "type": "object",
+ "properties": {
+ "prometheus_api_url": {
+ "type": "string"
+ },
+ "apdex_sli_query": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "string"
+ },
+ "ci": {
+ "type": "string"
+ }
+ }
+ },
+ "apdex_slo": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "number",
+ "format": "float"
+ },
+ "ci": {
+ "type": "number",
+ "format": "float"
+ }
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/build_report_result_data.json b/app/validators/json_schemas/build_report_result_data.json
index d109389a046..d9ef7633acd 100644
--- a/app/validators/json_schemas/build_report_result_data.json
+++ b/app/validators/json_schemas/build_report_result_data.json
@@ -8,10 +8,7 @@
"format": "float"
},
"tests": {
- "type": "object",
- "items": {
- "$ref": "./build_report_result_data_tests.json"
- }
+ "$ref": "./build_report_result_data_tests.json"
}
},
"additionalProperties": false
diff --git a/app/validators/json_schemas/build_report_result_data_tests.json b/app/validators/json_schemas/build_report_result_data_tests.json
index 3b6a2688313..456b651dd6c 100644
--- a/app/validators/json_schemas/build_report_result_data_tests.json
+++ b/app/validators/json_schemas/build_report_result_data_tests.json
@@ -7,7 +7,7 @@
"type": "string"
},
"duration": {
- "type": "string"
+ "type": "number"
},
"failed": {
"type": "integer"
@@ -20,6 +20,16 @@
},
"success": {
"type": "integer"
+ },
+ "suite_error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"additionalProperties": false
diff --git a/app/validators/json_schemas/cluster_agent_authorization_configuration.json b/app/validators/json_schemas/clusters_agents_authorizations_ci_access_config.json
index f3de0b7043b..f3de0b7043b 100644
--- a/app/validators/json_schemas/cluster_agent_authorization_configuration.json
+++ b/app/validators/json_schemas/clusters_agents_authorizations_ci_access_config.json
diff --git a/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json b/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json
new file mode 100644
index 00000000000..75624af9e6a
--- /dev/null
+++ b/app/validators/json_schemas/clusters_agents_authorizations_user_access_config.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Cluster Agent configuration for an authorized project or group through user_access keyword",
+ "type": "object",
+ "additionalProperties": true
+}
diff --git a/app/validators/json_schemas/import_failure_external_identifiers.json b/app/validators/json_schemas/import_failure_external_identifiers.json
index 3756e712de5..19d4e51ad21 100644
--- a/app/validators/json_schemas/import_failure_external_identifiers.json
+++ b/app/validators/json_schemas/import_failure_external_identifiers.json
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Import failure external identifiers",
"type": "object",
- "maxProperties": 3,
+ "maxProperties": 4,
"patternProperties": {
".*": {
"oneOf": [
diff --git a/app/validators/json_schemas/pinned_nav_items.json b/app/validators/json_schemas/pinned_nav_items.json
new file mode 100644
index 00000000000..60dee5cc463
--- /dev/null
+++ b/app/validators/json_schemas/pinned_nav_items.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Pinned navigation items per panel",
+ "type": "object",
+ "properties": {
+ "group": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true
+ },
+ "project": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index 8b9bbfd0a59..39b8fe26c7b 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -26,6 +26,17 @@
= f.label :reported_from
= f.text_field :reported_from_url, class: "form-control", readonly: true
#js-links-to-spam{ data: { links: Array(@abuse_report.links_to_spam) } }
+
+ .form-group.row
+ .col-lg-8
+ = f.label :screenshot do
+ %span
+ = s_('ReportAbuse|Screenshot')
+ .gl-font-weight-normal
+ = s_('ReportAbuse|Screenshot of abuse')
+ %div
+ = render 'shared/file_picker_button', f: f, field: :screenshot, help_text: _("Screenshot must be less than 1 MB."), mime_types: valid_image_mimetypes
+
.form-group.row
.col-lg-8
= f.label :reason
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 6a8ef86a56e..16b2a0b8fc6 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -6,9 +6,9 @@
= f.label :container_registry_token_expire_delay, _('Authorization token duration (minutes)'), class: 'label-bold'
= f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input'
.form-group
- - label = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.")
+ - label = _("Enable cleanup policies for projects created earlier than GitLab 12.7.")
- label_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy')
- - help_text = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
+ - help_text = _("Existing projects will be able to use cleanup policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
- help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'use-with-external-container-registries')
= f.gitlab_ui_checkbox_component :container_expiration_policies_enable_historic_entries,
'%{label} %{label_link}'.html_safe % { label: label, label_link: label_link },
@@ -29,9 +29,9 @@
.form-text.text-muted
= _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.")
.form-group
- - help_text = _("When enabled, cleanup polices execute faster but put more load on Redis.")
+ - help_text = _("When enabled, cleanup policies execute faster but put more load on Redis.")
- help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources')
- = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable container expiration caching."),
+ = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable cleanup policy caching."),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 83347034cc5..a8d5a45041d 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -2,39 +2,34 @@
= form_errors(application)
= content_tag :div, class: 'form-group row' do
- .col-sm-2.col-form-label
+ .col-12
= f.label :name
- .col-sm-10
- = f.text_field :name, class: 'form-control gl-form-input'
+ = f.text_field :name, class: 'form-control gl-form-input', data: { qa_selector: 'name_field' }
= doorkeeper_errors_for application, :name
= content_tag :div, class: 'form-group row' do
- .col-sm-2.col-form-label
+ .col-12
= f.label :redirect_uri
- .col-sm-10
- = f.text_area :redirect_uri, class: 'form-control gl-form-input'
+ = f.text_area :redirect_uri, class: 'form-control gl-form-input', data: { qa_selector: 'redirect_uri_field' }
= doorkeeper_errors_for application, :redirect_uri
%span.form-text.text-muted
Use one line per URI
= content_tag :div, class: 'form-group row' do
- .col-sm-2.col-form-label.pt-0
+ .col-12
= f.label :trusted
- .col-sm-10
- = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.')
+ = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.'), checkbox_options: { data: { qa_selector: 'trusted_checkbox' } }
= content_tag :div, class: 'form-group row' do
- .col-sm-2.col-form-label.pt-0
+ .col-12
= f.label :confidential
- .col-sm-10
= f.gitlab_ui_checkbox_component :confidential, _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
.form-group.row
- .col-sm-2.col-form-label.pt-0
+ .col-12
= f.label :scopes
- .col-sm-10
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes, f: f
- .form-actions
- = f.submit _('Save application'), pajamas_button: true
+ .gl-mt-5
+ = f.submit _('Save application'), pajamas_button: true, data: { qa_selector: 'save_application_button' }
= link_to _('Cancel'), admin_applications_path, class: "gl-button btn btn-default btn-cancel"
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index d6a0974d10f..60aa7ae1c56 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -10,16 +10,16 @@
- if @applications.empty?
%section.empty-state.gl-text-center.gl-display-flex.gl-flex-direction-column
.svg-content.svg-150
- = image_tag 'illustrations/empty-state/empty-admin-apps.svg', class: 'gl-max-w-full'
+ = image_tag 'illustrations/empty-state/empty-admin-apps-md.svg', class: 'gl-max-w-full'
.gl-max-w-full.gl-m-auto
%h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found')
- = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do
+ = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
= s_('New application')
- else
%hr
- = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do
+ = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
= s_('New application')
.table-responsive
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index 00859bf6b66..9550ea2884e 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -17,6 +17,9 @@
= gl_tab_link_to admin_background_migrations_path({ tab: nil, database: params[:database] }), item_active: @current_tab == 'queued' do
= _('Queued')
= gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['queued'])
+ = gl_tab_link_to admin_background_migrations_path({ tab: 'finalizing', database: params[:database] }), item_active: @current_tab == 'finalizing' do
+ = _('Finalizing')
+ = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['finalizing'])
= gl_tab_link_to admin_background_migrations_path({ tab: 'failed', database: params[:database] }), item_active: @current_tab == 'failed' do
= _('Failed')
= gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['failed'])
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 8afddd99451..01d47facb5c 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -101,7 +101,7 @@
doc_href: help_page_path('integration/omniauth'))
= feature_entry(_('Reply by email'),
- enabled: Gitlab::IncomingEmail.enabled?,
+ enabled: Gitlab::Email::IncomingEmail.enabled?,
doc_href: help_page_path('administration/reply_by_email'))
= render_if_exists 'admin/dashboard/elastic_and_geo'
@@ -126,7 +126,7 @@
- if show_version_check?
.float-right
.js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true", "version": gitlab_version_check.to_json } }
- = link_to(sprite_icon('question'), "https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md", class: 'gl-ml-2', target: '_blank', rel: 'noopener noreferrer')
+ = link_to(sprite_icon('question-o'), "https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md", class: 'gl-ml-2', target: '_blank', rel: 'noopener noreferrer')
%p
= link_to _('GitLab'), general_admin_application_settings_path
%span.float-right
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index d92d13260fe..c079dd6b581 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -1,10 +1,9 @@
- page_title _('DevOps Reports')
- add_page_specific_style 'page_bundles/dev_ops_reports'
-.container
- .gl-mt-3
- - if show_adoption?
- = render_if_exists 'admin/dev_ops_report/devops_tabs'
- - else
- = render 'score'
+.gl-mt-3
+ - if show_adoption?
+ = render_if_exists 'admin/dev_ops_report/devops_tabs'
+ - else
+ = render 'score'
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 8d6df064c3c..1cdfde07adc 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -18,8 +18,8 @@
.js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) }
%section.row.empty-state.gl-text-center
.col-12
- .svg-content
- = image_tag 'illustrations/labels.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-labels-md.svg'
.col-12
.gl-mx-auto.gl-my-0.gl-p-5
%h1.gl-font-size-h-display.gl-line-height-36.h4
diff --git a/app/views/admin/labels/new.html.haml b/app/views/admin/labels/new.html.haml
index 76f9eee717e..c8c3fe7b9af 100644
--- a/app/views/admin/labels/new.html.haml
+++ b/app/views/admin/labels/new.html.haml
@@ -1,5 +1,4 @@
- page_title _("New Label")
%h1.page-title.gl-font-size-h-display
= _('New Label')
-%hr
= render 'shared/labels/form', url: admin_labels_path, back_path: admin_labels_path
diff --git a/app/views/admin/projects/_form.html.haml b/app/views/admin/projects/_form.html.haml
index 18bef523168..dbb4f3a63cc 100644
--- a/app/views/admin/projects/_form.html.haml
+++ b/app/views/admin/projects/_form.html.haml
@@ -17,6 +17,21 @@
= f.label :description, _('Project description (optional)')
= f.text_area :description, class: 'form-control gl-form-input gl-form-textarea gl-lg-form-input-xl', rows: 5
+ = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c|
+ = c.title { _('Permissions and project features') }
+ = c.description do
+ = _('Configure advanced permissions')
+ = c.body do
+ - if @project.project_setting.present?
+ .form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_('Runners|Runner Registration')
+ - all_disabled = Gitlab::CurrentSettings.valid_runner_registrars.exclude?('project')
+ = f.gitlab_ui_checkbox_component :runner_registration_enabled,
+ s_('Runners|New project runners can be registered'),
+ checkbox_options: { checked: @project.runner_registration_enabled, disabled: all_disabled },
+ help_text: html_escape_once(s_('Runners|Existing runners are not affected. To permit runner registration for all projects, enable this setting in the Admin Area in Settings &gt; CI/CD.')).html_safe
+
.gl-mt-5
= f.submit _('Save changes'), pajamas_button: true
= render Pajamas::ButtonComponent.new(href: admin_project_path(@project)) do
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 3950170e486..a24ef5d8ea4 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -1,4 +1,5 @@
- page_title _('Enter Admin Mode')
+- add_page_specific_style 'page_bundles/login'
.row.justify-content-center
.col-md-5.new-session-forms-container
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index d05cc51af41..65455f2ccf0 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -1,4 +1,5 @@
- page_title _('Enter 2FA for Admin Mode')
+- add_page_specific_style 'page_bundles/login'
.row.justify-content-center
.col-md-5.new-session-forms-container
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 049f3d61294..097329a027c 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -3,45 +3,46 @@
.gl-mt-3
.row
.col-sm
- .bg-light.info-well.p-3
- %h4.page-title.d-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('pod', size: 18, css_class: 'pod-icon gl-mr-3')
- = _('CPU')
- .data
- - if @cpus
- %h2= _('%{cores} cores') % { cores: @cpus.length }
- - else
- = sprite_icon('warning-solid', css_class: 'text-warning')
- = _('Unable to collect CPU info')
- .bg-light.info-well.p-3.gl-mt-3
- %h4.page-title.d-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('status-health', size: 18, css_class: 'pod-icon gl-mr-3')
- = _('Memory Usage')
- .data
- - if @memory
- %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
- - else
- = sprite_icon('warning-solid', css_class: 'text-warning')
- = _('Unable to collect memory info')
- .bg-light.info-well.p-3.gl-mt-3
- %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')
- = _('System started')
- .data
- %h2= time_ago_with_tooltip(Rails.application.config.booted_at)
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c|
+ = c.body do
+ %h4
+ = sprite_icon('pod', size: 18, css_class: 'gl-text-gray-700')
+ = s_('CPU')
+ .data
+ - if @cpus
+ %h2= _('%{cores} cores') % { cores: @cpus.length }
+ - else
+ = sprite_icon('warning-solid', css_class: 'text-warning')
+ = _('Unable to collect CPU info')
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c|
+ = c.body do
+ %h4
+ = sprite_icon('status-health', size: 18, css_class: 'gl-text-gray-700')
+ = s_('Memory Usage')
+ .data
+ - if @memory
+ %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
+ - else
+ = sprite_icon('warning-solid', css_class: 'text-warning')
+ = _('Unable to collect memory info')
+
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c|
+ = c.body do
+ %h4
+ = sprite_icon('clock', size: 18, css_class: 'gl-text-gray-700')
+ = s_('System started')
+ .data
+ %h2= time_ago_with_tooltip(Rails.application.config.booted_at)
.col-sm
- .bg-light.info-well.p-3
- %h4.page-title.d-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('disk', size: 18, css_class: 'pod-icon gl-mr-3')
- = _('Disk Usage')
- .data
- %ul
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c|
+ = c.body do
+ %h4
+ = sprite_icon('disk', size: 18, css_class: 'gl-text-gray-700')
+ = s_('Disk Usage')
+ .data
- @disks.each do |disk|
- %li
- %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
- %p= disk[:disk_name]
- %p= disk[:mount_path]
+ %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
+ %ul
+ %li= disk[:disk_name]
+ %li= disk[:mount_path]
diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml
index e90dab68b39..de48129dfe6 100644
--- a/app/views/admin/users/_profile.html.haml
+++ b/app/views/admin/users/_profile.html.haml
@@ -1,31 +1,32 @@
-.card
- .card-header
+= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c|
+ - c.header do
= _('Profile')
- %ul.content-list
- %li
- %span.light= _('Member since')
- %strong= user.created_at.to_s(:medium)
- - unless user.public_email.blank?
+ - c.body do
+ %ul.content-list
%li
- %span.light= _('E-mail:')
- %strong= link_to user.public_email, "mailto:#{user.public_email}"
- - unless user.skype.blank?
- %li
- %span.light= _('Skype:')
- %strong= link_to user.skype, "skype:#{user.skype}"
- - unless user.linkedin.blank?
- %li
- %span.light= _('LinkedIn:')
- %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}"
- - unless user.twitter.blank?
- %li
- %span.light= _('Twitter:')
- %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}"
- - unless user.website_url.blank?
- %li
- %span.light= _('Website:')
- %strong= link_to user.short_website_url, user.full_website_url
- - unless user.location.blank?
- %li
- %span.light= _('Location:')
- %strong= user.location
+ %span.light= _('Member since')
+ %strong= user.created_at.to_s(:medium)
+ - unless user.public_email.blank?
+ %li
+ %span.light= _('E-mail:')
+ %strong= link_to user.public_email, "mailto:#{user.public_email}"
+ - unless user.skype.blank?
+ %li
+ %span.light= _('Skype:')
+ %strong= link_to user.skype, "skype:#{user.skype}"
+ - unless user.linkedin.blank?
+ %li
+ %span.light= _('LinkedIn:')
+ %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}"
+ - unless user.twitter.blank?
+ %li
+ %span.light= _('Twitter:')
+ %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}"
+ - unless user.website_url.blank?
+ %li
+ %span.light= _('Website:')
+ %strong= link_to user.short_website_url, user.full_website_url
+ - unless user.location.blank?
+ %li
+ %span.light= _('Location:')
+ %strong= user.location
diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml
index 7dcec50573f..7d7bd395836 100644
--- a/app/views/authentication/_authenticate.html.haml
+++ b/app/views/authentication/_authenticate.html.haml
@@ -1,5 +1,8 @@
#js-authenticate-token-2fa
-%a.gl-button.btn.btn-block.btn-confirm#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
+= render Pajamas::ButtonComponent.new(variant: :confirm,
+ block: true,
+ button_options: { id: 'js-login-2fa-device' }) do
+ = _("Sign in via 2FA code")
-# haml-lint:disable InlineJavaScript
%script#js-authenticate-token-2fa-in-progress{ type: "text/template" }
@@ -9,7 +12,9 @@
%script#js-authenticate-token-2fa-error{ type: "text/template" }
%div
%p <%= error_message %> (<%= error_name %>)
- %a.btn.btn-default.gl-button.btn-block#js-token-2fa-try-again= _("Try again?")
+ = render Pajamas::ButtonComponent.new(block: true,
+ button_options: { id: 'js-token-2fa-try-again', class: 'gl-mb-3' }) do
+ = _("Try again?")
-# haml-lint:disable InlineJavaScript
%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml
index dc4511a8159..f8a03f085ff 100644
--- a/app/views/authentication/_register.html.haml
+++ b/app/views/authentication/_register.html.haml
@@ -1,4 +1,4 @@
-- if Feature.enabled?(:webauthn) && Feature.enabled?(:webauthn_without_totp)
+- if Feature.enabled?(:webauthn_without_totp)
#js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: target_path, webauthn_error: @webauthn_error) }
- else
#js-register-token-2fa
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index b49f1aa061a..a818f8a5c26 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -24,6 +24,7 @@
order_by: 'last_activity_at',
group_id: group_id,
user_id: user_id,
+ with_shared: true.to_s,
include_subgroups: true.to_s,
membership: true.to_s,
selected: @cluster.management_project_id } }
diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml
index 82750974803..a6e1837badf 100644
--- a/app/views/clusters/clusters/connect.html.haml
+++ b/app/views/clusters/clusters/connect.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
- breadcrumb_title _('Connect a cluster')
- page_title _('Connect a Kubernetes Cluster')
diff --git a/app/views/clusters/clusters/new_cluster_docs.html.haml b/app/views/clusters/clusters/new_cluster_docs.html.haml
index bff371b8d51..72c70f35e22 100644
--- a/app/views/clusters/clusters/new_cluster_docs.html.haml
+++ b/app/views/clusters/clusters/new_cluster_docs.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
- breadcrumb_title _('Create a cluster')
- page_title _('Create a Kubernetes cluster')
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 58e0ef96333..7660a8e4ac1 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -1,4 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
- breadcrumb_title @cluster.name
- page_title _('Kubernetes Cluster')
diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml
index 87bd5209fdf..4367d201190 100644
--- a/app/views/dashboard/_projects_nav.html.haml
+++ b/app/views/dashboard/_projects_nav.html.haml
@@ -3,8 +3,8 @@
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav' }) do
= gl_tab_link_to dashboard_projects_path, { item_active: is_your_projects_path, class: 'shortcuts-activity', data: { placement: 'right' } } do
= s_("ProjectList|Yours")
- = gl_tab_counter_badge(limited_counter_with_delimiter(@total_user_projects_count))
+ = gl_tab_counter_badge(limited_counter_with_delimiter(@all_user_projects))
= gl_tab_link_to starred_dashboard_projects_path, { data: { placement: 'right' } } do
= s_("ProjectList|Starred")
- = gl_tab_counter_badge(limited_counter_with_delimiter(@total_starred_projects_count))
+ = gl_tab_counter_badge(limited_counter_with_delimiter(@all_starred_projects))
= render_if_exists "dashboard/removed_projects_tab"
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 0ddee68e93f..ff9f13ba2de 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -1,12 +1,9 @@
-- @hide_top_links = true
-
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
= render_dashboard_ultimate_trial(current_user)
-- page_title _("Activity")
-- header_title _("Activity"), activity_dashboard_path
+- page_title _("Activity")
= render "projects/last_push"
= render 'dashboard/activity_head'
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index fdfc2c5adb8..7f004e405a7 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,6 +1,4 @@
-- @hide_top_links = true
- page_title _("Groups")
-- header_title _("Groups"), dashboard_groups_path
= render_dashboard_ultimate_trial(current_user)
= render 'dashboard/groups_head'
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 0933f6d6a94..7e77b31499a 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,4 +1,3 @@
-- @hide_top_links = true
- page_title _("Issues")
- @breadcrumb_link = issues_dashboard_path(assignee_username: current_user.username)
- add_page_specific_style 'page_bundles/issuable_list'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 712f987a783..eb4ce46412b 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,4 +1,3 @@
-- @hide_top_links = true
- page_title _("Merge requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
- add_page_specific_style 'page_bundles/issuable_list'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 2556791da12..682dfa8458e 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,6 +1,4 @@
-- @hide_top_links = true
- page_title _('Milestones')
-- header_title _('Milestones'), dashboard_milestones_path
- add_page_specific_style 'page_bundles/milestone'
.page-title-holder.d-flex.align-items-center
diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml
index 6db018d72da..dafa3b4dc8d 100644
--- a/app/views/dashboard/projects/_starred_empty_state.html.haml
+++ b/app/views/dashboard/projects/_starred_empty_state.html.haml
@@ -1,7 +1,7 @@
.row.empty-state
.col-12
- .svg-content.svg-250
- = image_tag 'illustrations/starred_empty.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-projects-starred-md.svg'
.text-content
%h4.gl-text-center
= s_("StarredProjectsEmptyState|You don't have starred projects yet.")
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index f427c347dd3..140bc6e06c3 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -1,12 +1,9 @@
-- @hide_top_links = true
-
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
= render_dashboard_ultimate_trial(current_user)
-- page_title _("Projects")
-- header_title _("Projects"), dashboard_projects_path
+- page_title _("Projects")
- add_page_specific_style 'page_bundles/dashboard_projects'
= render "projects/last_push"
diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml
index 17dcb072152..f6f67ad7712 100644
--- a/app/views/dashboard/projects/shared/_common.html.haml
+++ b/app/views/dashboard/projects/shared/_common.html.haml
@@ -1,6 +1,4 @@
-- @hide_top_links = true
-- breadcrumb_title _("Projects")
-- header_title _("Projects"), dashboard_projects_path
+- page_title _("Projects")
= render_dashboard_ultimate_trial(current_user)
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 42386e5b9cc..667ed617849 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,6 +1,4 @@
-- @hide_top_links = true
-- page_title _("Snippets")
-- header_title _("Snippets"), dashboard_snippets_path
+- page_title _("Snippets")
- button_path = new_snippet_path if can?(current_user, :create_snippet)
= render 'dashboard/snippets_head'
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 7ca89651282..ca6b1071f03 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -1,9 +1,8 @@
-- @hide_top_links = true
- page_title _("To-Do List")
-- header_title _("To-Do List"), dashboard_todos_path
= render_two_factor_auth_recovery_settings_check
= render_dashboard_ultimate_trial(current_user)
+= render_if_exists 'dashboard/todos/saml_reauth_notice'
- add_page_specific_style 'page_bundles/todos'
- add_page_specific_style 'page_bundles/issuable'
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index 4b586b2f580..5af247703f6 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -7,6 +7,8 @@
.form-group
= f.label :email
= f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', title: _('Please provide a valid email address.'), value: nil
+ .form-text.gl-text-secondary
+ = _('Requires your primary GitLab email address.')
%div
- if recaptcha_enabled?
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 439a2fc4d96..150f61a97e0 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -7,12 +7,12 @@
.gl-display-flex.gl-flex-wrap{ class: restyle_login_page_enabled ? 'gl-justify-content-center' : 'gl-justify-content-between' }
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", data: { qa_selector: "#{qa_selector_for_provider(provider)}" }, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do
- if has_icon
= provider_image_tag(provider)
%span.gl-button-text
= label_for_provider(provider)
- unless hide_remember_me
- = render Pajamas::CheckboxTagComponent.new(name: 'remember_me', value: nil) do |c|
+ = render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c|
= c.label do
= _('Remember me')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 57cd819cb89..23bb7170d87 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,12 +1,13 @@
- max_first_name_length = max_last_name_length = 127
- omniauth_providers_placement ||= :bottom
- borderless ||= false
+- form_resource_name = "new_#{resource_name}"
.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
- if show_omniauth_providers && omniauth_providers_placement == :top
= render 'devise/shared/signup_omniauth_providers_top'
- = gitlab_ui_form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
+ = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: resource
- if Gitlab::CurrentSettings.invisible_captcha_enabled
@@ -52,16 +53,12 @@
%p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?')
-# 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
+ .form-group.gl-mb-5
= f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
- = f.password_field :password,
- 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 }
+ %input.form-control.gl-form-input.js-password{ data: { resource_name: form_resource_name,
+ minimum_password_length: @minimum_password_length,
+ qa_selector: 'new_user_password_field' } }
+ %p.gl-field-hint-valid.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
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index a96c8d6358b..99428708b20 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -5,7 +5,7 @@
= _("Register with:")
.gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto
- providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
@@ -15,7 +15,7 @@
= _("Create an account using:")
.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-3 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml
index c48e2cd4db0..f1c3ea6aab1 100644
--- a/app/views/doorkeeper/applications/edit.html.haml
+++ b/app/views/doorkeeper/applications/edit.html.haml
@@ -1,5 +1,4 @@
- page_title _("Edit"), @application.name, _("Applications")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display= _('Edit application')
= render 'shared/doorkeeper/applications/form', url: doorkeeper_submit_path(@application)
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index d087d85a94e..5fc1384f6ee 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _("Applications"), oauth_applications_path
- breadcrumb_title @application.name
- page_title @application.name, _("Applications")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display
= _("Application: %{name}") % { name: @application.name }
diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml
index e1b7804c5a7..eb6154a446e 100644
--- a/app/views/events/_events.html.haml
+++ b/app/views/events/_events.html.haml
@@ -1,4 +1,4 @@
-- illustration_path = 'illustrations/profile-page/activity.svg'
+- illustration_path = 'illustrations/empty-state/empty-activity-md.svg'
- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!')
- primary_button_label = _('New group')
- primary_button_link = new_group_path
diff --git a/app/views/explore/_head.html.haml b/app/views/explore/_head.html.haml
deleted file mode 100644
index eefc797cf03..00000000000
--- a/app/views/explore/_head.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.explore-title.text-center
- %h2
- = _("Explore GitLab")
- %p.lead
- = _("Discover projects, groups and snippets. Share your projects with others")
- %br
diff --git a/app/views/explore/projects/_head.html.haml b/app/views/explore/projects/_head.html.haml
new file mode 100644
index 00000000000..605d85f49e0
--- /dev/null
+++ b/app/views/explore/projects/_head.html.haml
@@ -0,0 +1,11 @@
+- breadcrumb_title _("Projects")
+- page_title _("Explore projects")
+
+= render_dashboard_ultimate_trial(current_user)
+
+.page-title-holder.gl-display-flex.gl-align-items-center
+ %h1.page-title.gl-font-size-h-display= page_title
+ .page-title-controls
+ - if current_user&.can_create_project?
+ = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do
+ = _("New project")
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index 53b252db4fe..50d79eeefdb 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -1,15 +1,5 @@
-- breadcrumb_title _("Projects")
-- page_title _("Explore projects")
- page_canonical_link explore_projects_url
-= render_dashboard_ultimate_trial(current_user)
-
-.page-title-holder.gl-display-flex.gl-align-items-center
- %h1.page-title.gl-font-size-h-display= page_title
- .page-title-controls
- - if current_user&.can_create_project?
- = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do
- = _("New project")
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml
index e13768a3ccb..1b65cdb0c56 100644
--- a/app/views/explore/projects/page_out_of_bounds.html.haml
+++ b/app/views/explore/projects/page_out_of_bounds.html.haml
@@ -1,14 +1,4 @@
-- @hide_top_links = true
-- page_title _("Projects")
-- header_title _("Projects"), dashboard_projects_path
-
-= render_dashboard_ultimate_trial(current_user)
-
-- if current_user
- = render 'dashboard/projects_head', project_tab_filter: :explore
-- else
- = render 'explore/head'
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
.nothing-here-block
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index c765c086027..8840a2dc0e3 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -1,14 +1,3 @@
-- @hide_top_links = true
-- page_title _("Explore projects")
-- header_title _("Projects"), dashboard_projects_path
-
-= render_dashboard_ultimate_trial(current_user)
-
-.page-title-holder.gl-display-flex.gl-align-items-center
- %h1.page-title.gl-font-size-h-display= page_title
- .page-title-controls
- = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do
- = _("New project")
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index 043189315b4..8840a2dc0e3 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -1,15 +1,3 @@
-- @hide_top_links = true
-- page_title _("Explore projects")
-- header_title _("Projects"), dashboard_projects_path
-
-= render_dashboard_ultimate_trial(current_user)
-
-.page-title-holder.gl-display-flex.gl-align-items-center
- %h1.page-title.gl-font-size-h-display= page_title
- .page-title-controls
- - if current_user&.can_create_project?
- = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm) do
- = _("New project")
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
= render 'projects', projects: @projects
diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml
index fa1a9d2cca4..b6b409967b0 100644
--- a/app/views/groups/_flash_messages.html.haml
+++ b/app/views/groups/_flash_messages.html.haml
@@ -1,2 +1,2 @@
= content_for :flash_message do
- = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class]
diff --git a/app/views/groups/_group_readme.html.haml b/app/views/groups/_group_readme.html.haml
new file mode 100644
index 00000000000..724e82594e6
--- /dev/null
+++ b/app/views/groups/_group_readme.html.haml
@@ -0,0 +1,3 @@
+- return unless show_group_readme?(group)
+
+#js-group-readme{ data: group_readme_app_data(group.group_readme) }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index 178d8980ab8..23d397faaf5 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -1,5 +1,4 @@
- page_title _("Dependency Proxy")
-- @content_class = "limit-container-width" unless fluid_layout
#js-dependency-proxy{ data: { group_path: @group.full_path,
no_manifests_illustration: image_path('illustrations/docker-empty-state.svg'),
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 0c416d57b75..84b8c7b6e66 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _("General settings")
- page_title _("General settings")
-- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
= render 'shared/namespaces/cascading_settings/lock_popovers'
@@ -17,7 +16,7 @@
.settings-content
= render 'groups/settings/general'
-%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content' } }
+%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content', testid: 'permissions-settings' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Permissions and group features')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index a2a5f519221..04bf3f98a1e 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,9 +1,6 @@
- add_page_specific_style 'page_bundles/members'
- page_title _('Group members')
-= content_for :page_level_alert do
- = render_if_exists 'shared/unlimited_members_during_trial_alert', group: @group.root_ancestor
-
.row.gl-mt-3
.col-lg-12
.gl-display-flex.gl-flex-wrap
diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml
index 59ad29ccabd..3f9073e358d 100644
--- a/app/views/groups/harbor/repositories/index.html.haml
+++ b/app/views/groups/harbor/repositories/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Harbor Registry")
-- @content_class = "limit-container-width" unless fluid_layout
#js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml
index 9cfb58da7e4..6a5c266f74d 100644
--- a/app/views/groups/imports/show.html.haml
+++ b/app/views/groups/imports/show.html.haml
@@ -1,5 +1,4 @@
- page_title _('Import in progress')
-- @content_class = "limit-container-width" unless fluid_layout
.save-group-loader
.center
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index a5cbc443fa4..1d306d4d3b8 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -1,4 +1,4 @@
-- @hide_breadcrumbs = true
+- @hide_top_bar = true
- @hide_top_links = true
- page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path
@@ -6,8 +6,9 @@
.group-edit-container
- .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s, groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group),
- verification_for_group_creation_data) }
+ .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s,
+ root_path: root_path,
+ groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group)) }
.row{ 'v-cloak': true }
#create-group-pane.tab-pane
diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml
index 1c0627779ec..b6cf26c3677 100644
--- a/app/views/groups/packages/index.html.haml
+++ b/app/views/groups/packages/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Package Registry")
-- @content_class = "limit-container-width" unless fluid_layout
.row
.col-12
@@ -10,4 +9,5 @@
empty_list_illustration: image_path('illustrations/no-packages.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: '',
+ settings_path: show_group_package_registry_settings(@group) ? group_settings_packages_and_registries_path(@group) : '',
group_list_url: group_packages_path(@group) } }
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index aaa42aaea3a..f665b1f71f3 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _("Projects")
- page_title _("Projects")
-- @content_class = "limit-container-width" unless fluid_layout
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-3 js-search-settings-section' }, header_options: { class: 'gl-display-flex' }, body_options: { class: 'gl-py-0' }) do |c|
- c.header do
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index efd2e53e100..c906beb631b 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Container Registry")
-- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil})
%section
diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml
index 7e98f6035a6..d619635d3e0 100644
--- a/app/views/groups/runners/index.html.haml
+++ b/app/views/groups/runners/index.html.haml
@@ -1,3 +1,3 @@
- page_title s_('Runners|Runners')
-#js-group-runners{ data: group_runners_data_attributes(@group).merge({ registration_token: @group_runner_registration_token }) }
+#js-group-runners{ data: group_runners_data_attributes(@group).merge({ registration_token: @group_runner_registration_token, new_runner_path: @group_new_runner_path }) }
diff --git a/app/views/groups/runners/new.html.haml b/app/views/groups/runners/new.html.haml
new file mode 100644
index 00000000000..12e7e458a79
--- /dev/null
+++ b/app/views/groups/runners/new.html.haml
@@ -0,0 +1,5 @@
+- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- breadcrumb_title s_('Runners|New')
+- page_title s_('Runners|Create a group runner')
+
+#js-group-new-runner{ data: { legacy_registration_token: @group_runner_registration_token, group_id: @group.to_global_id } }
diff --git a/app/views/groups/runners/register.html.haml b/app/views/groups/runners/register.html.haml
new file mode 100644
index 00000000000..fdee1675475
--- /dev/null
+++ b/app/views/groups/runners/register.html.haml
@@ -0,0 +1,7 @@
+- runner_name = "##{@runner.id} (#{@runner.short_sha})"
+- breadcrumb_title s_('Runners|Register')
+- page_title s_('Runners|Register'), "##{@runner.id} (#{@runner.short_sha})"
+- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- add_to_breadcrumbs runner_name, register_group_runner_path(@runner)
+
+#js-group-register-runner{ data: { runner_id: @runner.id, runners_path: group_runners_path(@group) } }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 5258854c931..8c73fc95544 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -19,6 +19,11 @@
= f.label :description, s_('Groups|Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
+ .row.gl-mt-3
+ .form-group.col-md-5
+ = f.label :description, s_('Groups|Group README'), class: 'label-bold'
+ #js-group-settings-readme{ data: group_settings_readme_app_data(@group) }
+
= render 'shared/repository_size_limit_setting_registration_features_cta', form: f
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index a18789b52a3..32ef830b6cb 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -30,13 +30,15 @@
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
+ - unless Feature.enabled?(:always_perform_delayed_deletion)
+ = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
= render 'groups/settings/ip_restriction_registration_features_cta', f: f
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
- if @group.licensed_feature_available?(:group_wikis)
= render_if_exists 'groups/settings/wiki', f: f, group: @group
= render 'groups/settings/lfs', f: f
+ = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group
= render 'groups/settings/git_access_protocols', f: f, group: @group
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 309633471a5..8435f32db49 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -2,7 +2,6 @@
- page_title _('Group Access Tokens')
- type = _('group access token')
- type_plural = _('group access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4
diff --git a/app/views/groups/settings/applications/edit.html.haml b/app/views/groups/settings/applications/edit.html.haml
index ee71fd5d886..0dec3906bf2 100644
--- a/app/views/groups/settings/applications/edit.html.haml
+++ b/app/views/groups/settings/applications/edit.html.haml
@@ -1,5 +1,4 @@
- page_title _("Edit"), @application.name, _("Group applications")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display= _('Edit group application')
= render 'shared/doorkeeper/applications/form', url: group_settings_application_path(@group, @application)
diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml
index e24aa993b26..06c678b1187 100644
--- a/app/views/groups/settings/applications/show.html.haml
+++ b/app/views/groups/settings/applications/show.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _("Group applications"), group_settings_applications_path(@group)
- breadcrumb_title @application.name
- page_title @application.name, _("Group applications")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display
= _("Group application: %{name}") % { name: @application.name }
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index ec99ceb5f8d..93140de4dfa 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title s_('Integrations|Group-level integration management')
- page_title s_('Integrations|Group-level integration management')
-- @content_class = 'limit-container-width' unless fluid_layout
%section.js-search-settings-section
%h3= s_('Integrations|Group-level integration management')
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 faed486b20f..374ae9777a5 100644
--- a/app/views/groups/settings/packages_and_registries/show.html.haml
+++ b/app/views/groups/settings/packages_and_registries/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _('Packages and registries settings')
- page_title _('Packages and registries settings')
-- @content_class = 'limit-container-width' unless fluid_layout
%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/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index c6bf2d66683..a6222f39092 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _('Repository Settings')
- page_title _('Repository')
-- @content_class = "limit-container-width" unless fluid_layout
- if can?(current_user, :admin_group, @group)
- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 7983274f319..8d7a7dd6b1b 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,4 @@
-- @content_class = "limit-container-width" unless fluid_layout
-- page_itemtype 'https://schema.org/Organization'
+- page_itemtype 'https://schema.org/Organization'
- @skip_current_level_breadcrumb = true
- add_page_specific_style 'page_bundles/group'
@@ -29,3 +28,5 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
#js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) }
+
+= render partial: 'groups/group_readme', locals: { group: @group }
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 4d2186a1352..8545b5fd71d 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -5,6 +5,7 @@
- paginatable = local_assigns.fetch(:paginatable, false)
- default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path
- cancel_path = local_assigns.fetch(:cancel_path, nil)
+- details_path = local_assigns.fetch(:details_path, nil)
- provider_title = Gitlab::ImportSources.title(local_assigns.fetch(:provider))
- optional_stages = local_assigns.fetch(:optional_stages, [])
@@ -19,6 +20,7 @@
default_target_namespace: default_namespace_path,
import_path: url_for([:import, provider, { format: :json }]),
cancel_path: cancel_path,
+ details_path: details_path,
filterable: filterable.to_s,
paginatable: paginatable.to_s,
optional_stages: optional_stages.to_json }.merge(extra_data) }
diff --git a/app/views/import/github/details.html.haml b/app/views/import/github/details.html.haml
new file mode 100644
index 00000000000..9056af1f129
--- /dev/null
+++ b/app/views/import/github/details.html.haml
@@ -0,0 +1,4 @@
+- add_to_breadcrumbs _('Create a new project'), new_project_path
+- page_title s_('Import|GitHub import details')
+
+.js-import-details
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 4a9f8be35c3..45b5a9408be 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -11,4 +11,5 @@
provider: 'github', paginatable: paginatable,
default_namespace: @namespace,
cancel_path: cancel_import_github_path,
+ details_path: details_import_github_path,
optional_stages: Gitlab::GithubImport::Settings.stages_array
diff --git a/app/views/jira_connect/branches/new.html.haml b/app/views/jira_connect/branches/new.html.haml
index 482012b2848..bb27e89abb9 100644
--- a/app/views/jira_connect/branches/new.html.haml
+++ b/app/views/jira_connect/branches/new.html.haml
@@ -1,4 +1,3 @@
-- @hide_breadcrumbs = true
- @hide_top_links = true
- @content_class = 'limit-container-width'
- page_title _('Create branch')
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 2dd6eab2e17..c608569e22c 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -1,6 +1,7 @@
- page_description brand_title unless page_description
- site_name = _('GitLab')
-%head{ prefix: "og: http://ogp.me/ns#" }
+- omit_og = sign_in_with_redirect?
+%head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } }
%meta{ charset: "utf-8" }
%title= page_title(site_name)
@@ -64,22 +65,23 @@
= yield :project_javascripts
- -# Open Graph - http://ogp.me/
- %meta{ property: 'og:type', content: "object" }
- %meta{ property: 'og:site_name', content: site_name }
- %meta{ property: 'og:title', content: page_title }
- %meta{ property: 'og:description', content: page_description }
- %meta{ property: 'og:image', content: page_image }
- %meta{ property: 'og:image:width', content: '64' }
- %meta{ property: 'og:image:height', content: '64' }
- %meta{ property: 'og:url', content: request.base_url + request.fullpath }
-
- -# Twitter Card - https://dev.twitter.com/cards/types/summary
- %meta{ property: 'twitter:card', content: "summary" }
- %meta{ property: 'twitter:title', content: page_title }
- %meta{ property: 'twitter:description', content: page_description }
- %meta{ property: 'twitter:image', content: page_image }
- = page_card_meta_tags
+ - unless omit_og
+ -# Open Graph - http://ogp.me/
+ %meta{ property: 'og:type', content: "object" }
+ %meta{ property: 'og:site_name', content: site_name }
+ %meta{ property: 'og:title', content: page_title }
+ %meta{ property: 'og:description', content: page_description }
+ %meta{ property: 'og:image', content: page_image }
+ %meta{ property: 'og:image:width', content: '64' }
+ %meta{ property: 'og:image:height', content: '64' }
+ %meta{ property: 'og:url', content: request.base_url + request.fullpath }
+
+ -# Twitter Card - https://dev.twitter.com/cards/types/summary
+ %meta{ property: 'twitter:card', content: "summary" }
+ %meta{ property: 'twitter:title', content: page_title }
+ %meta{ property: 'twitter:description', content: page_description }
+ %meta{ property: 'twitter:image', content: page_image }
+ = page_card_meta_tags
%meta{ name: "description", content: page_description }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 74567af3554..1a647249eb7 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -6,7 +6,7 @@
- group = @parent_group || @group
- sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user)
- - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel).to_json
+ - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json
%aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url } }
- if display_whats_new?
@@ -14,7 +14,7 @@
- elsif defined?(nav) && nav
= render "layouts/nav/sidebar/#{nav}"
- .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
+ .content-wrapper{ class: "#{@content_wrapper_class}" }
.mobile-overlay
= dispensable_render_if_exists 'layouts/header/verification_reminder'
.alert-wrapper.gl-force-block-formatting-context
@@ -34,10 +34,9 @@
= 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
- = yield :free_user_cap_alert
= yield :group_invite_members_banner
- - unless @hide_breadcrumbs
- = render "layouts/nav/breadcrumbs"
+ - unless @hide_top_bar
+ = render "layouts/nav/top_bar"
%div{ class: "#{container_class unless @no_container} #{@content_class}" }
%main.content{ id: "content-body", **page_itemtype }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
@@ -48,4 +47,4 @@
-# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab-cn/-/issues/81)
= render_if_exists "shared/footer/global_footer"
-= render "layouts/nav/top_nav_responsive", class: 'layout-page content-wrapper-margin' unless show_super_sidebar?
+= render "layouts/nav/top_nav_responsive", class: 'layout-page' unless show_super_sidebar?
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
deleted file mode 100644
index daf2c582de2..00000000000
--- a/app/views/layouts/_search.html.haml
+++ /dev/null
@@ -1,42 +0,0 @@
-.search.search-form{ data: { track_label: "navbar_search", track_action: "activate_form_input", track_value: "" } }
- = form_tag search_path, method: :get, class: 'form-inline form-control' do |_f|
- .search-input-container
- .search-input-wrap
- .dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: _('Search GitLab'),
- class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
- spellcheck: false,
- autocomplete: 'off',
- data: { issues_path: issues_dashboard_path,
- mr_path: merge_requests_dashboard_path,
- qa_selector: 'search_term_field' },
- aria: { label: _('Search GitLab') }
- %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
- .dropdown-menu.dropdown-select{ data: { testid: 'dashboard-search-options' } }
- = dropdown_content do
- %ul
- %li.dropdown-menu-empty-item
- %a
- = _('Loading...')
- = dropdown_loading
- = sprite_icon('search', css_class: 'search-icon')
- = sprite_icon('close', css_class: 'clear-icon js-clear-input')
-
- = hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata
- = hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata
-
- - if search_context.for_project? || search_context.for_group?
- = hidden_field_tag :scope, search_context.scope
- = hidden_field_tag :search_code, search_context.code_search?
-
- - ref = search_context.ref if can?(current_user, :read_code, search_context.project)
- = hidden_field_tag :snippets, search_context.for_snippets?
- = hidden_field_tag :repository_ref, ref
- = hidden_field_tag :nav_source, 'navbar'
-
- -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- - if ENV['RAILS_ENV'] == 'test'
- %noscript= button_tag 'Search'
- .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path,
- :'data-autocomplete-project-id' => search_context.project.try(:id),
- :'data-autocomplete-project-ref' => ref }
diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml
index 1ac5f0a8497..39f4a755340 100644
--- a/app/views/layouts/dashboard.html.haml
+++ b/app/views/layouts/dashboard.html.haml
@@ -1,5 +1,4 @@
-- page_title _("Dashboard")
-- header_title _("Dashboard"), root_path unless header_title
+- header_title _("Your work"), root_path
- @left_sidebar = true
- nav (@parent_group ? "group" : "your_work")
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 3532c6638ce..36a9a284e91 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/login'
!!! 5
%html.devise-layout-html{ class: system_message_class }
= render "layouts/head", { startup_filename: 'signin' }
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index cadba3f91e9..89aba85984f 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/login'
!!! 5
%html.devise-layout-html{ lang: "en", class: system_message_class }
= render "layouts/head"
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 40ec1ff199b..1f742279756 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -21,5 +21,6 @@
= dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert"
= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
+= dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group
= render template: base_layout || "layouts/application"
diff --git a/app/views/layouts/header/_current_user_dropdown_item.html.haml b/app/views/layouts/header/_current_user_dropdown_item.html.haml
index 3fded43ee4f..fa0a6364a15 100644
--- a/app/views/layouts/header/_current_user_dropdown_item.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown_item.html.haml
@@ -1,7 +1,7 @@
.gl-font-weight-bold
= current_user.name
- if current_user.status&.busy?
- %span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)")
+ = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning')
= current_user.to_reference
- if current_user.status
.user-status.d-flex.align-items-center.gl-mt-2.gl-mr-0.gl-font-sm.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 6d000c3e9ad..7156a0e5931 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -8,7 +8,7 @@
.title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3
.title
%span.gl-sr-only GitLab
- = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do
+ = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do
= brand_header_logo
.gl-display-flex.gl-align-items-center
- if Gitlab.com_and_canary?
@@ -31,10 +31,7 @@
%ul.nav.navbar-nav.gl-w-full.gl-align-items-center
%li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full
- unless current_controller?(:search)
- - if Feature.enabled?(:new_header_search)
- = render 'layouts/header_search'
- - else
- = render 'layouts/search'
+ = render 'layouts/header_search'
%li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
= link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) },
data: { toggle: 'tooltip', placement: 'bottom', container: 'body',
@@ -106,7 +103,7 @@
= gl_badge_tag({ size: :sm, variant: :info }, { class: "js-todos-count gl-ml-n2 #{'hidden' if todos_pending_count == 0}", "aria-label": _("Todos count") }) do
= todos_count_format(todos_pending_count)
%li.nav-item.header-help.dropdown.d-none.d-md-block
- = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown", track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation_top', track_experiment: 'cross_stage_fdm' } do
+ = link_to help_path, class: 'header-help-dropdown-toggle gl-relative', data: { toggle: "dropdown", track_action: 'click_question_mark_link', track_label: 'main_navigation', track_property: 'navigation_top' } do
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question-o')
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index f50df72afbc..38b9a9a5383 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -2,7 +2,6 @@
- if current_user_menu?(:help)
%li
= render 'layouts/header/gitlab_version'
- = render_if_exists 'layouts/header/help_dropdown/cross_stage_fdm'
= render 'layouts/header/whats_new_dropdown_item'
%li
= link_to _("Help"), help_path, data: {track_action: 'click_link', track_label: 'help', track_property: 'navigation_top'}
diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml
index b5cb8f2af37..83260377e72 100644
--- a/app/views/layouts/minimal.html.haml
+++ b/app/views/layouts/minimal.html.haml
@@ -8,10 +8,10 @@
= render 'peek/bar'
= render "layouts/header/empty"
.layout-page
- .content-wrapper.content-wrapper-margin.gl-pt-6{ class: 'gl-md-pt-11!' }
+ .content-wrapper.gl-pt-6{ class: 'gl-md-pt-11!' }
.alert-wrapper.gl-force-block-formatting-context
= render "layouts/broadcast"
- .limit-container-width{ class: container_class }
+ %div{ class: container_class }
%main#content-body.content
= render "layouts/flash" unless @hide_flash
= yield
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
deleted file mode 100644
index 06dff99718c..00000000000
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-- container = @no_breadcrumb_container ? 'container-fluid' : container_class
-- hide_top_links = @hide_top_links || false
-- unless @skip_current_level_breadcrumb
- - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
-
-.gl-relative
- .breadcrumbs{ class: [container, @content_class] }
- .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) }
- - if show_super_sidebar?
- = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { label: _('Expand sidebar') }, data: {toggle: 'tooltip', placement: 'right' } })
- - elsif defined?(@left_sidebar)
- = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } })
- %nav.breadcrumbs-links{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
- %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- - unless hide_top_links
- = header_title
- - if @breadcrumbs_extra_links
- - @breadcrumbs_extra_links.each do |extra|
- = breadcrumb_list_item link_to(extra[:text], extra[:link])
- = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after
- - unless @skip_current_level_breadcrumb
- %li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } }
- = link_to @breadcrumb_title, breadcrumb_title_link
- -# haml-lint:disable InlineJavaScript
- %script{ type: 'application/ld+json' }
- :plain
- #{schema_breadcrumb_json}
- = yield :header_content
diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml
new file mode 100644
index 00000000000..a0e03c9c0cf
--- /dev/null
+++ b/app/views/layouts/nav/_top_bar.html.haml
@@ -0,0 +1,14 @@
+- if show_super_sidebar?
+ - top_bar_class = 'top-bar-fixed container-fluid'
+ - top_bar_container_class = nil
+- else
+ - top_bar_class = [@no_top_bar_container ? 'container-fluid' : container_class, @content_class]
+ - top_bar_container_class = 'gl-border-b'
+
+%div{ class: top_bar_class }
+ .top-bar-container.gl-display-flex.gl-align-items-center{ :class => top_bar_container_class }
+ - if show_super_sidebar?
+ = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3 gl-mr-2', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } })
+ - elsif defined?(@left_sidebar)
+ = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3 gl-mr-2', data: { qa_selector: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } })
+ = render "layouts/nav/breadcrumbs/breadcrumbs"
diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml
new file mode 100644
index 00000000000..b5f067cf42f
--- /dev/null
+++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml
@@ -0,0 +1,20 @@
+- hide_top_links = @hide_top_links || false
+- unless @skip_current_level_breadcrumb
+ - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
+
+%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
+ %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
+ - unless hide_top_links
+ = header_title
+ - if @breadcrumbs_extra_links
+ - @breadcrumbs_extra_links.each do |extra|
+ = breadcrumb_list_item link_to(extra[:text], extra[:link])
+ = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after
+ - unless @skip_current_level_breadcrumb
+ %li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } }
+ = link_to @breadcrumb_title, breadcrumb_title_link
+ -# haml-lint:disable InlineJavaScript
+ %script{ type: 'application/ld+json' }
+ :plain
+ #{schema_breadcrumb_json}
+= yield :header_content
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 24b301fadce..bffc030dbd9 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -1,298 +1 @@
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation'), data: { qa_selector: 'admin_sidebar_content' } }
- .nav-sidebar-inner-scroll
- .context-header
- = link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
- %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', 's32'] }
- = sprite_icon('admin', size: 18)
- %span.sidebar-context-title
- = _('Admin Area')
- %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } }
- = nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics gitaly_servers cohorts], html_options: {class: 'home'}) do
- = link_to admin_root_path, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('overview')
- %span.nav-item-name
- = _('Overview')
- %ul.sidebar-sub-level-items
- = nav_link(controller: %w[dashboard admin admin/projects users groups gitaly_servers cohorts], html_options: { class: "fly-out-top-item" }) do
- = link_to admin_root_path do
- %strong.fly-out-top-item-name
- = _('Overview')
- %li.divider.fly-out-top-item
- = nav_link(controller: :dashboard, html_options: {class: 'home'}) do
- = link_to admin_root_path, title: _('Overview') do
- %span
- = _('Dashboard')
- = nav_link(controller: [:admin, 'admin/projects']) do
- = link_to admin_projects_path, title: _('Projects') do
- %span
- = _('Projects')
- = nav_link(controller: %w[users cohorts]) do
- = link_to admin_users_path, title: _('Users'), data: { qa_selector: 'admin_overview_users_link' } do
- %span
- = _('Users')
- = nav_link(controller: :groups) do
- = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'admin_overview_groups_link' } do
- %span
- = _('Groups')
- = nav_link(controller: [:admin, 'admin/topics']) do
- = link_to admin_topics_path, title: _('Topics') do
- %span
- = _('Topics')
- = nav_link(controller: :gitaly_servers) do
- = link_to admin_gitaly_servers_path, title: 'Gitaly Servers' do
- %span
- = _('Gitaly Servers')
-
- = nav_link(controller: %w[runners jobs]) do
- = link_to admin_runners_path, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('rocket')
- %span.nav-item-name
- = _('CI/CD')
- %ul.sidebar-sub-level-items
- = nav_link(controller: %w[runners jobs], html_options: { class: "fly-out-top-item" }) do
- = link_to admin_runners_path do
- %strong.fly-out-top-item-name
- = _('CI/CD')
- %li.divider.fly-out-top-item
- = nav_link(controller: :runners) do
- = link_to admin_runners_path, title: _('Runners') do
- %span
- = _('Runners')
- = nav_link(controller: :jobs) do
- = link_to admin_jobs_path, title: _('Jobs') do
- %span
- = _('Jobs')
-
- = nav_link(controller: admin_analytics_nav_links) do
- = link_to admin_dev_ops_reports_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('chart')
- %span.nav-item-name
- = _('Analytics')
-
- %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_analytics_submenu_content' } }
- = nav_link(controller: admin_analytics_nav_links, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_dev_ops_reports_path do
- %strong.fly-out-top-item-name
- = _('Analytics')
- %li.divider.fly-out-top-item
- = nav_link(controller: :dev_ops_report) do
- = link_to admin_dev_ops_reports_path, title: _('DevOps Reports') do
- %span
- = _('DevOps Reports')
- = nav_link(controller: :usage_trends) do
- = link_to admin_usage_trends_path, title: _('Usage Trends') do
- %span
- = _('Usage Trends')
-
- = nav_link(controller: admin_monitoring_nav_links) do
- = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_menu_link' }, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('monitor')
- %span.nav-item-name
- = _('Monitoring')
-
- %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } }
- = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_system_info_path do
- %strong.fly-out-top-item-name
- = _('Monitoring')
- %li.divider.fly-out-top-item
- = nav_link(controller: :system_info) do
- = link_to admin_system_info_path, title: _('System Info') do
- %span
- = _('System Info')
- = nav_link(controller: :background_migrations) do
- = link_to admin_background_migrations_path, title: _('Background Migrations') do
- %span
- = _('Background Migrations')
- = nav_link(controller: :background_jobs) do
- = link_to admin_background_jobs_path, title: _('Background Jobs') do
- %span
- = _('Background Jobs')
- = nav_link(controller: :health_check) do
- = link_to admin_health_check_path, title: _('Health Check') do
- %span
- = _('Health Check')
- - if Gitlab::CurrentSettings.current_application_settings.grafana_enabled?
- = nav_link do
- = link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard'), rel: 'noopener noreferrer' do
- %span
- = _('Metrics Dashboard')
- = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar'
-
- = nav_link(controller: :broadcast_messages) do
- = link_to admin_broadcast_messages_path do
- .nav-icon-container
- = sprite_icon('messages')
- %span.nav-item-name
- = _('Messages')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :broadcast_messages, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_broadcast_messages_path do
- %strong.fly-out-top-item-name
- = _('Messages')
-
- = nav_link(controller: [:hooks, :hook_logs]) do
- = link_to admin_hooks_path do
- .nav-icon-container
- = sprite_icon('hook')
- %span.nav-item-name
- = _('System Hooks')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: [:hooks, :hook_logs], html_options: { class: "fly-out-top-item" }) do
- = link_to admin_hooks_path do
- %strong.fly-out-top-item-name
- = _('System Hooks')
-
- = nav_link(controller: :applications) do
- = link_to admin_applications_path do
- .nav-icon-container
- = sprite_icon('applications')
- %span.nav-item-name
- = _('Applications')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :applications, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_applications_path do
- %strong.fly-out-top-item-name
- = _('Applications')
-
- = nav_link(controller: :abuse_reports) do
- = link_to admin_abuse_reports_path do
- .nav-icon-container
- = sprite_icon('slight-frown')
- %span.nav-item-name
- = _('Abuse Reports')
- = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_abuse_reports_path do
- %strong.fly-out-top-item-name
- = _('Abuse Reports')
- = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm
-
- = render_if_exists 'layouts/nav/sidebar/licenses_link'
-
- - if instance_clusters_enabled?
- = nav_link(controller: :clusters) do
- = link_to admin_clusters_path do
- .nav-icon-container
- = sprite_icon('cloud-gear')
- %span.nav-item-name
- = _('Kubernetes')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_clusters_path do
- %strong.fly-out-top-item-name
- = _('Kubernetes')
-
- - if anti_spam_service_enabled?
- = nav_link(controller: :spam_logs) do
- = link_to admin_spam_logs_path do
- .nav-icon-container
- = sprite_icon('spam')
- %span.nav-item-name
- = _('Spam Logs')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :spam_logs, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_spam_logs_path do
- %strong.fly-out-top-item-name
- = _('Spam Logs')
-
- = render_if_exists 'layouts/nav/sidebar/push_rules_link'
-
- = render_if_exists 'layouts/nav/ee/admin/geo_sidebar'
-
- = nav_link(controller: :deploy_keys) do
- = link_to admin_deploy_keys_path do
- .nav-icon-container
- = sprite_icon('key')
- %span.nav-item-name
- = _('Deploy Keys')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :deploy_keys, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_deploy_keys_path do
- %strong.fly-out-top-item-name
- = _('Deploy Keys')
-
- = render_if_exists 'layouts/nav/sidebar/credentials_link'
-
- = nav_link(controller: :labels) do
- = link_to admin_labels_path do
- .nav-icon-container
- = sprite_icon('labels')
- %span.nav-item-name
- = _('Labels')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :labels, html_options: { class: "fly-out-top-item" }) do
- = link_to admin_labels_path do
- %strong.fly-out-top-item-name
- = _('Labels')
-
- = nav_link(controller: [:application_settings, :integrations, :appearances]) do
- = link_to general_admin_application_settings_path, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('settings')
- %span.nav-item-name{ data: { qa_selector: 'admin_settings_menu_link' } }
- = _('Settings')
-
- %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } }
- -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml`
- = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" }) do
- = link_to general_admin_application_settings_path do
- %strong.fly-out-top-item-name
- = _('Settings')
- %li.divider.fly-out-top-item
- = nav_link(path: 'application_settings#general') do
- = link_to general_admin_application_settings_path, title: _('General'), data: { qa_selector: 'admin_settings_general_link' } do
- %span
- = _('General')
-
- = render_if_exists 'layouts/nav/sidebar/advanced_search', data: { qa_selector: 'admin_settings_advanced_search_link' }
-
- - if instance_level_integrations?
- = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
- = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'admin_settings_integrations_link' } do
- %span
- = _('Integrations')
- = nav_link(path: 'application_settings#repository') do
- = link_to repository_admin_application_settings_path, title: _('Repository'), data: { qa_selector: 'admin_settings_repository_link' } do
- %span
- = _('Repository')
- - if Gitlab.ee? && License.feature_available?(:custom_file_templates)
- = nav_link(path: 'application_settings#templates') do
- = link_to templates_admin_application_settings_path, title: _('Templates'), data: { qa_selector: 'admin_settings_templates_link' } do
- %span
- = _('Templates')
- = nav_link(path: 'application_settings#ci_cd') do
- = link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do
- %span
- = _('CI/CD')
- = nav_link(path: 'application_settings#reporting') do
- = link_to reporting_admin_application_settings_path, title: _('Reporting') do
- %span
- = _('Reporting')
- = nav_link(path: 'application_settings#metrics_and_profiling') do
- = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), data: { qa_selector: 'admin_settings_metrics_and_profiling_link' } do
- %span
- = _('Metrics and profiling')
- = nav_link(path: ['application_settings#service_usage_data']) do
- = link_to service_usage_data_admin_application_settings_path, title: _('Service usage data') do
- %span
- = _('Service usage data')
- = nav_link(path: 'application_settings#network') do
- = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do
- %span
- = _('Network')
- = nav_link(controller: :appearances) do
- = link_to admin_application_settings_appearances_path do
- %span
- = _('Appearance')
- = nav_link(path: 'application_settings#preferences') do
- = link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do
- %span
- = _('Preferences')
-
- = render 'shared/sidebar_toggle_button'
+= render partial: 'shared/nav/sidebar', object: Sidebars::Admin::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil))
diff --git a/app/views/layouts/nav/sidebar/_search.html.haml b/app/views/layouts/nav/sidebar/_search.html.haml
new file mode 100644
index 00000000000..956079c351a
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_search.html.haml
@@ -0,0 +1 @@
+-# if this file is missing empty or not the old left menu throws error
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 09fa8575106..214b41d5ab6 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -10,6 +10,7 @@
- content_for :flash_message do
= dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project
+ = dispensable_render_if_exists "projects/deprecate_license_check_alert", project: @project
- content_for :project_javascripts do
- project = @target_project || @project
@@ -23,5 +24,6 @@
= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
+= dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project
= render template: "layouts/application"
diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml
index 4d0bb36d4b5..8cbea686d51 100644
--- a/app/views/layouts/signup_onboarding.html.haml
+++ b/app/views/layouts/signup_onboarding.html.haml
@@ -1,6 +1,7 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/signup'
+ - add_page_specific_style 'page_bundles/login'
= render "layouts/head"
%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"
diff --git a/app/views/layouts/simple_registration.html.haml b/app/views/layouts/simple_registration.html.haml
index dc7ec25c96e..a68941b031f 100644
--- a/app/views/layouts/simple_registration.html.haml
+++ b/app/views/layouts/simple_registration.html.haml
@@ -1,6 +1,7 @@
!!! 5
%html{ lang: "en" }
= render "layouts/head"
+ - add_page_specific_style 'page_bundles/login'
%body.login-page.application.navless{ class: user_application_theme, data: { page: body_data_page } }
= render "layouts/header/logo_with_title"
= render "layouts/broadcast"
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index e396f38499a..ad566f262cf 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,7 +1,7 @@
- page_title _("Snippets")
-- header_title _("Snippets"), dashboard_snippets_path
+- header_title _("Your work"), root_path
+- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path
- snippets_upload_path = snippets_upload_path(@snippet, current_user)
-
- @left_sidebar = true
- if current_user
diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml
index 032be73f70c..71c622d7a62 100644
--- a/app/views/layouts/terms.html.haml
+++ b/app/views/layouts/terms.html.haml
@@ -1,6 +1,6 @@
!!! 5
- add_page_specific_style 'page_bundles/terms'
-- @hide_breadcrumbs = true
+- @hide_top_bar = true
- body_classes = [user_application_theme]
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
diff --git a/app/views/notify/access_token_created_email.html.haml b/app/views/notify/access_token_created_email.html.haml
index 9eea8f44142..8216994f8fa 100644
--- a/app/views/notify/access_token_created_email.html.haml
+++ b/app/views/notify/access_token_created_email.html.haml
@@ -1,7 +1,7 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
- = html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name }
+ = html_escape(_('A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe }
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/new_achievement_email.html.haml b/app/views/notify/new_achievement_email.html.haml
new file mode 100644
index 00000000000..f802684fb56
--- /dev/null
+++ b/app/views/notify/new_achievement_email.html.haml
@@ -0,0 +1,7 @@
+- namespace_link = link_to(@achievement.namespace.full_path, group_url(@achievement.namespace))
+- profile_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">' % { url: user_url(@user) }
+
+%p
+ = sprintf(s_("Achievements|%{namespace_link} awarded you the %{bold_start}%{achievement_name}%{bold_end} achievement!"), { namespace_link: namespace_link, achievement_name: @achievement.name, bold_start: '<b>', bold_end: '</b>' }).html_safe
+%p
+ = sprintf(s_("Achievements|View your achievements on your %{link_start}profile%{link_end}."), { link_start: profile_link_start, link_end: '</a>' }).html_safe
diff --git a/app/views/notify/new_achievement_email.text.erb b/app/views/notify/new_achievement_email.text.erb
new file mode 100644
index 00000000000..6d66c1130bf
--- /dev/null
+++ b/app/views/notify/new_achievement_email.text.erb
@@ -0,0 +1,4 @@
+<%= sprintf(s_("Achievements|%{namespace_full_path} awarded you the %{achievement_name} achievement!"),
+ { namespace_full_path: @achievement.namespace.full_path, achievement_name: @achievement.name }) %>
+
+<%= s_("Achievements|View your achievements on your profile.") %>
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index dc0d8fc80b0..f37c8ffa515 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project, @issue, { only_path: false }]) %>
-Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
+Assignee changed<%= " from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/service_desk_custom_email_verification_email.text.erb b/app/views/notify/service_desk_custom_email_verification_email.text.erb
new file mode 100644
index 00000000000..c3d49a67263
--- /dev/null
+++ b/app/views/notify/service_desk_custom_email_verification_email.text.erb
@@ -0,0 +1,4 @@
+This email is auto-generated. It verifies the ownership of the entered Service Desk custom email address and
+correct functionality of email forwarding.
+
+Verification token: <%= @verification_token %>
diff --git a/app/views/notify/service_desk_verification_result_email.html.haml b/app/views/notify/service_desk_verification_result_email.html.haml
new file mode 100644
index 00000000000..d63177e4a42
--- /dev/null
+++ b/app/views/notify/service_desk_verification_result_email.html.haml
@@ -0,0 +1,58 @@
+- project_link = @service_desk_setting.project.web_url
+- project_link_start = '<a href="%{project_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { project_link: project_link }
+- project_name = @service_desk_setting.project.human_name
+- project_link_end = '</a>'.html_safe
+- settings_link = edit_project_url(@service_desk_setting.project, anchor: 'js-service-desk')
+- settings_link_start = '<a href="%{settings_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { settings_link: settings_link }
+- settings_link_end = '</a>'.html_safe
+- strong_open = '<strong>'.html_safe
+- strong_close = '</strong>'.html_safe
+- email_address = @service_desk_setting.custom_email
+- verify_email_address = @service_desk_setting.custom_email_address_for_verification
+- code_open = '<code>'.html_safe
+- code_end = '</code>'.html_safe
+
+%tr
+ %td.text-content
+ - if @verification.verified?
+ %h1{ :style => "margin-top:0;" }
+ = s_("Notify|Email successfully verified")
+ %p
+ = html_escape(s_('Notify|Your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} was verified successfully.')) % { email_address: email_address, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close }
+ %p
+ = html_escape(s_('Notify|To enable the custom email address, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end }
+ - else
+ %h1{ :style => "margin-top:0;" }
+ = s_("Notify|Email could not be verified")
+ %p
+ = html_escape(s_('Notify|We could not verify your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end}.')) % { email_address: email_address, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close }
+ - if @verification.smtp_host_issue?
+ %p
+ %b
+ = s_('Notify|SMTP host issue:')
+ = s_('Notify|We were not able to make a connection to the specified host or there was an SSL issue.')
+ - if @verification.invalid_credentials?
+ %p
+ %b
+ = s_('Notify|Invalid credentials:')
+ = s_('Notify|The given credentials (username and password) were rejected by the SMTP server.')
+ - if @verification.mail_not_received_within_timeframe?
+ %p
+ %b
+ = s_('Notify|Verification email not received within timeframe:')
+ = html_escape(s_('Notify|We did not receive the verification email we sent out to %{strong_open}%{email_address}%{strong_close} in time.')) % { email_address: verify_email_address, strong_open: strong_open, strong_close: strong_close }
+ %p
+ = s_('Notify|We wait for 30 minutes for messages to appear in your instance\'s Service Desk inbox.')
+ = s_('Notify|Please check that your service provider supports email subaddressing and that you have set up email forwarding correctly.')
+ - if @verification.incorrect_from?
+ %p
+ %b
+ = html_escape(s_('Notify|Incorrect %{code_open}From%{code_end} header:')) % { code_open: code_open, code_end: code_end }
+ = html_escape(s_('Notify|Check your forwarding settings and make sure the original email sender remains in the %{code_open}From%{code_end} header.')) % { code_open: code_open, code_end: code_end }
+ - if @verification.incorrect_token?
+ %p
+ %b
+ = s_('Notify|Incorrect verification token:')
+ = s_('Notify|We could not verify that we received the email we sent to your email inbox.')
+ %p
+ = html_escape(s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.')) % { settings_link_start: settings_link_start, settings_link_end: settings_link_end }
diff --git a/app/views/notify/service_desk_verification_result_email.text.erb b/app/views/notify/service_desk_verification_result_email.text.erb
new file mode 100644
index 00000000000..a78e3b19d1e
--- /dev/null
+++ b/app/views/notify/service_desk_verification_result_email.text.erb
@@ -0,0 +1,38 @@
+<% project_name = @service_desk_setting.project.human_name %>
+<% email_address = @service_desk_setting.custom_email %>
+<% verify_email_address = @service_desk_setting.custom_email_address_for_verification %>
+
+<% if @verification.verified? %>
+ <%= s_("Notify|Email successfully verified") %>
+
+ <%= s_('Notify|Your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} was verified successfully.') % { email_address: email_address, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %>
+
+ <%= s_('Notify|To enable the custom email address, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %>
+<% else %>
+ <%= s_("Notify|Email could not be verified") %>
+
+ <%= s_('Notify|We could not verify your email address %{strong_open}%{email_address}%{strong_close} for the Service Desk of %{project_link_start}%{project_name}%{project_link_end}.') % { email_address: email_address, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %>
+
+ <% if @verification.smtp_host_issue? %>
+ <%= s_('Notify|SMTP host issue:') %>
+ <%= s_('Notify|We were not able to make a connection to the specified host or there was an SSL issue.') %>
+ <% elsif @verification.invalid_credentials? %>
+ <%= s_('Notify|Invalid credentials:') %>
+ <%= s_('Notify|The given credentials (username and password) were rejected by the SMTP server.') %>
+ <% elsif @verification.mail_not_received_within_timeframe? %>
+ <%= s_('Notify|Verification email not received within timeframe:') %>
+ <%= s_('Notify|We did not receive the verification email we sent out to %{strong_open}%{email_address}%{strong_close} in time.') % { email_address: verify_email_address, strong_open: '', strong_close: '' } %>
+
+ <%= s_('Notify|We wait for 30 minutes for messages to appear in your instance\'s Service Desk inbox.') %>
+
+ <%= s_('Notify|Please check that your service provider supports email subaddressing and that you have set up email forwarding correctly.') %>
+ <% elsif @verification.incorrect_from? %>
+ <%= s_('Notify|Incorrect %{code_open}From%{code_end} header:') % { code_open: '', code_end: '' } %>
+ <%= s_('Notify|Check your forwarding settings and make sure the original email sender remains in the %{code_open}From%{code_end} header.') % { code_open: '', code_end: '' } %>
+ <% elsif @verification.incorrect_token? %>
+ <%= s_('Notify|Incorrect verification token:') %>
+ <%= s_('Notify|We could not verify that we received the email we sent to your email inbox.') %>
+ <% end %>
+
+ <%= s_('Notify|To restart the verification process, go to your %{settings_link_start}project\'s Service Desk settings page%{settings_link_end}.') % { settings_link_start: '', settings_link_end: '' } %>
+<% end %>
diff --git a/app/views/notify/service_desk_verification_triggered_email.html.haml b/app/views/notify/service_desk_verification_triggered_email.html.haml
new file mode 100644
index 00000000000..f2174af9615
--- /dev/null
+++ b/app/views/notify/service_desk_verification_triggered_email.html.haml
@@ -0,0 +1,18 @@
+- user_name = '@' + @triggerer.username
+- project_link = @service_desk_setting.project.web_url
+- project_link_start = '<a href="%{project_link}" target="_blank" rel="noopener noreferrer" class="highlight">'.html_safe % { project_link: project_link}
+- project_name = @service_desk_setting.project.human_name
+- project_link_end = '</a>'.html_safe
+- strong_open = '<strong>'.html_safe
+- strong_close = '</strong>'.html_safe
+- email_address = @service_desk_setting.custom_email
+- smtp_host = @smtp_address
+
+%tr
+ %td.text-content
+ %p
+ = html_escape(s_('Notify|%{strong_open}%{user_name}%{strong_close} updated the custom email address credentials for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} and triggered the verification process.')) % { user_name: user_name, project_link_start: project_link_start, project_name: project_name, project_link_end: project_link_end, strong_open: strong_open, strong_close: strong_close }
+ %p
+ = html_escape(s_('Notify|The provided custom email address is %{strong_open}%{email_address}%{strong_close} and uses the SMTP host %{strong_open}%{smtp_host}%{strong_close}.')) % { email_address: email_address, smtp_host: smtp_host, strong_open: strong_open, strong_close: strong_close }
+ %p
+ = s_('Notify|If this was a mistake you can change these settings or deactivate the custom email address in the project settings.')
diff --git a/app/views/notify/service_desk_verification_triggered_email.text.erb b/app/views/notify/service_desk_verification_triggered_email.text.erb
new file mode 100644
index 00000000000..98c79e2d2f1
--- /dev/null
+++ b/app/views/notify/service_desk_verification_triggered_email.text.erb
@@ -0,0 +1,10 @@
+<% user_name = '@' + @triggerer.username %>
+<% project_name = @service_desk_setting.project.human_name %>
+<% email_address = @service_desk_setting.custom_email %>
+<% smtp_host = @smtp_address %>
+
+<%= s_('Notify|%{strong_open}%{user_name}%{strong_close} updated the custom email address credentials for the Service Desk of %{project_link_start}%{project_name}%{project_link_end} and triggered the verification process.') % { user_name: user_name, project_link_start: '', project_name: project_name, project_link_end: '', strong_open: '', strong_close: '' } %>
+
+<%= s_('Notify|The provided custom email address is %{strong_open}%{email_address}%{strong_close} and uses the SMTP host %{strong_open}%{smtp_host}%{strong_close}.') % { email_address: email_address, smtp_host: smtp_host, strong_open: '', strong_close: '' } %>
+
+<%= s_('Notify|If this was a mistake you can change these settings or deactivate the custom email address in the project settings.') %>
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
index 8914bfab336..cc58ad248e8 100644
--- a/app/views/peek/_bar.html.haml
+++ b/app/views/peek/_bar.html.haml
@@ -3,5 +3,6 @@
#js-peek{ data: { env: Peek.env,
request_id: peek_request_id,
stats_url: ENV.fetch('GITLAB_PERFORMANCE_BAR_STATS_URL', ''),
- peek_url: "#{peek_routes_path}/results" },
+ peek_url: "#{peek_routes_path}/results",
+ request_method: request.method, },
class: Peek.env }
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index bc0d615bb64..1065ddb59e6 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,5 +1,4 @@
- page_title _('Account')
-- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
= render Pajamas::AlertComponent.new(alert_options: { class: 'gl-my-5' },
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index e9e6ca3ecce..e2b6008934c 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -1,5 +1,4 @@
- page_title _('Active Sessions')
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index 9997c8c4b4c..6072042001c 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,5 +1,4 @@
- page_title _('Authentication log')
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 6de5f183981..8a1814e55c3 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,5 +1,4 @@
- page_title _('Chat')
-- @content_class = "limit-container-width" unless fluid_layout
- @hide_search_settings = true
.row.gl-mt-5.js-search-settings-section
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index 8ff2e6f34a0..bc30ccc5821 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -4,10 +4,10 @@
.gl-max-w-80.gl-mx-auto.gl-mt-6
= render Pajamas::CardComponent.new do |c|
- c.header do
- %h4.gl-m-0= s_('SlackIntegration|Authorize GitLab for Slack app (%{user}) to use your account?').html_safe % { user: @chat_name_params[:chat_name] }
+ %h4.gl-m-0= sprintf(s_('Integrations|Authorize %{integration_name} (%{user}) to use your account?'), { user: @chat_name_params[:chat_name], integration_name: @integration_name })
- c.body do
%p
- = s_('SlackIntegration|An application called GitLab for Slack app is requesting access to your GitLab account. This application was created by GitLab Inc.')
+ = sprintf(s_('Integrations|An application called %{integration_name} is requesting access to your GitLab account. This application was created by GitLab Inc.'), { integration_name: @integration_name })
%p
= _('This application will be able to:')
%ul
diff --git a/app/views/profiles/comment_templates/index.html.haml b/app/views/profiles/comment_templates/index.html.haml
new file mode 100644
index 00000000000..dd5b43aa802
--- /dev/null
+++ b/app/views/profiles/comment_templates/index.html.haml
@@ -0,0 +1,10 @@
+- page_title _('Comment Templates')
+
+#js-comment-templates-root.row.gl-mt-5{ data: { base_path: profile_comment_templates_path } }
+ .col-lg-4
+ %h4.gl-mt-0
+ = page_title
+ %p
+ = _('Comment templates can be used when creating comments inside issues, merge requests, and epics.')
+ .col-lg-8
+ = gl_loading_icon(size: 'lg')
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index f4513d15a30..53db00c1638 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,5 +1,4 @@
- page_title _('Emails')
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
@@ -56,7 +55,7 @@
%li= s_('Profiles|Public email')
- if email.email == current_user.notification_email_or_default
%li= s_('Profiles|Notification email')
- .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-wrap-reverse.gl-gap-3
+ .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-reverse.gl-gap-3
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
= link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default'
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 539a0cd1f0e..d018035c5d6 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,6 +1,5 @@
- page_title _('GPG Keys')
- add_page_specific_style 'page_bundles/profile'
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 69e92b9e508..9f1614d4f49 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,6 +1,5 @@
- page_title _('SSH Keys')
- add_page_specific_style 'page_bundles/profile'
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 09c16b0c038..f5fed281e20 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,5 +1,4 @@
- add_to_breadcrumbs _('SSH Keys'), profile_keys_path
- breadcrumb_title @key.title
- page_title @key.title, _('SSH Keys')
-- @content_class = "limit-container-width" unless fluid_layout
= render "key_details"
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index efc1e23d9b4..c757f774d4e 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,6 +1,5 @@
- add_page_specific_style 'page_bundles/notifications'
- page_title _('Notifications')
-- @content_class = "limit-container-width" unless fluid_layout
%div
- if @user.errors.any?
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 99c89dcebb4..b6d12bbefc6 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _('Edit Password')
- page_title _('Password')
-- @content_class = "limit-container-width" unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 82df6b1b2c7..bc3f63372a3 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -2,7 +2,6 @@
- page_title s_('AccessTokens|Personal Access Tokens')
- type = _('personal access token')
- type_plural = _('personal access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 5f74a4c4427..c16469bbf79 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,6 +1,5 @@
- page_title _('Preferences')
- add_page_specific_style 'page_bundles/profiles/preferences'
-- @content_class = "limit-container-width" unless fluid_layout
- user_theme_id = Gitlab::Themes.for_user(@user).id
- user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id
- user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json
@@ -37,7 +36,7 @@
%h4.gl-mt-0
= s_('Preferences|Syntax highlighting theme')
%p
- = s_('Preferences|This setting allows you to customize the appearance of the syntax.')
+ = s_('Preferences|Customize the appearance of the syntax.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank', rel: 'noopener noreferrer'
.col-lg-8.syntax-theme
@@ -69,7 +68,7 @@
%h4.gl-mt-0
= s_('Preferences|Behavior')
%p
- = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
+ = s_('Preferences|Customize the behavior of the system layout and default views.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank', rel: 'noopener noreferrer'
.col-lg-8
@@ -79,7 +78,7 @@
= f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select'
.form-text.text-muted
= s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
- .js-listbox-input{ data: { label: s_('Preferences|Dashboard'), description: s_('Preferences|Choose what content you want to see by default on your dashboard.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } }
+ .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } }
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
diff --git a/app/views/profiles/saved_replies/index.html.haml b/app/views/profiles/saved_replies/index.html.haml
deleted file mode 100644
index 2ae7a092249..00000000000
--- a/app/views/profiles/saved_replies/index.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- page_title _('Saved Replies')
-
-#js-saved-replies-root.row.gl-mt-5{ data: { base_path: profile_saved_replies_path } }
- .col-lg-4
- %h4.gl-mt-0
- = page_title
- %p
- = _('Saved replies can be used when creating comments inside issues, merge requests, and epics.')
- .col-lg-8
- = gl_loading_icon(size: 'lg')
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 659b218bdef..ba17078f4c4 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,7 +1,6 @@
- breadcrumb_title s_("Profiles|Edit Profile")
- page_title s_("Profiles|Edit Profile")
- add_page_specific_style 'page_bundles/profile'
-- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
= 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|
@@ -158,8 +157,13 @@
%legend.col-form-label.col-form-label
= s_("Profiles|Private contributions")
= f.gitlab_ui_checkbox_component :include_private_contributions,
- s_('Profiles|Include private contributions on my profile'),
+ s_('Profiles|Include private contributions on your profile'),
help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_("Profiles|Achievements")
+ = f.gitlab_ui_checkbox_component :achievements_enabled,
+ s_('Profiles|Display achievements on your profile')
.row.js-hide-when-nothing-matches-search
.col-lg-12
%hr
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 61fe6ba8e47..9cc7f6bdd49 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,8 +1,6 @@
- breadcrumb_title _('Two-Factor Authentication')
- page_title _('Two-Factor Authentication'), _('Account')
- add_to_breadcrumbs _('Account'), profile_account_path
-- @content_class = "limit-container-width" unless fluid_layout
-- webauthn_enabled = Feature.enabled?(:webauthn)
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.gl-mt-3
@@ -68,7 +66,7 @@
%p
= _('Set up a hardware device to enable two-factor authentication (2FA).')
%p
- - if webauthn_enabled && Feature.enabled?(:webauthn_without_totp)
+ - if Feature.enabled?(:webauthn_without_totp)
= _("Not all browsers support WebAuthn. You must save your recovery codes after you first register a two-factor authenticator to be able to sign in, even from an unsupported browser.")
- else
= _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in, even from an unsupported browser.")
@@ -134,7 +132,7 @@
dismissible: false) do |c|
= c.body do
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
- .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } }
+ .js-manage-two-factor-form{ data: { current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } }
- else
%p
= _("Register a one-time password authenticator or a WebAuthn device first.")
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 6ac084b7749..5c7f83fc579 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -1,4 +1,3 @@
-- @no_breadcrumb_border = true
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
- ref = local_assigns.fetch(:ref) { current_ref }
@@ -7,18 +6,17 @@
- if readme_path = @project.repository.readme_path
- add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json")
-#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } }
- .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-2
+#tree-holder.tree-holder.clearfix.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } }
+ .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5
#js-last-commit.gl-m-auto
= gl_loading_icon(size: 'md')
- #js-code-owners
+ #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
= render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview
- - if project.forked? && Feature.enabled?(:fork_divergence_counts, @project.fork_source)
-
- #js-fork-info{ data: vue_fork_divergence_data(project, ref), project_id: @project.id }
+ - if project.forked?
+ #js-fork-info{ data: vue_fork_divergence_data(project, ref) }
- if is_project_overview
.project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } }
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index 2d9f7e49ddc..dc0c9547901 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -10,5 +10,5 @@
- if show_auto_devops_callout?(@project)
= render 'shared/auto_devops_callout'
= render_if_exists 'projects/above_size_limit_warning', project: project
- = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render_if_exists 'shared/shared_runners_minutes_limit', project: project
= render_if_exists 'projects/terraform_banner', project: project
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 65fd02b291c..9cb5ec39de2 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,9 +1,8 @@
- empty_repo = @project.empty_repo?
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- emails_disabled = @project.emails_disabled?
-- cache_enabled = Feature.enabled?(:cache_home_panel, @project, type: :development)
-.project-home-panel.js-show-on-project-root.gl-mt-2.gl-mb-5{ class: [("empty-project" if empty_repo)] }
+.project-home-panel.js-show-on-project-root.gl-mt-4.gl-mb-5{ class: [("empty-project" if empty_repo)] }
.gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3.gl-gap-5
.home-panel-title-row.gl-display-flex.gl-align-items-center
%div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' }
@@ -25,28 +24,26 @@
%span.gl-ml-3.gl-mb-3
= render 'shared/members/access_request_links', source: @project
- = cache_if(cache_enabled, [@project, @project.star_count, @project.forks_count, :buttons, current_user, @notification_setting], expires_in: 1.day) do
- .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
- - if current_user
- - if current_user.admin?
- = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'),
- data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
- = sprite_icon('admin')
- - if @notification_setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } }
+ .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
+ - if current_user
+ - if current_user.admin?
+ = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'),
+ data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
+ = sprite_icon('admin')
+ - if @notification_setting
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } }
- = render 'projects/buttons/star'
- = render 'projects/buttons/fork'
+ = render 'projects/buttons/star'
+ = render 'projects/buttons/fork'
- if can?(current_user, :read_code, @project)
- = cache_if(cache_enabled, [@project, :read_code], expires_in: 1.minute) do
- %nav.project-stats
- - if @project.empty_repo?
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
- - else
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ %nav.project-stats
+ - if @project.empty_repo?
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ - else
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
.gl-my-3
- = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled
+ = render "shared/projects/topics", project: @project
.home-panel-home-desc.mt-1
- if @project.description.present?
.home-panel-description.text-break
@@ -55,15 +52,6 @@
%button.btn.gl-button.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more")
- - if @project.forked? && Feature.disabled?(:fork_divergence_counts, @project.fork_source)
- %p
- - source = visible_fork_source(@project)
- - if source
- #{ s_('ForkedFromProjectPath|Forked from') }
- = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' }
- - else
- = s_('ForkedFromProjectPath|Forked from an inaccessible project.')
-
= render_if_exists "projects/home_mirror"
- if @project.badges.present?
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index ed238dab4ff..dec3199ffe1 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -7,7 +7,6 @@
%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: '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 bfc1e77118a..260c2b2272e 100644
--- a/app/views/projects/_remove_fork.html.haml
+++ b/app/views/projects/_remove_fork.html.haml
@@ -7,6 +7,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: 'remove-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
+ %strong= _('After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks.')
.js-confirm-danger{ data: remove_fork_project_confirm_json(@project, remove_form_id) }
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 349cd88437f..7654677d8a8 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -12,7 +12,7 @@
enabled: "#{@project.service_desk_enabled}",
incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled),
custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
- custom_email_enabled: "#{Gitlab::ServiceDeskEmail.enabled?}",
+ custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}",
selected_template: "#{@project.service_desk_setting&.issue_template_key}",
selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}",
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
diff --git a/app/views/projects/_terraform_banner.html.haml b/app/views/projects/_terraform_banner.html.haml
index 881e4ccd9df..24711fc39d8 100644
--- a/app/views/projects/_terraform_banner.html.haml
+++ b/app/views/projects/_terraform_banner.html.haml
@@ -1,5 +1,3 @@
-- @content_class = "container-limited limit-container-width" unless fluid_layout
-
- if show_terraform_banner?(project)
.container-fluid{ class: @content_class }
.js-terraform-notification{ data: { terraform_image_path: image_path('illustrations/third-party-logos/ci_cd-template-logos/terraform.svg') } }
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index ee7ca9cd351..a56d398d3a0 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,15 +1,17 @@
- page_title _("Blame"), @blob.path, @ref
- add_page_specific_style 'page_bundles/tree'
-- if @streaming_enabled && total_extra_pages > 0
+- blame_streaming_url = blame_pages_streaming_url(@id, @project)
+
+- if @blame_mode.streaming? && @blame_pagination.total_extra_pages > 0
- content_for :startup_js do
= javascript_tag do
:plain
window.blamePageStream = (() => {
- const url = new URL("#{@blame_pages_url}");
+ const url = new URL("#{blame_streaming_url}");
url.searchParams.set('page', 2);
return fetch(url).then(response => response.body);
})();
-- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page, total_extra_pages: total_extra_pages - 1, pages_url: @blame_pages_url }
+- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_pagination.per_page, total_extra_pages: @blame_pagination.total_extra_pages - 1, pages_url: blame_streaming_url }
#blob-content-holder.tree-holder.js-per-page{ data: dataset }
= render "projects/blob/breadcrumb", blob: @blob, blame: true
@@ -35,21 +37,20 @@
.blame-table-wrapper
= render partial: 'page'
- - if @streaming_enabled
+ - if @blame_mode.streaming?
#blame-stream-container.blame-stream-container
- - if @blame_pagination && @blame_pagination.total_pages > 1
+ - if @blame_mode.pagination? && @blame_pagination.total_pages > 1
.gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100
- = render Pajamas::ButtonComponent.new(href: @entire_blame_path, size: :small, button_options: { class: 'gl-mt-3' }) do |c|
+ = render Pajamas::ButtonComponent.new(href: entire_blame_path(@id, @project, @blame_mode), size: :small, button_options: { class: 'gl-mt-3' }) do |c|
= _('Show full blame')
- - if @streaming_enabled
+ - if @blame_mode.streaming?
#blame-stream-loading.blame-stream-loading
.gradient
= gl_loading_icon(size: 'sm')
%span.gl-mx-2
= _('Loading full blame...')
- - if @blame_pagination
- = paginate(@blame_pagination, theme: "gitlab")
-
+ - if @blame_mode.pagination?
+ = paginate(@blame_pagination.paginator, theme: "gitlab")
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 17d5ef69b76..453a60a62f4 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -10,19 +10,19 @@
%ul.blob-commit-info
= render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref
- #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref } }
+ #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
= render "projects/blob/auxiliary_viewer", blob: blob
-#blob-content-holder.blob-content-holder.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } }
+- if project.forked?
+ #js-fork-info{ data: vue_fork_divergence_data(project, ref) }
+
+#blob-content-holder.blob-content-holder.js-per-page{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } }
- 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 !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,
- project_path: @project.full_path,
- target_branch: project.empty_repo? ? ref : @ref,
- original_branch: @ref } }
+ #js-view-blob-app{ data: vue_blob_app_data(project, blob, ref) }
= gl_loading_icon(size: 'md')
- else
%article.file-holder
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 528999f5c89..0f37ae8ad41 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -3,18 +3,20 @@
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')
- add_page_specific_style 'page_bundles/editor'
-
- if @conflict
= render Pajamas::AlertComponent.new(alert_options: { class: 'gl-mb-5 gl-mt-5' },
variant: :danger,
dismissible: false) do |c|
- - blob_url = project_blob_path(@project, @id)
- - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do
- - sprite_icon('external-link', css_class: 'gl-icon').html_safe
- - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe % { url: blob_url }
= c.body do
- = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start, link_end: '</a>'.html_safe , icon: external_link_icon }
-
+ - blob_link_start = '<a href="%{url}" class="gl-link" target="_blank" rel="noopener noreferrer">'.html_safe
+ - link_end = '</a>'.html_safe
+ - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do
+ - sprite_icon('external-link', css_class: 'gl-icon').html_safe
+ - if @different_project
+ = _("Error: Can't edit this file. The fork and upstream project have diverged. %{link_start}Edit the file on the fork %{icon}%{link_end}, and create a merge request.").html_safe % {link_start: blob_link_start % { url: project_blob_path(@project_to_commit_into, @id) } , link_end: link_end, icon: external_link_icon }
+ - else
+ - blob_url = project_blob_path(@project, @id)
+ = _('Someone edited the file the same time you did. Please check out %{link_start}the file %{icon}%{link_end} and make sure your changes will not unintentionally remove theirs.').html_safe % { link_start: blob_link_start % { url: blob_url }, link_end: link_end , icon: external_link_icon }
%h1.page-title.gl-font-size-h-display.blob-edit-page-title
Edit file
diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
index 3bdb81f02ad..4e4a72c154f 100644
--- a/app/views/projects/branch_defaults/_branch_names_fields.html.haml
+++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml
@@ -10,6 +10,6 @@
%p.form-text.text-muted
= s_('ProjectSettings|Leave empty to use default template.')
= sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Issue::MAX_BRANCH_TEMPLATE })
- - branch_name_help_link = help_page_path('user/project/merge_requests/creating_merge_requests.md', anchor: 'from-an-issue')
+ - branch_name_help_link = help_page_path('user/project/repository/branches/index.md', anchor: 'name-your-branch')
= link_to _('What variables can I use?'), branch_name_help_link, target: "_blank"
= render_if_exists 'projects/branch_defaults/branch_names_help'
diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml
index 6e70dc42776..605715e2899 100644
--- a/app/views/projects/branch_rules/_show.html.haml
+++ b/app/views/projects/branch_rules/_show.html.haml
@@ -3,7 +3,7 @@
- show_status_checks = @project.licensed_feature_available?(:external_status_checks)
- show_approvers = @project.licensed_feature_available?(:merge_request_approvers)
-%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded) }
+%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded), data: { qa_selector: 'branch_rules_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch rules')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 91efd5ef048..86bed956bc4 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -7,22 +7,18 @@
= @error
%h1.page-title.gl-font-size-h-display
= _('New Branch')
-%hr
= form_tag namespace_project_branches_path, method: :post, id: "new-branch-form", class: "js-create-branch-form js-requires-input" do
- .form-group.row
- = label_tag :branch_name, _('Branch name'), class: 'col-form-label col-sm-2'
- .col-sm-10
- = text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace'
- .form-text.text-muted.text-danger.js-branch-name-error
- .form-group.row
- = label_tag :ref, _('Create from'), class: 'col-form-label col-sm-2'
- .col-sm-auto.create-from
- .js-new-branch-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } }
- .form-text.text-muted
- = _('Existing branch name, tag, or commit SHA')
- .form-actions
- = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do
- = _('Create branch')
- = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ .form-group.gl-max-w-80
+ = label_tag :branch_name, _('Branch name')
+ = text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace'
+ .form-text.text-muted.text-danger.js-branch-name-error{ 'aria-live': 'assertive' }
+ .form-group.gl-max-w-80
+ = label_tag :ref, _('Create from')
+ .js-new-branch-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } }
+ .form-text.text-muted
+ = _('Existing branch name, tag, or commit SHA')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do
+ = _('Create branch')
+ = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index feaac255d8c..0868475c49f 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -3,13 +3,11 @@
- add_to_breadcrumbs _('Commits'), project_commits_path(@project)
- breadcrumb_title @commit.short_id
- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
-- limited_container_width = fluid_layout ? '' : 'limit-container-width'
-- @content_class = limited_container_width
- page_title "#{@commit.title} (#{@commit.short_id})", _('Commits')
- page_description @commit.description
- add_page_specific_style 'page_bundles/pipelines'
-.container-fluid{ class: [limited_container_width, container_class] }
+.container-fluid{ class: [container_class] }
= render "commit_box"
= render "ci_menu"
= render "projects/diffs/diffs",
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 780bb3404cc..0a87ae145ac 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,7 +1,7 @@
- diff_file = local_assigns.fetch(:diff_file, nil)
- file_hash = hexdigest(diff_file.file_path)
-.diff-content
+.diff-content.gl-rounded-bottom-base
- if diff_file.has_renderable?
.hidden{ id: "#raw-diff-#{file_hash}", data: { file_hash: file_hash, diff_toggle_entity: 'rawViewer' } }
= render 'projects/diffs/viewer', viewer: diff_file.viewer
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 03e26fd4456..88354f57c55 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,13 +2,13 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
-- load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit"
+- load_diff_files_async = diff_page_context == "is-commit"
- paginate_diffs = local_assigns.fetch(:paginate_diffs, false)
- paginate_diffs_per_page = local_assigns.fetch(:paginate_diffs_per_page, nil)
- page = local_assigns.fetch(:page, nil)
- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, page: page, per: paginate_diffs_per_page)
-.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
+.files-changed.diff-files-changed.js-diff-files-changed.gl-py-3
.files-changed-inner
.inline-parallel-buttons.gl-display-none.gl-md-display-flex
- if !diffs_expanded? && diff_files.any?(&:collapsed?)
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index b2270e0faf7..b0eef923411 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,7 +1,6 @@
- breadcrumb_title _("General Settings")
- page_title _("General")
- add_page_specific_style 'page_bundles/projects_edit'
-- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
- reduce_visibility_form_id = 'reduce-visibility-form'
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index ca3f49bae95..b6c21588193 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,4 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
- default_branch_name = @project.default_branch_or_main
- escaped_default_branch_name = default_branch_name.shellescape
- @skip_current_level_breadcrumb = true
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index e4b8750b96c..7ddaf868a35 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -8,4 +8,5 @@
"help-page-path" => help_page_path("ci/environments/index.md"),
"project-path" => @project.full_path,
"project-id" => @project.id,
- "default-branch-name" => @project.default_branch_or_main } }
+ "default-branch-name" => @project.default_branch_or_main,
+ "kas-tunnel-url" => ::Gitlab::Kas.tunnel_url } }
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index 121dcd31a13..28a8f8729dd 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -1,7 +1,7 @@
- @gfm_form = true
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- breadcrumb_title @feature_flag.name
-- page_title s_('FeatureFlags|Edit Feature Flag'), @feature_flag.name
+- page_title s_('FeatureFlags|Edit Feature flag'), @feature_flag.name
#js-edit-feature-flag{ data: edit_feature_flag_data }
diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml
index a6eaeacc61f..e473a6f3cfd 100644
--- a/app/views/projects/feature_flags/index.html.haml
+++ b/app/views/projects/feature_flags/index.html.haml
@@ -1,4 +1,4 @@
-- page_title s_('FeatureFlags|Feature Flags')
+- page_title s_('FeatureFlags|Feature flags')
#feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json),
"project-id" => @project.id,
diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml
index c91487ad198..3a32a249d1e 100644
--- a/app/views/projects/feature_flags/new.html.haml
+++ b/app/views/projects/feature_flags/new.html.haml
@@ -1,7 +1,7 @@
- @breadcrumb_link = new_project_feature_flag_path(@project)
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- breadcrumb_title s_('FeatureFlags|New')
-- page_title s_('FeatureFlags|New Feature Flag')
+- page_title s_('FeatureFlags|New feature flag')
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json),
feature_flags_path: project_feature_flags_path(@project),
diff --git a/app/views/projects/feature_flags_user_lists/edit.html.haml b/app/views/projects/feature_flags_user_lists/edit.html.haml
index 1ff488ff0f0..417b6354ec0 100644
--- a/app/views/projects/feature_flags_user_lists/edit.html.haml
+++ b/app/views/projects/feature_flags_user_lists/edit.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|Edit User List')
- page_title s_('FeatureFlags|Edit User List')
diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml
index f0e3c36992a..c0e98b27d29 100644
--- a/app/views/projects/feature_flags_user_lists/index.html.haml
+++ b/app/views/projects/feature_flags_user_lists/index.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- breadcrumb_title s_('FeatureFlags|User Lists')
-- page_title s_('FeatureFlags|Feature Flag User Lists')
+- page_title s_('FeatureFlags|Feature flag User Lists')
#js-user-lists{ data: { project_id: @project.id,
feature_flags_help_page_path: help_page_path("operations/feature_flags"),
diff --git a/app/views/projects/feature_flags_user_lists/new.html.haml b/app/views/projects/feature_flags_user_lists/new.html.haml
index f2e1ea38d9c..cea55c0ca2a 100644
--- a/app/views/projects/feature_flags_user_lists/new.html.haml
+++ b/app/views/projects/feature_flags_user_lists/new.html.haml
@@ -1,5 +1,5 @@
- @breadcrumb_link = new_project_feature_flags_user_list_path(@project)
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|New User List')
- page_title s_('FeatureFlags|New User List')
diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml
index 2c88f3da66b..5c4e93e7707 100644
--- a/app/views/projects/feature_flags_user_lists/show.html.haml
+++ b/app/views/projects/feature_flags_user_lists/show.html.haml
@@ -1,7 +1,7 @@
-- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
+- add_to_breadcrumbs s_('FeatureFlags|Feature flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|List details')
-- page_title s_('FeatureFlags|Feature Flag User List Details')
+- page_title s_('FeatureFlags|Feature flag user list details')
#js-edit-user-list{ data: { project_id: @project.id,
user_list_iid: @user_list.iid,
diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml
index dab49d5032a..07aaf9b2513 100644
--- a/app/views/projects/google_cloud/configuration/index.html.haml
+++ b/app/views/projects/google_cloud/configuration/index.html.haml
@@ -2,6 +2,4 @@
- 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/cloudsql_form.html.haml b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml
index 05838717b49..ea0a53010ef 100644
--- a/app/views/projects/google_cloud/databases/cloudsql_form.html.haml
+++ b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml
@@ -3,7 +3,5 @@
- breadcrumb_title @title
- page_title @title
-- @content_class = "limit-container-width" unless fluid_layout
-
= form_tag project_google_cloud_databases_path(@project), method: 'post' do
#js-google-cloud-databases-cloudsql-form{ 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
index 0528ac3d1f5..0d54c1618e4 100644
--- a/app/views/projects/google_cloud/databases/index.html.haml
+++ b/app/views/projects/google_cloud/databases/index.html.haml
@@ -2,6 +2,4 @@
- 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
index 22a365671bc..96f73fc3dd1 100644
--- a/app/views/projects/google_cloud/deployments/index.html.haml
+++ b/app/views/projects/google_cloud/deployments/index.html.haml
@@ -2,6 +2,4 @@
- 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/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml
index 4cc218ff548..378ec592a74 100644
--- a/app/views/projects/google_cloud/gcp_regions/index.html.haml
+++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml
@@ -2,7 +2,5 @@
- breadcrumb_title s_('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-gcp-regions{ 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 8f70818abd9..0e114350193 100644
--- a/app/views/projects/google_cloud/service_accounts/index.html.haml
+++ b/app/views/projects/google_cloud/service_accounts/index.html.haml
@@ -2,7 +2,5 @@
- 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-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 e6f0e3e950c..b6f6fb64451 100644
--- a/app/views/projects/harbor/repositories/index.html.haml
+++ b/app/views/projects/harbor/repositories/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Harbor Registry")
-- @content_class = "limit-container-width" unless fluid_layout
#js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index d610ef21400..0f4dc4b5e32 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
- page_title _('Webhook Logs')
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 3e63faaf448..b553249c4b8 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- add_to_breadcrumbs _('Webhook Settings'), project_hooks_path(@project)
- page_title _('Webhook')
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 15cb7869dc5..35214ad38dc 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- breadcrumb_title _('Webhook Settings')
- page_title _('Webhooks')
diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml
index 9fe541c5912..7f509aee07c 100644
--- a/app/views/projects/imports/show.html.haml
+++ b/app/views/projects/imports/show.html.haml
@@ -1,5 +1,4 @@
- page_title import_in_progress_title
-- @content_class = "limit-container-width" unless fluid_layout
.save-project-loader
.center
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
index 5e2b2bbfcc4..df5ab1d4a7c 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -13,7 +13,7 @@
issue_path: project_issue_path(@project, @issue),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
- saved_replies_new_path: profile_saved_replies_path } }
+ new_comment_template_path: profile_comment_templates_path } }
- else
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
= enable_lfs_message
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 8f259fe73e1..c6e5102889a 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -13,4 +13,4 @@
current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json,
can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}",
report_abuse_path: add_category_abuse_reports_path,
- saved_replies_new_path: profile_saved_replies_path } }
+ new_comment_template_path: profile_comment_templates_path } }
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 18975bc3db6..fc6ef2ea153 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -50,10 +50,10 @@
%ul.controls
- if issue.closed? && issue.moved?
%li.issuable-status
- = _('CLOSED (MOVED)')
+ = render Pajamas::BadgeComponent.new(_('Closed (moved)'), size: 'sm', variant: 'info')
- elsif issue.closed?
%li.issuable-status
- = _('CLOSED')
+ = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'info')
- if issue.assignees.any?
%li.gl-display-flex
= render 'shared/issuable/assignees', project: @project, issuable: issue
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index f9798d25b06..90d99d51d29 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -11,18 +11,18 @@
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path(from: @issue.to_branch_name, source_project: @project, to: @project.default_branch, mr_params: { issue_iid: @issue.iid }), create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.unavailable
- %button.gl-button.btn{ type: 'button', disabled: 'disabled' }
+ = render Pajamas::ButtonComponent.new(button_options: { disabled: 'disabled' }) do
= gl_loading_icon(inline: true, css_class: 'js-create-mr-spinner gl-button-icon gl-display-none')
%span.text
- Checking branch availability…
+ = _('Checking branch availability…')
+
.btn-group.available.hidden
- %button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } }
- = gl_loading_icon(css_class: 'js-create-mr-spinner js-spinner gl-mr-2 gl-display-none')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-merge-request', data: { action: data_action } }) do
+ = gl_loading_icon(inline: true , css_class: 'js-create-mr-spinner js-spinner gl-display-none')
= value
- %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
- = sprite_icon('chevron-down')
+ = render Pajamas::ButtonComponent.new(variant: :confirm, icon: 'chevron-down', button_options: { class: 'js-dropdown-toggle dropdown-toggle create-merge-request-dropdown-toggle', data: { 'dropdown-trigger': '#create-merge-request-dropdown', display: 'static' } })
.droplab-dropdown
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
@@ -57,7 +57,7 @@
%span.js-ref-message.form-text
.form-group
- %button.btn.gl-button.btn-confirm.js-create-target{ type: 'button', data: { action: 'create-mr' } }
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'js-create-target', data: { action: 'create-mr' } }) do
= create_mr_text
- if can_create_confidential_merge_request?
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 466eca2fdb0..d26b0f96992 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -1,12 +1,24 @@
- if @related_branches.any?
- %h2.gl-font-lg
- = pluralize(@related_branches.size, 'Related Branch')
- %ul.related-merge-requests.gl-pl-0.gl-mb-3
- - @related_branches.each do |branch|
- %li.gl-display-flex.gl-align-items-center
- - if branch[:pipeline_status].present?
- %span.related-branch-ci-status
- = render 'ci/status/icon', status: branch[:pipeline_status]
- %span.related-branch-info
- %strong
- = link_to branch[:name], branch[:link], class: "ref-name"
+ - if @related_branches.any?
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-bg-gray-10 gl-mt-5 gl-mb-0' }, header_options: { class: 'gl-bg-white gl-pl-5 gl-pr-4 gl-py-4' } , body_options: { class: 'gl-py-3 gl-px-4' }) do |c|
+ - c.header do
+ %h3.card-title.h5.gl-my-0.gl-display-flex.gl-align-items-center.gl-flex-grow-1.gl-relative.gl-line-height-24
+ = link_to "", "#related-branches", class: "gl-link anchor position-absolute gl-text-decoration-none", "aria-hidden": true
+ = _('Related branches')
+ .gl-display-inline-flex.gl-mx-3.gl-text-gray-500
+ .gl-display-inline-flex.gl-align-items-center
+ = sprite_icon('branch', css_class: "gl-mr-2 gl-text-gray-500 gl-icon")
+ = @related_branches.size
+ - c.body do
+ %ul.related-merge-requests.content-list.gl-p-3!
+ - @related_branches.each do |branch|
+ %li.list-item{ class: "gl-py-0! gl-border-0!" }
+ .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2
+ .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7
+ .item-title.gl-display-flex.mb-xl-0.gl-min-w-0
+ - if branch[:pipeline_status].present?
+ %span.related-branch-ci-status
+ = render 'ci/status/icon', status: branch[:pipeline_status]
+ %span.related-branch-info
+ %strong
+ = link_to branch[:name], branch[:link], class: "ref-name"
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index 617579cdd6f..d344ae6a4e6 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -2,7 +2,7 @@
- breadcrumb_title _("New")
- page_title _("New Issue")
-.top-area.gl-lg-flex-direction-row.gl-border-bottom-0
+.page-title-holder
%h1.page-title.gl-font-size-h-display= _("New Issue")
= render "form"
diff --git a/app/views/projects/mattermosts/new.html.haml b/app/views/projects/mattermosts/new.html.haml
index 8254198bd41..025ca1e1fd4 100644
--- a/app/views/projects/mattermosts/new.html.haml
+++ b/app/views/projects/mattermosts/new.html.haml
@@ -2,7 +2,6 @@
- add_to_breadcrumbs @integration.title, scoped_edit_integration_path(@integration, project: @project, group: @group)
- breadcrumb_title _('New')
- page_title @integration.title, _('Integrations')
-- @content_class = 'limit-container-width' unless fluid_layout
- if @teams_error_message
= render Pajamas::AlertComponent.new(variant: :danger) do |c|
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 92b0a5a0b90..b8ee62055f0 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
@@ -1,7 +1,7 @@
- display_issuable_type = issuable_display_type(@merge_request)
.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full
- = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Merge request actions'), testid: 'merge-request-actions', 'aria-label': _('Merge request actions') } do
+ = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do
= sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
= button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do
%span.gl-dropdown-button-text= _('Merge request actions')
diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml
index 2ef89a7bf04..4cab6fac388 100644
--- a/app/views/projects/merge_requests/_code_dropdown.html.haml
+++ b/app/views/projects/merge_requests/_code_dropdown.html.haml
@@ -32,7 +32,7 @@
%li.gl-dropdown-item
= link_to merge_request_path(@merge_request, format: :patch), class: 'dropdown-item', download: '', data: { qa_selector: 'download_email_patches_menu_item' } do
.gl-dropdown-item-text-wrapper
- = _('Email patches')
+ = _('Patches')
%li.gl-dropdown-item
= link_to merge_request_path(@merge_request, format: :diff), class: 'dropdown-item', download: '', data: { qa_selector: 'download_plain_diff_menu_item' } do
.gl-dropdown-item-text-wrapper
diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml
index 1dd4cc6495c..5590f9e6184 100644
--- a/app/views/projects/merge_requests/_description.html.haml
+++ b/app/views/projects/merge_requests/_description.html.haml
@@ -1,6 +1,6 @@
%div
- if @merge_request.description.present?
- .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' , data: { qa_selector: 'description_content' } }
+ .description{ class: ['gl-mt-4!', can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : ''], data: { qa_selector: 'description_content' } }
.md
= markdown_field(@merge_request, :description)
%textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } }
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index b96d869e9d7..85396134db2 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -39,19 +39,18 @@
= sprite_icon('branch', size: 12, css_class: 'fork-sprite')
= merge_request.target_branch
- if merge_request.labels.any?
- &nbsp;
- - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label|
- = link_to_label(label, type: :merge_request, small: true)
+ .gl-mt-1{ role: 'group', 'aria-label': _('Labels') }
+ - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label|
+ = link_to_label(label, type: :merge_request, small: true)
.issuable-meta
%ul.controls.d-flex.align-items-end
- if merge_request.merged?
- %li.issuable-status.d-none.d-sm-inline-block
- = _('MERGED')
+ %li.d-none.d-sm-flex
+ = render Pajamas::BadgeComponent.new(_('Merged'), size: 'sm', variant: 'info')
- elsif merge_request.closed?
- %li.issuable-status.d-none.d-sm-inline-block
- = sprite_icon('cancel', css_class: 'gl-vertical-align-text-bottom')
- = _('CLOSED')
+ %li.d-none.d-sm-flex
+ = render Pajamas::BadgeComponent.new(_('Closed'), size: 'sm', variant: 'danger')
= render 'shared/merge_request_pipeline_status', merge_request: merge_request
- if merge_request.open? && merge_request.broken?
%li.issuable-pipeline-broken.d-none.d-sm-flex
@@ -67,6 +66,6 @@
= render 'shared/issuable_meta_data', issuable: merge_request
- .float-right.issuable-updated-at.d-none.d-sm-inline-block
+ .float-right.issuable-updated-at.d-none.d-sm-inline-block.gl-text-gray-500
%span
= _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago') }
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 901a2ebfd1e..6f662b81dd7 100644
--- a/app/views/projects/merge_requests/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -1,3 +1,3 @@
-.detail-page-description.py-2.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
+.detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-center.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
= render 'shared/issuable/status_box', issuable: @merge_request
= merge_request_header(@project, @merge_request)
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index d0bd176028f..aee746100ea 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -1,4 +1,3 @@
-- @no_breadcrumb_border = true
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false)
@@ -13,7 +12,7 @@
= c.body do
= _('The source project of this merge request has been removed.')
- .detail-page-header.border-bottom-0.pt-0.pb-0.gl-display-block{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
+ .detail-page-header.border-bottom-0.gl-display-block.gl-pt-5{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" }
.detail-page-header-body
.issuable-meta.gl-display-flex
#js-issuable-header-warnings{ data: { hidden: @merge_request.hidden?.to_s } }
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 5bd33cd210d..1f6c95d920f 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -8,6 +8,8 @@
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md')
- mr_action = j(params[:tab].presence || 'show')
+-# @diffs_count is a number when the value is 0, but a string when there are other values
+- diffs_count_display = @diffs_count.to_s == "0" ? "-" : @diffs_count
- add_page_specific_style 'page_bundles/issuable'
- add_page_specific_style 'page_bundles/design_management'
- add_page_specific_style 'page_bundles/merge_requests'
@@ -16,7 +18,7 @@
- add_page_specific_style 'page_bundles/ci_status'
- add_page_startup_api_call @endpoint_metadata_url
-- if mr_action == 'diffs'
+- if mr_action == 'diffs' && (!@file_by_file_default || !single_file_file_by_file?)
- add_page_startup_api_call @endpoint_diff_batch_url
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version, diffs_batch_cache_key: @diffs_batch_cache_key } }
@@ -45,7 +47,7 @@
= render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
- = gl_badge_tag @diffs_count, { size: :sm }
+ = gl_badge_tag diffs_count_display, { size: :sm }
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
#js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } }
- if moved_mr_sidebar_enabled?
@@ -82,7 +84,7 @@
current_user_data: @current_user_data,
is_locked: @merge_request.discussion_locked.to_s,
report_abuse_path: add_category_abuse_reports_path,
- saved_replies_new_path: profile_saved_replies_path } }
+ new_comment_template_path: profile_comment_templates_path } }
- if moved_mr_sidebar_enabled?
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
@@ -106,7 +108,7 @@
- if @merge_request.can_be_cherry_picked?
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
-#js-review-bar{ data: { saved_replies_new_path: profile_saved_replies_path } }
+#js-review-bar{ data: { new_comment_template_path: profile_comment_templates_path } }
- if current_user && Feature.enabled?(:mr_experience_survey, current_user)
#js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } }
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 1246c45a529..35e8b30e6e9 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -10,12 +10,24 @@
- if params[:nav_source].present?
= hidden_field_tag(:nav_source, params[:nav_source])
-.mr-compare.merge-request.js-merge-request-new-submit{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" }
+.mr-compare.merge-request.js-merge-request-new-submit.gl-mt-5{ 'data-mr-submit-action': "#{j params[:tab].presence || 'new'}" }
- if @commits.empty?
- .commits-empty
- %h4
- = _("There are no commits yet.")
- = custom_icon ('illustration_no_commits')
+ .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
+ .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between
+ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0.js-tabs-affix
+ %li.commits-tab.new-tab
+ = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do
+ = _("Commits")
+ = gl_badge_tag @total_commit_count, { size: :sm }, { class: 'gl-tab-counter-badge' }
+
+ #diff-notes-app.tab-content
+ #new.commits.tab-pane.active
+ .commits-empty.gl-text-left.gl-my-5.gl-text-gray-500
+ %p
+ = _("There are no commits yet.")
- else
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between
diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml
index 5dbbb72db56..b06aca063fd 100644
--- a/app/views/projects/mirrors/_mirror_repos_list.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -10,7 +10,7 @@
- c.body do
= _('There are currently no mirrored repositories.')
- else
- %table.table.push-pull-table
+ %table.table.gl-table.gl-mt-5
%thead
%tr
%th
diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml
index 52145eb0964..cfec627d249 100644
--- a/app/views/projects/ml/experiments/show.html.haml
+++ b/app/views/projects/ml/experiments/show.html.haml
@@ -3,15 +3,14 @@
- page_title @experiment.name
- add_page_specific_style 'page_bundles/ml_experiment_tracking'
+- experiment = experiment_as_data(@experiment)
- items = candidates_table_items(@candidates)
- metrics = unique_logged_names(@candidates, &:latest_metrics)
- params = unique_logged_names(@candidates, &:params)
- page_info = formatted_page_info(@page_info)
-.page-title-holder.d-flex.align-items-center
- %h1.page-title.gl-font-size-h-display= @experiment.name
-
#js-show-ml-experiment{ data: {
+ experiment: experiment,
candidates: items,
metrics: metrics,
params: params,
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index f4a5862b2c0..e64ed2c7b8f 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -1,4 +1,4 @@
-- @hide_breadcrumbs = true
+- @hide_top_bar = true
- @hide_top_links = true
- page_title _('New Project')
- header_title _("Projects"), dashboard_projects_path
@@ -14,6 +14,7 @@
new_project_guidelines: brand_new_project_guidelines,
push_to_create_project_command: push_to_create_project_command,
working_with_projects_help_path: help_page_path("user/project/working_with_projects"),
+ root_path: root_path,
parent_group_url: @project.parent && group_url(@project.parent),
parent_group_name: @project.parent&.name,
projects_url: dashboard_projects_url } }
diff --git a/app/views/projects/packages/infrastructure_registry/index.html.haml b/app/views/projects/packages/infrastructure_registry/index.html.haml
index 5a118997ff9..9577f6383e9 100644
--- a/app/views/projects/packages/infrastructure_registry/index.html.haml
+++ b/app/views/projects/packages/infrastructure_registry/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Infrastructure Registry")
-- @content_class = "limit-container-width" unless fluid_layout
.row
.col-12
diff --git a/app/views/projects/packages/infrastructure_registry/show.html.haml b/app/views/projects/packages/infrastructure_registry/show.html.haml
index e7c77478170..8624fdacda7 100644
--- a/app/views/projects/packages/infrastructure_registry/show.html.haml
+++ b/app/views/projects/packages/infrastructure_registry/show.html.haml
@@ -1,8 +1,7 @@
-- add_to_breadcrumbs _("Infrastructure Registry"), project_infrastructure_registry_index_path(@project)
+- add_to_breadcrumbs _("Terraform Module Registry"), project_infrastructure_registry_index_path(@project)
- add_to_breadcrumbs @package.name, project_infrastructure_registry_index_path(@project)
- breadcrumb_title @package.version
-- page_title _("Infrastructure Registry")
-- @content_class = "limit-container-width" unless fluid_layout
+- page_title _("Terraform Module Registry")
.row
.col-12
diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml
index 4ab16f25dd2..48aaf0884c8 100644
--- a/app/views/projects/packages/packages/index.html.haml
+++ b/app/views/projects/packages/packages/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Package Registry")
-- @content_class = "limit-container-width" unless fluid_layout
.row
.col-12
@@ -10,4 +9,5 @@
empty_list_illustration: image_path('illustrations/no-packages.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: project_packages_path(@project),
+ settings_path: show_package_registry_settings(@project) ? project_settings_packages_and_registries_path(@project) : '',
group_list_url: '' } }
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 11e105d349d..4c8ec21db39 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -17,7 +17,7 @@
%p.gl-pl-6
= s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
- - if Feature.enabled?(:pages_unique_domain)
+ - if Feature.enabled?(:pages_unique_domain, @project)
.form-group
= f.fields_for :project_setting do |settings|
= settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled,
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 7a889570f56..3ff370dfaa4 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -21,10 +21,7 @@
- duration = time_interval_in_words(@pipeline.duration)
- queued_duration = time_interval_in_words(@pipeline.queued_duration)
%span.gl-pl-7{ 'data-testid': 'pipeline-stats-text' }
- - if Feature.enabled?(:refactor_ci_minutes_consumption, @project)
- = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration
- - else
- = s_("in %{duration} and was queued for %{queued_duration}").html_safe % { duration: duration, queued_duration: queued_duration }
+ = render_if_exists 'projects/pipelines/pipeline_stats_text', duration: duration, pipeline: @pipeline, queued_duration: queued_duration
- if has_pipeline_badges?(@pipeline)
.well-segment
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index ee51ee9b0e2..63b44de0d74 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -7,7 +7,6 @@
#js-new-pipeline{ data: { project_id: @project.id,
pipelines_path: project_pipelines_path(@project),
- config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project),
default_branch: @project.default_branch,
pipelines_editor_path: project_ci_pipeline_editor_path(@project),
can_view_pipeline_editor: can_view_pipeline_editor?(@project),
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 0cfb5ff6a3d..a0a90fbe204 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -3,10 +3,6 @@
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
-= content_for :page_level_alert do
- - if can_invite_members_for_project?(@project)
- = render_if_exists 'shared/unlimited_members_during_trial_alert', group: @project.root_ancestor
-
.row.gl-mt-3
.col-lg-12
.gl-display-flex.gl-flex-wrap
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 910aab6da72..644aca2477b 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,5 +1,4 @@
- page_title _("Container Registry")
-- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil})
%section
diff --git a/app/views/projects/releases/new.html.haml b/app/views/projects/releases/new.html.haml
index 4348035a324..87197f2662d 100644
--- a/app/views/projects/releases/new.html.haml
+++ b/app/views/projects/releases/new.html.haml
@@ -1,3 +1,4 @@
- page_title s_('Releases|New Release')
+- add_page_specific_style 'page_bundles/releases'
#js-new-release-page{ data: data_for_new_release_page }
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index 2904fb81afe..63e175f96e5 100644
--- a/app/views/projects/security/configuration/show.html.haml
+++ b/app/views/projects/security/configuration/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _("Security configuration")
- page_title _("Security configuration")
-- @content_class = "limit-container-width" unless fluid_layout
#js-security-configuration{ data: { **@configuration.to_html_data_attribute,
vulnerability_training_docs_path: vulnerability_training_docs_path,
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index f6c5c4e2950..b581ccaceec 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -2,7 +2,6 @@
- page_title _('Project Access Tokens')
- type = _('project access token')
- type_plural = _('project access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
.row.gl-mt-3.js-search-settings-section
.col-lg-4
diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml
index f05a528745c..efebc4223d9 100644
--- a/app/views/projects/settings/branch_rules/index.html.haml
+++ b/app/views/projects/settings/branch_rules/index.html.haml
@@ -1,6 +1,9 @@
- add_to_breadcrumbs _('Repository Settings'), project_settings_repository_path(@project)
+- add_to_breadcrumbs _('Branch rules'), project_settings_repository_path(@project, anchor: 'branch-rules')
+- breadcrumb_title _('Details')
+- @breadcrumb_link = '#'
- page_title s_('BranchRules|Branch rules details')
-%h3.gl-mb-5= s_('BranchRules|Branch rules details')
+%h3.gl-mb-5= page_title
#js-branch-rules{ data: branch_rules_data(@project) }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index b27f5a0e5ed..d8e26c7ad72 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,4 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
- page_title _("CI/CD Settings")
- page_title _("CI/CD")
@@ -58,7 +57,7 @@
%p
= _("A job artifact is an archive of files and directories saved by a job when it finishes.")
.settings-content
- #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } }
+ #js-artifacts-settings-app{ data: { full_path: @project.full_path, help_page_path: help_page_path('ci/jobs/job_artifacts', anchor: 'keep-artifacts-from-most-recent-successful-jobs') } }
%section.settings.no-animate#js-cicd-variables-settings{ class: ('expanded' if expanded), data: { qa_selector: 'variables_settings_content' } }
.settings-header
diff --git a/app/views/projects/settings/integrations/edit.html.haml b/app/views/projects/settings/integrations/edit.html.haml
index 46276e6c6c9..84d3ac2ded9 100644
--- a/app/views/projects/settings/integrations/edit.html.haml
+++ b/app/views/projects/settings/integrations/edit.html.haml
@@ -1,7 +1,6 @@
- breadcrumb_title @integration.title
- add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project)
- page_title @integration.title, _('Integrations')
-- @content_class = 'limit-container-width' unless fluid_layout
= render 'form', integration: @integration
diff --git a/app/views/projects/settings/integrations/index.html.haml b/app/views/projects/settings/integrations/index.html.haml
index c316b4e9cac..ed65cce5acb 100644
--- a/app/views/projects/settings/integrations/index.html.haml
+++ b/app/views/projects/settings/integrations/index.html.haml
@@ -1,4 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title _('Integration Settings')
- page_title _('Integrations')
diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml
index 5fca734222b..5c9389c9c1c 100644
--- a/app/views/projects/settings/members/show.html.haml
+++ b/app/views/projects/settings/members/show.html.haml
@@ -1,5 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
-
- page_title _("Members")
= render "projects/project_members/index"
diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml
index 7dfd304e07b..6cc5dfd8c90 100644
--- a/app/views/projects/settings/merge_requests/show.html.haml
+++ b/app/views/projects/settings/merge_requests/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _('Merge requests')
- page_title _('Merge requests')
-- @content_class = 'limit-container-width' unless fluid_layout
%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } }
.settings-header
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 90e0ccce8b4..2aae408b88f 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -1,4 +1,3 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- page_title _('Monitor Settings')
- breadcrumb_title _('Monitor Settings')
diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
index d27d268d65e..ad9ba0b506c 100644
--- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
+++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml
@@ -1,6 +1,5 @@
- add_to_breadcrumbs _('Packages and registries settings'), project_settings_packages_and_registries_path(@project)
-- breadcrumb_title s_('ContainerRegistry|Clean up image tags')
-- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages and registries settings')
-- @content_class = 'limit-container-width' unless fluid_layout
+- breadcrumb_title s_('ContainerRegistry|Cleanup policies')
+- page_title s_('ContainerRegistry|Cleanup policies'), _('Packages and registries settings')
#js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data }
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 c81b38f44dd..22385677192 100644
--- a/app/views/projects/settings/packages_and_registries/show.html.haml
+++ b/app/views/projects/settings/packages_and_registries/show.html.haml
@@ -1,5 +1,4 @@
- breadcrumb_title _('Packages and registries settings')
- page_title _('Packages and registries settings')
-- @content_class = 'limit-container-width' unless fluid_layout
#js-registry-settings{ data: settings_data }
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index de171a25e8d..c532c19e0d1 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _("Repository Settings")
- page_title _("Repository")
-- @content_class = "limit-container-width" unless fluid_layout
- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.')
= render "projects/branch_defaults/show"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index f47f4ebc7ee..ab2f6745dfd 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,5 +1,4 @@
- current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1]
-- @content_class = "limit-container-width" unless fluid_layout
- @skip_current_level_breadcrumb = true
- add_page_specific_style 'page_bundles/project'
- add_page_specific_style 'page_bundles/tree'
diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml
index d9bf064ad24..6e1ebdeedf0 100644
--- a/app/views/projects/snippets/edit.html.haml
+++ b/app/views/projects/snippets/edit.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project)
- breadcrumb_title @snippet.to_reference
- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display
= _("Edit Snippet")
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index 5086b5eaa3d..59b2536c5d0 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project)
- breadcrumb_title _("New")
- page_title _("New Snippet")
-- @content_class = "limit-container-width" unless fluid_layout
%h1.page-title.gl-font-size-h-display
= _("New Snippet")
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index a9c3309e38c..3124f47c832 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -57,13 +57,5 @@
%pre.wrap{ data: { qa_selector: 'tag_message_content' } }
= strip_signature(@tag.message)
-- if can?(current_user, :read_release, @release)
- .gl-mb-3.gl-mt-3
- - if @release&.description.present?
- .description.md{ data: { qa_selector: 'tag_release_notes_content' } }
- = markdown_field(@release, :description)
- - else
- = s_('TagsPage|This tag has no release notes.')
-
- if can?(current_user, :admin_tag, @project)
.js-delete-tag-modal
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 6d1ab80bdc5..fbbf1c04613 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -4,7 +4,6 @@
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})
- breadcrumb_title _("Repository")
-- @content_class = "limit-container-width" unless fluid_layout
- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index 5e2217d3c9f..5bfe6b650d1 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -1,4 +1,5 @@
- page_title s_("UsageQuota|Usage")
+- add_page_specific_style 'page_bundles/projects_usage_quotas'
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
@@ -17,10 +18,12 @@
%a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' }
= s_('UsageQuota|Learn more about usage quotas') + '.'
-= gl_tabs_nav do
+= gl_tabs_nav({ id: 'js-project-usage-quotas-tabs' }) do
= gl_tab_link_to '#storage-quota-tab', item_active: true do
= s_('UsageQuota|Storage')
+ = render_if_exists 'projects/usage_quotas/transfer_tab_link'
.tab-content
.tab-pane.active#storage-quota-tab
#js-project-storage-count-app{ data: { project_path: @project.full_path } }
+ = render_if_exists 'projects/usage_quotas/transfer_tab_content'
diff --git a/app/views/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml
index 8235411d240..ed2d420ffcd 100644
--- a/app/views/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/protected_branches/shared/_branches_list.html.haml
@@ -26,7 +26,7 @@
%th
= s_("ProtectedBranch|Allowed to force push")
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' }
- = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500')
+ = sprite_icon('question-o', size: 16, css_class: 'gl-text-blue-500')
= render_if_exists 'protected_branches/ee/code_owner_approval_table_head', protected_branch_entity: protected_branch_entity
diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml
index 109d92af8a7..9bc224b2e78 100644
--- a/app/views/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml
@@ -40,3 +40,5 @@
= render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity
- c.footer do
= f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true
+
+ .js-alert-protected-branch-created-container.gl-mb-5
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index 2796f0c0a7e..45c23aa7190 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -1,6 +1,7 @@
- @html_class = "subscriptions-layout-html"
- page_title _('Your profile')
- add_page_specific_style 'page_bundles/signup'
+- add_page_specific_style 'page_bundles/login'
- gitlab_experience_text = _('To personalize your GitLab experience, we\'d like to know a bit more about you')
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 3280dcf2cd4..99558f61b25 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,9 +1,5 @@
-- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
-
= render_if_exists 'shared/promotions/promote_advanced_search'
-.results.gl-md-display-flex.gl-mt-0
- #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } }
- .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
- = render partial: 'search/results_status' if @search_objects.present?
- = render partial: 'search/results_list'
+.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
+ = render partial: 'search/results_status' unless @search_objects.to_a.empty?
+ = render partial: 'search/results_list'
diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml
index c36acaf9ea8..fcbf0ba4452 100644
--- a/app/views/search/_results_list.html.haml
+++ b/app/views/search/_results_list.html.haml
@@ -5,7 +5,9 @@
- elsif @search_objects.blank?
= render partial: "search/results/empty"
- else
- .gl-md-pl-5
+ - statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : ''
+
+ .section{ class: statusBarClass }
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
index 4ab68caaf22..6fc07d35296 100644
--- a/app/views/search/_results_status.html.haml
+++ b/app/views/search/_results_status.html.haml
@@ -1,5 +1,7 @@
- return unless @search_service_presenter.show_results_status?
-.gl-md-pl-5
+- statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : ''
+
+.section{ class: statusBarClass }
.search-results-status
.gl-display-flex.gl-flex-direction-column
.gl-p-5.gl-display-flex
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 826d78c470d..934f59ea586 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -1,12 +1,14 @@
- @hide_top_links = true
- breadcrumb_title _('Search')
- page_title @search_term
+- nav 'search'
- if params[:group_id].present?
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
- group_attributes = @group&.attributes&.slice('id', 'name')&.merge(full_name: @group&.full_name)
- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
+- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
- if @search_results && !(@search_results.respond_to?(:failed?) && @search_results.failed?)
- if @search_service_presenter.without_count?
@@ -20,5 +22,7 @@
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
#js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } }
-- if @search_term
- = render 'search/results'
+.results.gl-md-display-flex.gl-mt-0
+ #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json } }
+ - if @search_term
+ = render 'search/results'
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 93f919f01d9..c468b3a2001 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,4 +1,4 @@
-- container = @no_breadcrumb_container ? 'container-fluid' : container_class
+- container = @no_top_bar_container ? 'container-fluid' : container_class
%div{ class: [container, @content_class, 'gl-pt-5!'] }
= render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'),
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index a749d1037a1..9dfbad20726 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -2,14 +2,8 @@
- offset = defined?(first_line_number) ? first_line_number : 1
- highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil
-- file_line_blame = Feature.enabled?(:file_line_blame)
-
-- if file_line_blame
- - line_class = "js-line-links"
- - blame_path = project_blame_path(@project, tree_join(@ref, blob.path))
-- else
- - line_class = nil
- - blame_path = nil
+- line_class = "js-line-links"
+- blame_path = project_blame_path(@project, tree_join(@ref, blob.path))
- highlighted_blob = blob.present.highlight
diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml
index 8d76e9c1b7d..beb564f7c7c 100644
--- a/app/views/shared/_file_picker_button.html.haml
+++ b/app/views/shared/_file_picker_button.html.haml
@@ -1,9 +1,10 @@
- classes = local_assigns.fetch(:classes, '')
+- mime_types = local_assigns.fetch(:mime_types, '')
%span.js-filepicker
= render Pajamas::ButtonComponent.new(button_options: { class: "js-filepicker-button #{classes}" }) do
= _("Choose file…")
- %span.file_name.js-filepicker-filename= _("No file chosen.")
- = f.file_field field, class: "js-filepicker-input hidden"
+ %span.file_name.gl-ml-3.js-filepicker-filename= _("No file chosen.")
+ = f.file_field field, class: "js-filepicker-input hidden", accept: mime_types
- if help_text.present?
.form-text.text-muted= help_text
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 547f12ac8fc..7f2511d3e28 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -44,10 +44,10 @@
= 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
+ = render Pajamas::ButtonComponent.new(button_options: { data: { toggle: 'dropdown' } }) do
= _('Subscribe')
= sprite_icon('chevron-down')
- .dropdown-menu.dropdown-open-left
+ .dropdown-menu.dropdown-menu-right
%ul
%li
= 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
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
deleted file mode 100644
index fa718a9c907..00000000000
--- a/app/views/shared/_ref_switcher.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-- return unless @project
-
-- ref = local_assigns.fetch(:ref, @ref)
-- form_path = local_assigns.fetch(:form_path, switch_project_refs_path(@project))
-- dropdown_toggle_text = ref || @project.default_branch
-- field_name = local_assigns.fetch(:field_name, 'ref')
-
-= form_tag form_path, method: :get, class: "project-refs-form" do
- - if defined?(destination)
- = hidden_field_tag :destination, destination
- - if defined?(path)
- = hidden_field_tag :path, path
- - @options && @options.each do |key, value|
- = hidden_field_tag key, value, id: nil
- .dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, ref_type: @ref_type, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" }
- .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-right" if local_assigns[:align_right]), data: { qa_selector: "branches_dropdown_content" } }
- .dropdown-page-one
- = dropdown_title _("Switch branch/tag")
- = dropdown_filter _("Search branches and tags")
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index c3835386d5a..e5aa4c58da1 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,5 +1,5 @@
- board = local_assigns.fetch(:board, nil)
-- @no_breadcrumb_container = true
+- @no_top_bar_container = true
- @no_container = true
- @content_wrapper_class = "#{@content_wrapper_class} gl-relative gl-pb-0"
- @content_class = "issue-boards-content js-focus-mode-board"
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 93f31629ca7..584d0758c76 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -27,6 +27,11 @@
.col-sm-10
= form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
+.form-group
+ .col-sm-10
+ = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
+ = form.text_field :expires_at, 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|
.form-group
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 11fa44fe282..c9e17b18264 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -15,6 +15,10 @@
.form-group.row
= deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
help_text: _('Allow this key to push to this repository')
+ .form-group.row
+ = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
+ = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_key_expires_at_field' }, value: f.object.expires_at
+ %p.form-text.text-muted= ssh_key_expires_field_description
.form-group.row
= f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
index 6a770a4fcb2..c2a47a88f02 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -1,5 +1,3 @@
-- @content_class = "limit-container-width" unless fluid_layout
-
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index 19f4c971c1d..b9095e2a1a1 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -8,23 +8,14 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
+ %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true, data: { qa_selector: 'application_id_field' } }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default")
%tr
%td
= _('Secret')
%td
- - if @application.plaintext_secret
- = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c|
- = c.body do
- = _('This is the only time the secret is accessible. Copy the secret and store it securely.')
- = clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
- - else
- = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c|
- = c.body do
- = _('The secret is only available when you create the application or renew the secret.')
- = render 'shared/doorkeeper/applications/update_form', path: renew_path
+ #js-oauth-application-secret{ data: { initial_secret: @application.plaintext_secret, renew_path: renew_path } }
%tr
%td
@@ -55,3 +46,6 @@
= link_to _('Continue'), index_path, class: 'btn btn-confirm btn-md gl-button gl-mr-3'
= link_to _('Edit'), edit_path, class: 'btn btn-default btn-md gl-button'
= render 'shared/doorkeeper/applications/delete_form', path: delete_path
+
+-# Create a hidden field to save the ID of application created
+= hidden_field_tag(:id_of_application, @application.id, data: { qa_selector: 'id_of_application_field' })
diff --git a/app/views/shared/doorkeeper/applications/_update_form.html.haml b/app/views/shared/doorkeeper/applications/_update_form.html.haml
deleted file mode 100644
index 1bee3288639..00000000000
--- a/app/views/shared/doorkeeper/applications/_update_form.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-- path = local_assigns.fetch(:path)
-= form_for(@application, url: path, html: {class: 'gl-display-inline-block', method: "put"}) do |f|
- = submit_tag s_('AuthorizedApplication|Renew secret'), data: { confirm: s_("AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab."), confirm_btn_variant: "danger" }, aria: { label: s_('AuthorizedApplication|Renew secret') }, class: 'gl-button btn btn-md btn-default'
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index e96fcd11cef..da88c139a6e 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,7 +1,7 @@
.row.empty-state.labels
.col-12
- .svg-content{ data: { qa_selector: 'label_svg_content' } }
- = image_tag 'illustrations/labels.svg'
+ .svg-content.svg-150{ data: { qa_selector: 'label_svg_content' } }
+ = image_tag 'illustrations/empty-state/empty-labels-md.svg'
.col-12
.text-content
%h4= _("Labels can be applied to issues and merge requests to categorize them.")
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index e34166bac6c..87de756093d 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -2,8 +2,8 @@
.row.empty-state
.col-12
- .svg-content{ data: { qa_selector: 'svg_content' } }
- = image_tag 'illustrations/snippets_empty.svg'
+ .svg-content.svg-150{ data: { qa_selector: 'svg_content' } }
+ = image_tag 'illustrations/empty-state/empty-snippets-md.svg'
.text-content.gl-text-center.gl-pt-0
- if current_user
%h4
diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml
index 0283e852c7d..cd60d966d71 100644
--- a/app/views/shared/empty_states/_topics.html.haml
+++ b/app/views/shared/empty_states/_topics.html.haml
@@ -1,7 +1,7 @@
.row.empty-state
.col-12
- .svg-content
- = image_tag 'illustrations/labels.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-labels-md.svg'
.text-content.gl-text-center.gl-pt-0!
%h4= _('There are no topics to show.')
%p= _('Add topics to projects to help users find them.')
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 2c46b2191c6..415849672b6 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,9 +1,9 @@
+- @gfm_form = true
- project = local_assigns.fetch(:project)
- model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form)
- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a description or drag your files here…')
-
-- supports_quick_actions = true
+- no_issuable_templates = issuable_templates(ref_project, model.to_ability_name).empty?
- preview_url = preview_markdown_path(project, target_type: model.class.name)
.form-group
@@ -16,12 +16,14 @@
= render 'shared/form_elements/apply_template_warning', issuable: model
- = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
- = render 'shared/zen', f: form, attr: :description,
- classes: 'note-textarea rspec-issuable-form-description',
- placeholder: placeholder,
- supports_quick_actions: supports_quick_actions,
- qa_selector: 'issuable_form_description_field'
- = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
- .clearfix
- .error-alert
+ .js-markdown-editor{ data: { render_markdown_path: preview_url,
+ markdown_docs_path: help_page_path('user/markdown'),
+ quick_actions_docs_path: help_page_path('user/project/quick_actions'),
+ qa_selector: 'issuable_form_description_field',
+ form_field_placeholder: placeholder,
+ form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } }
+ = form.hidden_field :description
+
+ - if no_issuable_templates && can?(current_user, :push_code, model.project)
+ = render 'shared/issuable/form/default_templates'
+
diff --git a/app/views/shared/hook_logs/_index.html.haml b/app/views/shared/hook_logs/_index.html.haml
index 6a46b0b3510..7dab14b95c1 100644
--- a/app/views/shared/hook_logs/_index.html.haml
+++ b/app/views/shared/hook_logs/_index.html.haml
@@ -1,4 +1,4 @@
-- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks')
+- docs_link_url = help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting')
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
- link_end = '</a>'.html_safe
diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml
index 0ae0eea59d8..9d613d2ad94 100644
--- a/app/views/shared/integrations/edit.html.haml
+++ b/app/views/shared/integrations/edit.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group)
- breadcrumb_title @integration.title
- page_title @integration.title, _('Integrations')
-- @content_class = 'limit-container-width' unless fluid_layout
%h2.gl-mb-4
= @integration.title
diff --git a/app/views/shared/integrations/overrides.html.haml b/app/views/shared/integrations/overrides.html.haml
index a63053bde0a..c25527a605c 100644
--- a/app/views/shared/integrations/overrides.html.haml
+++ b/app/views/shared/integrations/overrides.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _('Integrations'), scoped_integrations_path(project: @project, group: @group)
- breadcrumb_title @integration.title
- page_title @integration.title, _('Integrations')
-- @content_class = 'limit-container-width' unless fluid_layout
%h1.page-title.gl-font-size-h-display
= @integration.title
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 07cdbbece8c..5ba92676f89 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -62,9 +62,9 @@
= sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end })
- if issuable.new_record?
- = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
+ = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
- else
- = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 }
+ = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2 js-issuable-submit-button', data: { 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 js-reset-autosave'
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 95c5f51c339..06bc0ff5173 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -194,7 +194,7 @@
= render_if_exists 'shared/issuable/filter_epic', type: type
- %button.clear-search.hidden{ type: 'button' }
+ %button.clear-search.hidden.gl-rounded-base{ type: 'button' }
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-align-items-flex-start
- if type != :productivity_analytics && show_sorting_dropdown
diff --git a/app/views/shared/issuable/form/_default_templates.html.haml b/app/views/shared/issuable/form/_default_templates.html.haml
index 50f30e58b35..2dda0049c09 100644
--- a/app/views/shared/issuable/form/_default_templates.html.haml
+++ b/app/views/shared/issuable/form/_default_templates.html.haml
@@ -1,4 +1,4 @@
-%p.form-text.text-muted
+.gl-mt-3.gl-text-secondary
- template_link_url = help_page_path('user/project/description_templates')
- template_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: template_link_url }
= s_('Promotions|Add %{link_start} description templates %{link_end} to help your contributors to communicate effectively!').html_safe % { link_start: template_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 09086d3aa82..8e9793cdba5 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -9,22 +9,25 @@
%label
= _('Merge options')
- if issuable.can_remove_source_branch?(current_user)
- .form-check.gl-mb-3
+ .form-check.gl-pl-0
= hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
- = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input js-form-update'
- = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do
- = _("Delete source branch when merge request is accepted.")
+ = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[force_remove_source_branch]', checked: issuable.force_remove_source_branch?, value: '1', checkbox_options: { class: 'js-form-update' }) do |c|
+ = c.label do
+ = _("Delete source branch when merge request is accepted.")
+
- if !project.squash_never?
- .form-check
+ .form-check.gl-pl-0
- if project.squash_always?
= hidden_field_tag 'merge_request[squash]', '1', id: nil
- = check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true'
+ = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: project.squash_enabled_by_default?, value: '1', checkbox_options: { class: 'js-form-update', disabled: true }) do |c|
+ = c.label do
+ = _("Squash commits when merge request is accepted.")
+ = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
+ = c.help_text do
+ = _('Required in this project.')
- else
= hidden_field_tag 'merge_request[squash]', '0', id: nil
- = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input js-form-update'
- = label_tag 'merge_request[squash]', class: 'form-check-label' do
- = _("Squash commits when merge request is accepted.")
- = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
- - if project.squash_always?
- .gl-text-gray-400
- = _('Required in this project.')
+ = render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: issuable_squash_option?(issuable, project), value: '1', checkbox_options: { class: 'js-form-update' }) do |c|
+ = c.label do
+ = _("Squash commits when merge request is accepted.")
+ = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 4d31baee25b..be836f4b8a9 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -1,6 +1,5 @@
- issuable = local_assigns.fetch(:issuable)
- form = local_assigns.fetch(:form)
-- no_issuable_templates = issuable_templates(ref_project, issuable.to_ability_name).empty?
%div{ data: { testid: 'issue-title-input-field' } }
= form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true,
@@ -13,6 +12,3 @@
= s_('MergeRequests|Mark as draft')
= c.help_text do
= s_('MergeRequests|Drafts cannot be merged until marked ready.')
-
- - if no_issuable_templates && can?(current_user, :push_code, issuable.project)
- = render 'shared/issuable/form/default_templates'
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 6d4cd83d55b..2350864f0a6 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -5,28 +5,8 @@
= _('Type')
#js-type-popover
- .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")
- .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')}
+ .issuable-form-select-holder.form-group.gl-mb-0.gl-display-block
+ #js-type-select{ data: issuable_type_selector_data(issuable) }
- if issuable.incident?
%p.form-text.text-muted
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index e189cc34899..fdbe247c6ba 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -2,7 +2,7 @@
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.issue-details.issuable-details.js-issue-details
- .detail-page-description.content-block.js-detail-page-description.gl-pt-2.gl-pb-0.gl-border-none
+ .detail-page-description.content-block.js-detail-page-description.gl-pt-4.gl-pb-0.gl-border-none
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json,
issuable_id: issuable.id,
full_path: @project.full_path,
@@ -30,14 +30,14 @@
#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 } }
- - if can?(current_user, :admin_feature_flags_issue_links, @project)
- = render_if_exists 'projects/issues/related_feature_flags'
-
- if can?(current_user, :read_code, @project)
- add_page_startup_api_call related_branches_path
#related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript.
+ - if can?(current_user, :admin_feature_flags_issue_links, @project)
+ = render_if_exists 'projects/issues/related_feature_flags'
+
.js-issue-widgets
= render 'projects/issues/discussion'
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index ccb501dae11..9f7ed6b17c3 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -2,7 +2,7 @@
- badge_classes = 'issuable-status-badge gl-mr-3'
.detail-page-header
- .detail-page-header-body.gl-flex-wrap-wrap
+ .detail-page-header-body.gl-flex-wrap
= gl_badge_tag({ variant: :info, icon: 'issue-closed', icon_classes: 'gl-mr-0!' }, { class: "#{issue_status_visibility(issuable, status_box: :closed)} #{badge_classes} issuable-status-badge-closed" }) do
.gl-display-none.gl-sm-display-block.gl-ml-2
= issue_closed_text(issuable, current_user)
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 5d749b16eee..9148cb615d4 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -19,9 +19,7 @@
%input.label-color-preview.gl-w-7.gl-h-full.gl-border-1.gl-border-solid.gl-border-gray-500.gl-border-r-0.gl-rounded-top-right-none.gl-rounded-bottom-right-none{ type: "color", placeholder: _('Select color') }
= f.text_field :color, class: "gl-form-input form-control", data: { qa_selector: 'label_color_field' }
.form-text.text-muted
- = _('Choose any color.')
- %br
- = _("Or you can choose one of the suggested colors below")
+ = _('Select a color from the color picker or from the presets below.')
= render_suggested_colors
.gl-display-flex.gl-justify-content-space-between
%div
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index 432d2efc36e..caab7710fa8 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -1,8 +1,6 @@
- milestone_url = @milestone.project_milestone? ? project_milestone_path(@project, @milestone) : group_milestone_path(@group, @milestone)
-= render Pajamas::ButtonComponent.new(variant: :danger,
- button_options: { class: 'js-delete-milestone-button btn-grouped', data: { milestone_id: @milestone.id, milestone_title: markdown_field(@milestone, :title), milestone_url: milestone_url, milestone_issue_count: @milestone.issues.count, milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true }) do
- = gl_loading_icon(inline: true, css_class: "gl-mr-2 js-loading-icon hidden")
- = _('Delete')
-
+%button.gl-button.btn.btn-link.menu-item.js-delete-milestone-button{ data: { milestone_id: @milestone.id, milestone_title: markdown_field(@milestone, :title), milestone_url: milestone_url, milestone_issue_count: @milestone.issues.count, milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true }
+ .gl-dropdown-item-text-wrapper.gl-text-red-500
+ = _('Delete')
#js-delete-milestone-modal
diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml
index d7908b1c210..a63702661d0 100644
--- a/app/views/shared/milestones/_description.html.haml
+++ b/app/views/shared/milestones/_description.html.haml
@@ -1,4 +1,4 @@
-.detail-page-description.milestone-detail.gl-py-5
+.detail-page-description.milestone-detail.gl-py-4
%h2.gl-m-0{ data: { qa_selector: "milestone_title_content" } }
= markdown_field(milestone, :title)
.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'milestone_id_content' }, itemprop: 'identifier' }
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 900c71675d9..3413d6ff399 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -1,30 +1,57 @@
-.detail-page-header.milestone-page-header
- = gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mr-3' }
+.detail-page-header
+ .detail-page-header-body.gl-flex-wrap
+ = gl_badge_tag milestone_status_string(milestone), { variant: milestone_badge_variant(milestone) }, { class: 'gl-mr-3' }
- .header-text-content
- %span.identifier
- %strong
- = _('Milestone')
- - if milestone.due_date || milestone.start_date
- = milestone_date_range(milestone)
-
- .milestone-buttons
- - if can?(current_user, :admin_milestone, @group || @project)
- = render Pajamas::ButtonComponent.new(href: edit_milestone_path(milestone), button_options: { class: 'btn-grouped' }) do
- = _('Edit')
-
- - if milestone.project_milestone? && milestone.project.group
- = 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
+ .header-text-content
+ %span.identifier
+ %strong
+ = _('Milestone')
+ - if milestone.due_date || milestone.start_date
+ = milestone_date_range(milestone)
+ = 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' })
+ - if can?(current_user, :admin_milestone, @group || @project)
+ .milestone-buttons.detail-page-header-actions.gl-display-flex.gl-align-self-start
- if milestone.active?
- = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-grouped btn-close' }) do
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-close gl-display-none gl-md-display-inline-block' }) do
= _('Close milestone')
- else
- = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'btn-grouped' }) do
+ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'gl-display-none gl-md-display-inline-block' }) do
= _('Reopen milestone')
- = render 'shared/milestones/delete_button'
-
- = 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' })
+ .btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full
+ = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Milestone actions'), testid: 'milestone-actions', 'aria-label': _('Milestone actions') }, aria: { label: _('Milestone actions') } do
+ = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
+ = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do
+ %span.gl-dropdown-button-text= _('Milestone actions')
+ = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon"
+ .dropdown-menu.dropdown-menu-right
+ .gl-dropdown-inner
+ .gl-dropdown-contents
+ %ul
+ %li.gl-dropdown-item
+ = link_to edit_milestone_path(milestone), class: 'menu-item' do
+ .gl-dropdown-item-text-wrapper
+ = _('Edit')
+ - if milestone.project_milestone? && milestone.project.group
+ %li.gl-dropdown-item
+ %button.gl-button.btn.btn-link.menu-item.js-promote-project-milestone-button{ data: { milestone_title: milestone.title,
+ group_name: milestone.project.group.name,
+ url: promote_project_milestone_path(milestone.project, milestone)},
+ disabled: true,
+ type: 'button' }
+ .gl-dropdown-item-text-wrapper
+ = _('Promote')
+ #promote-milestone-modal
+ - if milestone.active?
+ %li.gl-dropdown-item{ class: "gl-md-display-none!" }
+ = link_to update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'menu-item' do
+ .gl-dropdown-item-text-wrapper
+ = _('Close milestone')
+ - else
+ %li.gl-dropdown-item{ class: "gl-md-display-none!" }
+ = link_to update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'menu-item' do
+ .gl-dropdown-item-text-wrapper
+ = _('Reopen milestone')
+ %li.gl-dropdown-item
+ = render 'shared/milestones/delete_button'
diff --git a/app/views/shared/nav/_admin_scope_header.html.haml b/app/views/shared/nav/_admin_scope_header.html.haml
new file mode 100644
index 00000000000..3a18b3660d4
--- /dev/null
+++ b/app/views/shared/nav/_admin_scope_header.html.haml
@@ -0,0 +1,6 @@
+%li.context-header
+ = link_to admin_root_path, title: _('Admin Area'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
+ %span.avatar-container.icon-avatar.rect-avatar.s32
+ = sprite_icon('admin', size: 18)
+ %span.sidebar-context-title
+ = _('Admin Area')
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index cbf0b6f1051..72081856da6 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -9,6 +9,7 @@
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning
= _("Finish editing this message first!")
- = submit_tag _('Save comment'), class: 'gl-button btn btn-confirm js-comment-save-button', data: { qa_selector: 'save_comment_button' }
+ = render Pajamas::ButtonComponent.new(type: 'submit', variant: :confirm, button_options: { class: 'js-comment-save-button', data: { qa_selector: 'save_comment_button' } }) do
+ = _("Save comment")
= render Pajamas::ButtonComponent.new(button_options: { class: 'note-edit-cancel' }) do
= _("Cancel")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index c552e94ac57..95e0beee5e0 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -12,7 +12,7 @@
note_id: note.id } }
.timeline-entry-inner
- if note.system
- .timeline-icon
+ .gl-float-left.gl--flex-center.gl-rounded-full.gl-mt-n1.gl-ml-2.gl-w-6.gl-h-6.gl-bg-gray-50.gl-text-gray-600
= icon_for_system_note(note)
- else
.timeline-avatar.gl-float-left
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 09d63347ed6..a2c831bfd1c 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -10,13 +10,12 @@
- skip_pagination = false unless local_assigns[:skip_pagination] == true
- compact_mode = false unless local_assigns[:compact_mode] == true
- css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
-- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
-- starred_projects_illustration_path = 'illustrations/starred_empty.svg'
+- starred_projects_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg'
- starred_projects_current_user_empty_message_header = s_('UserProfile|Star projects to track their progress and show your appreciation.')
- starred_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t starred any projects')
-- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
+- own_projects_illustration_path = 'illustrations/empty-state/empty-projects-md.svg'
- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
- own_projects_visitor_empty_message = s_('UserProfile|There are no projects available to be displayed here.')
@@ -43,7 +42,7 @@
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- if @contributed_projects
- = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
primary_button_label: new_project_button_label,
primary_button_link: new_project_button_link,
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 2adc7844a67..141118110ea 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -14,8 +14,7 @@
- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
- last_pipeline = project.last_pipeline if show_pipeline_status_icon
- css_controls_class = "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
-- css_controls_container_class = compact_mode ? "" : "gl-lg-flex-direction-row gl-justify-content-space-between"
-- css_metadata_classes = "gl-display-flex gl-align-items-center gl-mr-5 gl-reset-color! icon-wrapper has-tooltip"
+- css_metadata_classes = "gl-display-flex gl-align-items-center gl-ml-5 gl-reset-color! icon-wrapper has-tooltip"
%li.project-row
= cache(cache_key) do
@@ -28,7 +27,7 @@
= render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
.project-cell{ class: css_class }
.project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
- .gl-display-flex.gl-align-items-center.gl-flex-wrap-wrap
+ .gl-display-flex.gl-align-items-center.gl-flex-wrap
%h2.gl-font-base.gl-line-height-20.gl-my-0
= link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document' do
%span.namespace-name.gl-font-weight-normal
@@ -55,10 +54,10 @@
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!'
- if show_last_commit_as_description
- .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2
+ .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2.gl-font-sm
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- elsif project.description.present?
- .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2
+ .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2.gl-font-sm
= markdown_field(project, :description)
- if project.topics.any?
@@ -71,7 +70,7 @@
.controls.gl-display-flex.gl-align-items-center
- if show_pipeline_status_icon && last_pipeline.present?
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
- %span.icon-wrapper.pipeline-status.gl-mr-5
+ %span.icon-wrapper.pipeline-status
= render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
@@ -79,17 +78,17 @@
= link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
= sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
= badge_count(project.star_count)
- .updated-note.gl-ml-3.gl-sm-ml-0
+ .updated-note.gl-font-sm.gl-ml-3.gl-sm-ml-0
%span
= _('Updated')
= updated_tooltip
.project-cell{ class: "#{css_class} gl-xs-display-none!" }
- .project-controls.gl-display-flex.gl-flex-direction-column.gl-w-full{ class: css_controls_container_class, data: { testid: 'project_controls'} }
- .controls.gl-display-flex.gl-align-items-center{ class: css_controls_class }
+ .project-controls.gl-display-flex.gl-flex-direction-column.gl-align-items-flex-end.gl-w-full{ data: { testid: 'project_controls'} }
+ .controls.gl-display-flex.gl-align-items-center.gl-mb-2{ class: "#{css_controls_class} gl-pr-0!" }
- if show_pipeline_status_icon && last_pipeline.present?
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
- %span.icon-wrapper.pipeline-status.gl-mr-5
+ %span.icon-wrapper.pipeline-status
= render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
= render_if_exists 'shared/projects/archived', project: project
@@ -109,7 +108,7 @@
= link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do
= sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
= badge_count(project.open_issues_count)
- .updated-note.gl-white-space-nowrap.gl-justify-content-end
+ .updated-note.gl-font-sm.gl-white-space-nowrap.gl-justify-content-end
%span
= _('Updated')
= updated_tooltip
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 47e0e165276..72709b3ed2f 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -2,7 +2,7 @@
- admin_view ||= false
- top_padding = admin_view ? 'gl-lg-pt-3' : ''
-= form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f|
+= form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f|
= search_field_tag :name, params[:name],
placeholder: placeholder,
class: "project-filter-form-field form-control input-short js-projects-list-filter gl-m-0!",
diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml
index be513af4e3f..12246d1dcfa 100644
--- a/app/views/shared/projects/_topics.html.haml
+++ b/app/views/shared/projects/_topics.html.haml
@@ -1,31 +1,29 @@
-- cache_enabled = false unless local_assigns[:cache_enabled] == true
- max_project_topic_length = 15
- if project.topics.present?
- = cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do
- .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' }
- %span.gl-p-2.gl-text-gray-500
- = _('Topics') + ':'
- - project.topics_to_show.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
- - if topic[:title].length > max_project_topic_length
- %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
- - else
- %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag topic[:title]
+ .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' }
+ %span.gl-p-2.gl-text-gray-500
+ = _('Topics') + ':'
+ - project.topics_to_show.each do |topic|
+ - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - if topic[:title].length > max_project_topic_length
+ %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
+ - else
+ %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic[:title]
- - if project.has_extra_topics?
- - title = _('More topics')
- - content = capture do
- %span.gl-display-inline-flex.gl-flex-wrap
- - project.topics_not_shown.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
- - if topic[:title].length > max_project_topic_length
- %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
- - else
- %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag topic[:title]
- .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } }
- = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
+ - if project.has_extra_topics?
+ - title = _('More topics')
+ - content = capture do
+ %span.gl-display-inline-flex.gl-flex-wrap
+ - project.topics_not_shown.each do |topic|
+ - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - if topic[:title].length > max_project_topic_length
+ %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
+ - else
+ %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic[:title]
+ .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } }
+ = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
diff --git a/app/views/shared/users/index.html.haml b/app/views/shared/users/index.html.haml
index dd6b14d6be2..c6a61e1c4df 100644
--- a/app/views/shared/users/index.html.haml
+++ b/app/views/shared/users/index.html.haml
@@ -1,7 +1,7 @@
-- followers_illustration_path = 'illustrations/starred_empty.svg'
+- followers_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg'
- followers_visitor_empty_message = s_('UserProfile|This user doesn\'t have any followers.')
- followers_current_user_empty_message_header = s_('UserProfile|You do not have any followers.')
-- following_illustration_path = 'illustrations/starred_empty.svg'
+- following_illustration_path = 'illustrations/empty-state/empty-projects-starred-md.svg'
- following_visitor_empty_message = s_('UserProfile|This user isn\'t following other users.')
- following_current_user_empty_message_header = s_('UserProfile|You are not following other users.')
diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
index d9155b397b8..f8e2dc3d8dd 100644
--- a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
+++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
@@ -8,6 +8,6 @@
= c.body do
= s_('Webhooks|A webhook in this project was automatically disabled after being retried multiple times.')
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
= c.actions do
= link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button'
diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
index 38a7e6fc813..2c5c3aa68a3 100644
--- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml
+++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml
@@ -1,3 +1,7 @@
+- wiki_path = wiki_page_path(@wiki, wiki_page)
+
%li{ class: active_when(params[:id] == wiki_page.slug) }
- = link_to wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do
- = wiki_page.human_title
+ .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }
+ = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' })
+ = link_to wiki_path, data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do
+ = wiki_page.human_title
diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml
index ced51e1f697..6a066e0a838 100644
--- a/app/views/shared/wikis/_wiki_directory.html.haml
+++ b/app/views/shared/wikis/_wiki_directory.html.haml
@@ -1,8 +1,11 @@
+- wiki_path = wiki_page_path(@wiki, wiki_directory)
+
%li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } }
- .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list<
+ .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }<
= sprite_icon('chevron-right', css_class: 'js-wiki-list-expand-button wiki-list-expand-button gl-mr-2 gl-cursor-pointer')
= sprite_icon('chevron-down', css_class: 'js-wiki-list-collapse-button wiki-list-collapse-button gl-mr-2 gl-cursor-pointer')
- = link_to wiki_page_path(@wiki, wiki_directory), data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do
+ = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' })
+ = link_to wiki_path, data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do
= wiki_directory.title
%ul
- wiki_directory.entries.each do |entry|
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 1d22575803b..eeea8a34002 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,5 +1,5 @@
- link_project = local_assigns.fetch(:link_project, false)
-- illustration_path = 'illustrations/profile-page/activity.svg'
+- illustration_path = 'illustrations/empty-state/empty-snippets-md.svg'
- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
- primary_button_label = _('New snippet')
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 583f25b68eb..fe05a3de13a 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -5,7 +5,7 @@
- else
- add_page_startup_graphql_call('snippet/user_permissions')
- if @snippet.author != current_user
- -# Different breadcrumbs if this page is rendered as part of the Explore section
+ -# If current user is not the snippet author, then it renders with the Explore layout which doesn't have this breadcrumb.
- add_to_breadcrumbs _("Snippets"), explore_snippets_path
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
diff --git a/app/views/time_tracking/timelogs/index.html.haml b/app/views/time_tracking/timelogs/index.html.haml
new file mode 100644
index 00000000000..b0bfc749606
--- /dev/null
+++ b/app/views/time_tracking/timelogs/index.html.haml
@@ -0,0 +1,7 @@
+- @force_fluid_layout = true
+- page_title _('Time tracking report')
+
+.page-title-holder.gl-display-flex.gl-flex-align-items-center
+ %h1.page-title.gl-font-size-h-display= _('Time tracking report')
+
+#js-timelogs-app{ data: { limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index a7875f9b089..ce82a5e1614 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -14,9 +14,10 @@
.gl-display-flex
%ol.breadcrumb.gl-breadcrumb-list.gl-mb-4
%li.breadcrumb-item.gl-breadcrumb-item
- = link_to @user.username, project_path(@user.user_project)
- %span.gl-breadcrumb-separator
- = sprite_icon("chevron-right", size: 16)
+ = link_to project_path(@user.user_project) do
+ = @user.username
+ %span.gl-breadcrumb-separator
+ = sprite_icon("chevron-right", size: 16)
%li.breadcrumb-item.gl-breadcrumb-item
= link_to @user.user_readme.path, @user.user_project.readme_url
- if current_user == @user
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 3543d5c4336..70dccc4821b 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,6 +1,6 @@
- @hide_top_links = true
-- @hide_breadcrumbs = true
- @no_container = true
+- breadcrumb_title user_display_name(@user)
- page_title user_display_name(@user)
- page_description @user.bio unless @user.blocked? || !@user.confirmed?
- page_itemtype 'http://schema.org/Person'
@@ -14,161 +14,163 @@
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
.user-profile
- .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty?)] }
- = render layout: 'users/cover_controls' do
- - if @user == current_user
- = render Pajamas::ButtonComponent.new(href: profile_path,
- icon: 'pencil',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - elsif current_user
- #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } }
- - verified_gpg_keys = @user.gpg_keys.select(&:verified?)
- - if verified_gpg_keys.any?
- = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,
- icon: 'key',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - if can?(current_user, :read_user_profile, @user)
- = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options),
- icon: 'rss',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
- - if current_user && current_user.admin?
- = render Pajamas::ButtonComponent.new(href: [:admin, @user],
- icon: 'user',
- button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}})
- - if current_user && current_user.id != @user.id
- - if current_user.following?(@user)
- = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
- = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
- = _('Unfollow')
- - else
- = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
- = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
- = _('Follow')
+ %div{ class: container_class }
+ .cover-block.user-cover-block{ class: [('border-bottom' if profile_tabs.empty? || show_super_sidebar?)] }
+ = render layout: 'users/cover_controls' do
+ - if @user == current_user
+ = render Pajamas::ButtonComponent.new(href: profile_path,
+ icon: 'pencil',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ - elsif current_user
+ #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } }
+ - verified_gpg_keys = @user.gpg_keys.select(&:verified?)
+ - if verified_gpg_keys.any?
+ = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path,
+ icon: 'key',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ - if can?(current_user, :read_user_profile, @user)
+ = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options),
+ icon: 'rss',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }})
+ - if current_user && current_user.admin?
+ = render Pajamas::ButtonComponent.new(href: [:admin, @user],
+ icon: 'user',
+ button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'}})
+ - if current_user && current_user.id != @user.id
+ - if current_user.following?(@user)
+ = form_tag user_unfollow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
+ = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
+ = _('Unfollow')
+ - else
+ = form_tag user_follow_path(@user, :json), class: link_classes + 'gl-display-inline-block' do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
+ = _('Follow')
- .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
- .gl-display-inline-block.gl-mx-8.gl-vertical-align-top
- .avatar-holder
- = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
- = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
- #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } }
- .gl-display-inline-block.gl-vertical-align-top.gl-text-left
- - if @user.blocked? || !@user.confirmed?
- .user-info
- %h1.cover-title.gl-my-0
- = user_display_name(@user)
- = render "users/profile_basic_info"
- - else
- .user-info
- %h1.cover-title.gl-my-0{ itemprop: 'name' }
- = @user.name
- - if @user.pronouns.present?
- %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
- = "(#{@user.pronouns})"
- - if @user.status&.busy?
- %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
+ .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
+ .gl-display-inline-block.gl-mx-8.gl-vertical-align-top
+ .avatar-holder
+ = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
+ = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
+ - if @user.achievements_enabled && Ability.allowed?(current_user, :read_user_profile, @user)
+ #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } }
+ .gl-display-inline-block.gl-vertical-align-top.gl-text-left.gl-max-w-80
+ - if @user.blocked? || !@user.confirmed?
+ .user-info
+ %h1.cover-title.gl-my-0
+ = user_display_name(@user)
+ = render "users/profile_basic_info"
+ - else
+ .user-info
+ %h1.cover-title.gl-my-0{ itemprop: 'name' }
+ = @user.name
+ - if @user.pronouns.present?
+ %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
+ = "(#{@user.pronouns})"
+ - if @user.status&.busy?
+ = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning', class: 'gl-vertical-align-middle')
- - if @user.pronunciation.present?
- .gl-align-items-center
- %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
+ - if @user.pronunciation.present?
+ .gl-align-items-center
+ %p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
- - if @user.status&.customized?
- .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3
- = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
- = markdown_field(@user.status, :message)
- = render "users/profile_basic_info"
- - user_local_time = local_time(@user.timezone)
- - if @user.location.present? || user_local_time.present? || work_information(@user).present?
+ - if @user.status&.customized?
+ .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3
+ = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
+ = markdown_field(@user.status, :message)
+ = render "users/profile_basic_info"
+ - user_local_time = local_time(@user.timezone)
+ - if @user.location.present? || user_local_time.present? || work_information(@user).present?
+ .gl-text-gray-900
+ - if @user.location.present?
+ = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
+ = sprite_icon('location', css_class: 'fgray')
+ %span{ itemprop: 'addressLocality' }
+ = @user.location
+ - if user_local_time.present?
+ = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
+ = sprite_icon('clock', css_class: 'fgray')
+ %span
+ = user_local_time
+ - if work_information(@user).present?
+ = render 'middle_dot_divider', stacking: true do
+ = sprite_icon('work', css_class: 'fgray')
+ %span
+ = work_information(@user, with_schema_markup: true)
.gl-text-gray-900
- - if @user.location.present?
- = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
- = sprite_icon('location', css_class: 'fgray')
- %span{ itemprop: 'addressLocality' }
- = @user.location
- - if user_local_time.present?
- = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
- = sprite_icon('clock', css_class: 'fgray')
- %span
- = user_local_time
- - if work_information(@user).present?
+ - if @user.skype.present?
+ = render 'middle_dot_divider' do
+ = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
+ = sprite_icon('skype', css_class: 'skype-icon')
+ - if @user.linkedin.present?
+ = render 'middle_dot_divider' do
+ = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('linkedin', css_class: 'linkedin-icon')
+ - if @user.twitter.present?
+ = render 'middle_dot_divider', breakpoint: 'sm' do
+ = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('twitter', css_class: 'twitter-icon')
+ - if @user.discord.present?
+ = render 'middle_dot_divider', breakpoint: 'sm' do
+ = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = sprite_icon('discord', css_class: 'discord-icon')
+ - if @user.website_url.present?
+ = render 'middle_dot_divider', stacking: true do
+ - if Feature.enabled?(:security_auto_fix) && @user.bot?
+ = sprite_icon('question-o', css_class: 'gl-text-blue-500')
+ = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
+ - if display_public_email?(@user)
= render 'middle_dot_divider', stacking: true do
- = sprite_icon('work', css_class: 'fgray')
- %span
- = work_information(@user, with_schema_markup: true)
- .gl-text-gray-900
- - if @user.skype.present?
- = render 'middle_dot_divider' do
- = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
- = sprite_icon('skype', css_class: 'skype-icon')
- - if @user.linkedin.present?
- = render 'middle_dot_divider' do
- = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('linkedin', css_class: 'linkedin-icon')
- - if @user.twitter.present?
- = render 'middle_dot_divider', breakpoint: 'sm' do
- = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('twitter', css_class: 'twitter-icon')
- - if @user.discord.present?
- = render 'middle_dot_divider', breakpoint: 'sm' do
- = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = sprite_icon('discord', css_class: 'discord-icon')
- - if @user.website_url.present?
- = render 'middle_dot_divider', stacking: true do
- - if Feature.enabled?(:security_auto_fix) && @user.bot?
- = sprite_icon('question', css_class: 'gl-text-blue-600')
- = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- - if display_public_email?(@user)
- = render 'middle_dot_divider', stacking: true do
- = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
- - if @user.bio.present? && @user.confirmed? && !@user.blocked?
- %p.profile-user-bio.gl-mb-3
- = @user.bio
+ = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
+ - if @user.bio.present? && @user.confirmed? && !@user.blocked?
+ %p.profile-user-bio.gl-mb-3
+ = @user.bio
- - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user)
- .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] }
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
- %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs
- - if profile_tab?(:overview)
- %li.js-overview-tab
- = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do
- = s_('UserProfile|Overview')
- - if profile_tab?(:activity)
- %li.js-activity-tab
- = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
- = s_('UserProfile|Activity')
- - unless Feature.enabled?(:security_auto_fix) && @user.bot?
- - if profile_tab?(:groups)
- %li.js-groups-tab
- = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
- = s_('UserProfile|Groups')
- - if profile_tab?(:contributed)
- %li.js-contributed-tab
- = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
- = s_('UserProfile|Contributed projects')
- - if profile_tab?(:projects)
- %li.js-projects-tab
- = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
- = s_('UserProfile|Personal projects')
- - if profile_tab?(:starred)
- %li.js-starred-tab
- = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
- = s_('UserProfile|Starred projects')
- - if profile_tab?(:snippets)
- %li.js-snippets-tab
- = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
- = s_('UserProfile|Snippets')
- - if profile_tab?(:followers)
- %li.js-followers-tab
- = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do
- = s_('UserProfile|Followers')
- = gl_badge_tag @user.followers.count, size: :sm
- - if profile_tab?(:following)
- %li.js-following-tab
- = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), qa_selector: 'following_tab' } do
- = s_('UserProfile|Following')
- = gl_badge_tag @user.followees.count, size: :sm
- - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user)
- #js-profile-tabs{ data: user_profile_tabs_app_data(@user) }
+ - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user)
+ .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] }
+ .fade-left= sprite_icon('chevron-lg-left', size: 12)
+ .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs
+ - if profile_tab?(:overview)
+ %li.js-overview-tab
+ = link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do
+ = s_('UserProfile|Overview')
+ - if profile_tab?(:activity)
+ %li.js-activity-tab
+ = link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
+ = s_('UserProfile|Activity')
+ - unless Feature.enabled?(:security_auto_fix) && @user.bot?
+ - if profile_tab?(:groups)
+ %li.js-groups-tab
+ = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
+ = s_('UserProfile|Groups')
+ - if profile_tab?(:contributed)
+ %li.js-contributed-tab
+ = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
+ = s_('UserProfile|Contributed projects')
+ - if profile_tab?(:projects)
+ %li.js-projects-tab
+ = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
+ = s_('UserProfile|Personal projects')
+ - if profile_tab?(:starred)
+ %li.js-starred-tab
+ = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
+ = s_('UserProfile|Starred projects')
+ - if profile_tab?(:snippets)
+ %li.js-snippets-tab
+ = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
+ = s_('UserProfile|Snippets')
+ - if profile_tab?(:followers)
+ %li.js-followers-tab
+ = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do
+ = s_('UserProfile|Followers')
+ = gl_badge_tag @user.followers.count, size: :sm
+ - if profile_tab?(:following)
+ %li.js-following-tab
+ = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), qa_selector: 'following_tab' } do
+ = s_('UserProfile|Following')
+ = gl_badge_tag @user.followees.count, size: :sm
+ - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user)
+ #js-profile-tabs{ data: user_profile_tabs_app_data(@user) }
%div{ class: container_class }
- unless Feature.enabled?(:profile_tabs_vue, current_user)
.tab-content
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 1624538152e..f47d5da95f0 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -131,7 +131,7 @@
:tags: []
- :name: cluster_agent:clusters_agents_delete_expired_events
:worker_name: Clusters::Agents::DeleteExpiredEventsWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -374,7 +374,7 @@
:tags: []
- :name: cronjob:database_ci_namespace_mirrors_consistency_check
:worker_name: Database::CiNamespaceMirrorsConsistencyCheckWorker
- :feature_category: :pods
+ :feature_category: :cell
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -383,7 +383,7 @@
:tags: []
- :name: cronjob:database_ci_project_mirrors_consistency_check
:worker_name: Database::CiProjectMirrorsConsistencyCheckWorker
- :feature_category: :pods
+ :feature_category: :cell
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -518,7 +518,7 @@
:tags: []
- :name: cronjob:loose_foreign_keys_cleanup
:worker_name: LooseForeignKeys::CleanupWorker
- :feature_category: :pods
+ :feature_category: :cell
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -543,6 +543,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:metrics_global_metrics_update
+ :worker_name: Metrics::GlobalMetricsUpdateWorker
+ :feature_category: :metrics
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:namespaces_in_product_marketing_emails
:worker_name: Namespaces::InProductMarketingEmailsWorker
:feature_category: :experimentation_activation
@@ -579,6 +588,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:packages_debian_cleanup_dangling_package_files
+ :worker_name: Packages::Debian::CleanupDanglingPackageFilesWorker
+ :feature_category: :package_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:pages_domain_removal_cron
:worker_name: PagesDomainRemovalCronWorker
:feature_category: :pages
@@ -833,7 +851,7 @@
:tags: []
- :name: cronjob:users_deactivate_dormant_users
:worker_name: Users::DeactivateDormantUsersWorker
- :feature_category: :subscription_cost_management
+ :feature_category: :seat_cost_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -932,7 +950,7 @@
:tags: []
- :name: gcp_cluster:cluster_configure_istio
:worker_name: ClusterConfigureIstioWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -941,7 +959,7 @@
:tags: []
- :name: gcp_cluster:cluster_install_app
:worker_name: ClusterInstallAppWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -950,7 +968,7 @@
:tags: []
- :name: gcp_cluster:cluster_patch_app
:worker_name: ClusterPatchAppWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -959,7 +977,7 @@
:tags: []
- :name: gcp_cluster:cluster_provision
:worker_name: ClusterProvisionWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -968,7 +986,7 @@
:tags: []
- :name: gcp_cluster:cluster_update_app
:worker_name: ClusterUpdateAppWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -977,7 +995,7 @@
:tags: []
- :name: gcp_cluster:cluster_upgrade_app
:worker_name: ClusterUpgradeAppWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -986,7 +1004,7 @@
:tags: []
- :name: gcp_cluster:cluster_wait_for_app_installation
:worker_name: ClusterWaitForAppInstallationWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :cpu
@@ -995,7 +1013,7 @@
:tags: []
- :name: gcp_cluster:cluster_wait_for_app_update
:worker_name: ClusterWaitForAppUpdateWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -1004,7 +1022,7 @@
:tags: []
- :name: gcp_cluster:cluster_wait_for_ingress_ip_address
:worker_name: ClusterWaitForIngressIpAddressWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -1013,7 +1031,7 @@
:tags: []
- :name: gcp_cluster:clusters_applications_activate_integration
:worker_name: Clusters::Applications::ActivateIntegrationWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -1022,7 +1040,7 @@
:tags: []
- :name: gcp_cluster:clusters_applications_deactivate_integration
:worker_name: Clusters::Applications::DeactivateIntegrationWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -1031,7 +1049,7 @@
:tags: []
- :name: gcp_cluster:clusters_applications_uninstall
:worker_name: Clusters::Applications::UninstallWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -1040,7 +1058,7 @@
:tags: []
- :name: gcp_cluster:clusters_applications_wait_for_uninstall_app
:worker_name: Clusters::Applications::WaitForUninstallAppWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :cpu
@@ -1049,7 +1067,7 @@
:tags: []
- :name: gcp_cluster:clusters_cleanup_project_namespace
:worker_name: Clusters::Cleanup::ProjectNamespaceWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -1058,7 +1076,7 @@
:tags: []
- :name: gcp_cluster:clusters_cleanup_service_account
:worker_name: Clusters::Cleanup::ServiceAccountWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -1067,7 +1085,7 @@
:tags: []
- :name: gcp_cluster:wait_for_cluster_creation
:worker_name: WaitForClusterCreationWorker
- :feature_category: :kubernetes_management
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -1740,6 +1758,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: package_repositories:packages_npm_deprecate_package
+ :worker_name: Packages::Npm::DeprecatePackageWorker
+ :feature_category: :package_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_repositories:packages_nuget_extraction
:worker_name: Packages::Nuget::ExtractionWorker
:feature_category: :package_registry
@@ -2294,7 +2321,7 @@
:feature_category: :importers
:has_external_dependencies: true
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: false
:tags: []
@@ -2303,7 +2330,7 @@
:feature_category: :importers
:has_external_dependencies: false
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: true
:tags: []
@@ -2919,9 +2946,18 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: ml_experiment_tracking_associate_ml_candidate_to_package
+ :worker_name: Ml::ExperimentTracking::AssociateMlCandidateToPackageWorker
+ :feature_category: :mlops
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: namespaces_process_sync_events
:worker_name: Namespaces::ProcessSyncEventsWorker
- :feature_category: :pods
+ :feature_category: :cell
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -3200,7 +3236,7 @@
:tags: []
- :name: projects_process_sync_events
:worker_name: Projects::ProcessSyncEventsWorker
- :feature_category: :pods
+ :feature_category: :cell
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -3347,7 +3383,7 @@
:feature_category: :importers
:has_external_dependencies: true
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: false
:tags: []
@@ -3434,7 +3470,7 @@
:tags: []
- :name: update_highest_role
:worker_name: UpdateHighestRoleWorker
- :feature_category: :subscription_cost_management
+ :feature_category: :seat_cost_management
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -3504,6 +3540,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: work_items_import_work_items_csv
+ :worker_name: WorkItems::ImportWorkItemsCsvWorker
+ :feature_category: :team_planning
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: x509_certificate_revoke
:worker_name: X509CertificateRevokeWorker
:feature_category: :source_code_management
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 8f03c74e13e..f03e0bc0656 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -12,6 +12,7 @@ module BulkImports
sidekiq_options retry: false, dead: false
worker_has_external_dependencies!
deduplicate :until_executing
+ worker_resource_boundary :memory
def perform(pipeline_tracker_id, stage, entity_id)
@entity = ::BulkImports::Entity.find(entity_id)
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index dcac841b3b2..9d1ed30caf6 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -11,6 +11,7 @@ module BulkImports
data_consistency :always
feature_category :importers
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+ worker_resource_boundary :memory
def perform(user_id, portable_id, portable_class, relation)
user = User.find(user_id)
diff --git a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb
index 9a11db33fb6..9407e7c0e0a 100644
--- a/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb
+++ b/app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb
@@ -15,7 +15,7 @@ module Ci
idempotent!
def perform
- result = ::Ci::Runners::StaleMachinesCleanupService.new.execute
+ result = ::Ci::Runners::StaleManagersCleanupService.new.execute
log_extra_metadata_on_done(:status, result.status)
log_hash_metadata_on_done(result.payload)
end
diff --git a/app/workers/concerns/cluster_agent_queue.rb b/app/workers/concerns/cluster_agent_queue.rb
index 68de7cca135..8fdfba11111 100644
--- a/app/workers/concerns/cluster_agent_queue.rb
+++ b/app/workers/concerns/cluster_agent_queue.rb
@@ -5,6 +5,6 @@ module ClusterAgentQueue
included do
queue_namespace :cluster_agent
- feature_category :kubernetes_management
+ feature_category :deployment_management
end
end
diff --git a/app/workers/concerns/cluster_cleanup_methods.rb b/app/workers/concerns/cluster_cleanup_methods.rb
index 04fa4d69666..c0e670dfbe7 100644
--- a/app/workers/concerns/cluster_cleanup_methods.rb
+++ b/app/workers/concerns/cluster_cleanup_methods.rb
@@ -55,19 +55,12 @@ module ClusterCleanupMethods
cluster.make_cleanup_errored!("#{self.class.name} exceeded the execution limit")
end
- def cluster_applications_and_status(cluster)
- cluster.persisted_applications
- .map { |application| "#{application.name}:#{application.status_name}" }
- .join(",")
- end
-
def log_exceeded_execution_limit_error(cluster)
logger.error({
exception: ExceededExecutionLimitError.name,
cluster_id: cluster.id,
class_name: self.class.name,
cleanup_status: cluster.cleanup_status_name,
- applications: cluster_applications_and_status(cluster),
event: :failed_to_remove_cluster_and_resources,
message: "exceeded execution limit of #{execution_limit} tries"
})
diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb
index 60ba8785347..5f1a90a99d0 100644
--- a/app/workers/concerns/cluster_queue.rb
+++ b/app/workers/concerns/cluster_queue.rb
@@ -8,6 +8,6 @@ module ClusterQueue
included do
queue_namespace :gcp_cluster
- feature_category :kubernetes_management
+ feature_category :deployment_management
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 7e488862696..408354d5caa 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -134,6 +134,8 @@ module Gitlab
end
def add_identifiers_to_failure(failure, external_identifiers)
+ external_identifiers[:object_type] = object_type
+
failure.update_column(:external_identifiers, external_identifiers)
end
end
diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb
index 37b40c73ca6..53c92ab8969 100644
--- a/app/workers/database/batched_background_migration/execution_worker.rb
+++ b/app/workers/database/batched_background_migration/execution_worker.rb
@@ -11,7 +11,6 @@ module Database
INTERVAL_VARIANCE = 5.seconds.freeze
LEASE_TIMEOUT_MULTIPLIER = 3
- MAX_RUNNING_MIGRATIONS = 4
included do
data_consistency :always
@@ -21,7 +20,7 @@ module Database
class_methods do
def max_running_jobs
- MAX_RUNNING_MIGRATIONS
+ Gitlab::CurrentSettings.database_max_running_batched_background_migrations
end
# We have to overirde this one, as we want
diff --git a/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb
index 8918dca372d..e01b29ad4ff 100644
--- a/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb
+++ b/app/workers/database/ci_namespace_mirrors_consistency_check_worker.rb
@@ -6,7 +6,7 @@ module Database
include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
sidekiq_options retry: false
- feature_category :pods
+ feature_category :cell
data_consistency :sticky
idempotent!
diff --git a/app/workers/database/ci_project_mirrors_consistency_check_worker.rb b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb
index 5f10310f8d6..e04e3ab3cc7 100644
--- a/app/workers/database/ci_project_mirrors_consistency_check_worker.rb
+++ b/app/workers/database/ci_project_mirrors_consistency_check_worker.rb
@@ -6,7 +6,7 @@ module Database
include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
sidekiq_options retry: false
- feature_category :pods
+ feature_category :cell
data_consistency :sticky
idempotent!
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index 339383476be..99704b2a71c 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -21,7 +21,7 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker
end
def should_perform?
- Gitlab::IncomingEmail.enabled?
+ Gitlab::Email::IncomingEmail.enabled?
end
private
diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
index fb7fb661f4c..8cbbe35dd30 100644
--- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb
+++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
@@ -14,12 +14,21 @@ module Gitlab
sidekiq_options dead: false, retry: 5
+ sidekiq_retries_exhausted do |msg, _|
+ new.track_gist_import('failed', msg['args'][0])
+ end
+
def perform(user_id, gist_hash, notify_key)
gist = ::Gitlab::GithubGistsImport::Representation::Gist.from_json_hash(gist_hash)
with_logging(user_id, gist.github_identifiers) do
result = importer_class.new(gist, user_id).execute
- error(user_id, result.errors, gist.github_identifiers) unless result.success?
+ if result.success?
+ track_gist_import('success', user_id)
+ else
+ error(user_id, result.errors, gist.github_identifiers)
+ track_gist_import('failed', user_id)
+ end
JobWaiter.notify(notify_key, jid)
end
@@ -29,6 +38,18 @@ module Gitlab
raise
end
+ def track_gist_import(status, user_id)
+ user = User.find(user_id)
+
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'github_gist_import',
+ user: user,
+ status: status
+ )
+ end
+
private
def importer_class
diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb
index 6d6dea10e64..73f4ea580c4 100644
--- a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb
@@ -15,9 +15,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- info(project.id,
- message: "starting importer",
- importer: 'Importer::ProtectedBranchesImporter')
+ info(project.id, message: "starting importer", importer: 'Importer::ProtectedBranchesImporter')
waiter = Importer::ProtectedBranchesImporter
.new(project, client)
.execute
diff --git a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb
index 5e675193a8c..a216f3d4ebc 100644
--- a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb
+++ b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb
@@ -8,9 +8,11 @@ module Gitlab
private
def track_metrics(with_jid_count, without_jid_count)
- Gitlab::Metrics.add_event(:stuck_jira_import_jobs,
- jira_imports_without_jid_count: with_jid_count,
- jira_imports_with_jid_count: without_jid_count)
+ Gitlab::Metrics.add_event(
+ :stuck_jira_import_jobs,
+ jira_imports_without_jid_count: with_jid_count,
+ jira_imports_with_jid_count: without_jid_count
+ )
end
def enqueued_import_states
diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb
index d5e3a86eac1..7235eb4ef4b 100644
--- a/app/workers/issuable_export_csv_worker.rb
+++ b/app/workers/issuable_export_csv_worker.rb
@@ -9,7 +9,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :team_planning
worker_resource_boundary :cpu
- loggable_arguments 2
+ loggable_arguments 0, 1, 2, 3
def perform(type, current_user_id, project_id, params)
user = User.find(current_user_id)
diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb
index 6576aa9fdf4..a0a56695689 100644
--- a/app/workers/jira_connect/sync_merge_request_worker.rb
+++ b/app/workers/jira_connect/sync_merge_request_worker.rb
@@ -14,10 +14,13 @@ module JiraConnect
def perform(merge_request_id, update_sequence_id)
merge_request = MergeRequest.find_by_id(merge_request_id)
+ project = merge_request&.project
- return unless merge_request && merge_request.project
+ return unless merge_request && project
- JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request], update_sequence_id: update_sequence_id)
+ branches = [project.repository.find_branch(merge_request.source_branch)].compact.presence if merge_request.open?
+
+ JiraConnect::SyncService.new(project).execute(merge_requests: [merge_request], branches: branches, update_sequence_id: update_sequence_id)
end
end
end
diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb
index b0ebaf30e99..aa9784e4abb 100644
--- a/app/workers/jira_connect/sync_project_worker.rb
+++ b/app/workers/jira_connect/sync_project_worker.rb
@@ -3,6 +3,7 @@
module JiraConnect
class SyncProjectWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ include SortingTitlesValuesHelper
sidekiq_options retry: 3
queue_namespace :jira_connect
@@ -12,22 +13,31 @@ module JiraConnect
worker_has_external_dependencies!
- MERGE_REQUEST_LIMIT = 400
+ MAX_RECORDS_LIMIT = 400
def perform(project_id, update_sequence_id)
project = Project.find_by_id(project_id)
return if project.nil?
- JiraConnect::SyncService.new(project).execute(merge_requests: merge_requests_to_sync(project), update_sequence_id: update_sequence_id)
+ sync_params = { merge_requests: merge_requests_to_sync(project), update_sequence_id: update_sequence_id }
+ sync_params[:branches] = branches_to_sync(project) if Feature.enabled?(:jira_connect_sync_branches, project)
+
+ JiraConnect::SyncService.new(project).execute(**sync_params)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def merge_requests_to_sync(project)
- project.merge_requests.with_jira_issue_keys.preload(:author).limit(MERGE_REQUEST_LIMIT).order(id: :desc)
+ project.merge_requests.with_jira_issue_keys.preload(:author).limit(MAX_RECORDS_LIMIT).order(id: :desc)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def branches_to_sync(project)
+ project.repository.branches_sorted_by(SORT_UPDATED_RECENT).filter_map do |branch|
+ branch if branch.name.match(Gitlab::Regex.jira_issue_key_regex)
+ end.first(MAX_RECORDS_LIMIT)
+ end
end
end
diff --git a/app/workers/loose_foreign_keys/cleanup_worker.rb b/app/workers/loose_foreign_keys/cleanup_worker.rb
index 9a0909598bb..e6d0261b7f1 100644
--- a/app/workers/loose_foreign_keys/cleanup_worker.rb
+++ b/app/workers/loose_foreign_keys/cleanup_worker.rb
@@ -7,7 +7,7 @@ module LooseForeignKeys
include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
sidekiq_options retry: false
- feature_category :pods
+ feature_category :cell
data_consistency :always
idempotent!
diff --git a/app/workers/metrics/global_metrics_update_worker.rb b/app/workers/metrics/global_metrics_update_worker.rb
new file mode 100644
index 00000000000..326403a2f8f
--- /dev/null
+++ b/app/workers/metrics/global_metrics_update_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Metrics
+ class GlobalMetricsUpdateWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :sticky
+ feature_category :metrics
+
+ include ExclusiveLeaseGuard
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ LEASE_TIMEOUT = 2.minutes
+
+ def perform
+ try_obtain_lease { ::Metrics::GlobalMetricsUpdateService.new.execute }
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+ end
+end
diff --git a/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb b/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb
new file mode 100644
index 00000000000..b9c75c01f81
--- /dev/null
+++ b/app/workers/ml/experiment_tracking/associate_ml_candidate_to_package_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Ml
+ module ExperimentTracking
+ class AssociateMlCandidateToPackageWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :mlops
+ urgency :low
+ idempotent!
+
+ def handle_event(event)
+ return unless (candidate = Ml::Candidate.with_project_id_and_iid(event.data[:project_id], event.data[:version]))
+ return unless (package = Packages::Package.find_by_id(event.data[:id]))
+
+ candidate.package = package
+ candidate.save!
+ end
+
+ def self.handles_event?(event)
+ event.generic? && Ml::Experiment.package_for_experiment?(event.data[:name])
+ end
+ end
+ end
+end
diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb
index d0124c69781..112badd08b5 100644
--- a/app/workers/namespaces/process_sync_events_worker.rb
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -9,7 +9,7 @@ module Namespaces
data_consistency :always
- feature_category :pods
+ feature_category :cell
urgency :high
idempotent!
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
index 02b3468c052..c8527182d35 100644
--- a/app/workers/namespaces/root_statistics_worker.rb
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -16,15 +16,23 @@ module Namespaces
def perform(namespace_id)
namespace = Namespace.find(namespace_id)
+ if Feature.enabled?(:remove_aggregation_schedule_lease, namespace)
+ Namespaces::StatisticsRefresherService.new.execute(namespace)
+ else
+ refresh_through_namespace_aggregation_schedule(namespace)
+ end
+
+ notify_storage_usage(namespace)
+ rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
+ Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path)
+ end
+
+ def refresh_through_namespace_aggregation_schedule(namespace)
return unless namespace.aggregation_scheduled?
Namespaces::StatisticsRefresherService.new.execute(namespace)
namespace.aggregation_schedule.destroy
-
- notify_storage_usage(namespace)
- rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
- Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path)
end
private
diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb
index 7cd7f5223d6..bf48eb8180e 100644
--- a/app/workers/namespaces/schedule_aggregation_worker.rb
+++ b/app/workers/namespaces/schedule_aggregation_worker.rb
@@ -13,16 +13,24 @@ module Namespaces
idempotent!
def perform(namespace_id)
- return unless aggregation_schedules_table_exists?
-
namespace = Namespace.find(namespace_id)
root_ancestor = namespace.root_ancestor
+ if Feature.enabled?(:remove_aggregation_schedule_lease, root_ancestor)
+ Namespaces::RootStatisticsWorker.perform_async(root_ancestor.id)
+ else
+ schedule_through_aggregation_schedules_table(root_ancestor)
+ end
+ rescue ActiveRecord::RecordNotFound => ex
+ Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id)
+ end
+
+ def schedule_through_aggregation_schedules_table(root_ancestor)
+ return unless aggregation_schedules_table_exists?
+
return if root_ancestor.aggregation_scheduled?
Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: root_ancestor.id)
- rescue ActiveRecord::RecordNotFound => ex
- Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id)
end
private
diff --git a/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb b/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb
new file mode 100644
index 00000000000..03b272db026
--- /dev/null
+++ b/app/workers/packages/debian/cleanup_dangling_package_files_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ class CleanupDanglingPackageFilesWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :always
+
+ deduplicate :until_executed
+ idempotent!
+
+ feature_category :package_registry
+
+ THREE_HOUR = 3.hours.freeze
+ BATCH_TIMEOUT = 250.seconds.freeze
+
+ def perform
+ return unless Feature.enabled?(:debian_packages)
+
+ package_files = Packages::PackageFile.with_debian_unknown_since(THREE_HOUR.ago)
+ .installable
+
+ Packages::MarkPackageFilesForDestructionService.new(package_files)
+ .execute(batch_deadline: Time.zone.now + BATCH_TIMEOUT)
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e)
+ end
+ end
+ end
+end
diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb
index e9d6ad57749..8e7f0b3b987 100644
--- a/app/workers/packages/debian/process_package_file_worker.rb
+++ b/app/workers/packages/debian/process_package_file_worker.rb
@@ -26,7 +26,8 @@ module Packages
::Packages::Debian::ProcessPackageFileService.new(package_file, distribution_name, component_name).execute
rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id,
- distribution_name: @distribution_name, component_name: @component_name)
+ distribution_name: @distribution_name, component_name: @component_name)
+ package_file.update_column(:status, :error)
package_file.package.update_column(:status, :error)
end
diff --git a/app/workers/packages/npm/deprecate_package_worker.rb b/app/workers/packages/npm/deprecate_package_worker.rb
new file mode 100644
index 00000000000..1fd324b89c3
--- /dev/null
+++ b/app/workers/packages/npm/deprecate_package_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class DeprecatePackageWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+ queue_namespace :package_repositories
+ feature_category :package_registry
+ deduplicate :until_executed
+ urgency :low
+ idempotent!
+
+ def perform(project_id, params)
+ project = Project.find(project_id)
+
+ ::Packages::Npm::DeprecatePackageService.new(project, params).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb
index 4bbe1b65e5a..b088aed8fb7 100644
--- a/app/workers/projects/process_sync_events_worker.rb
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -9,7 +9,7 @@ module Projects
data_consistency :always
- feature_category :pods
+ feature_category :cell
urgency :high
idempotent!
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index f9e12c5135a..641b2291896 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -12,6 +12,7 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
# Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab/-/issues/16812 is solved.
sidekiq_options retry: false, dead: false
sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION
+ worker_resource_boundary :memory
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index 9265449fdf4..598cf9ce567 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -25,10 +25,12 @@ class RepositoryUpdateRemoteMirrorWorker
# If the update is already running, wait for it to finish before running again
# This will wait for a total of 90 seconds in 3 steps
- in_lock(remote_mirror_update_lock(remote_mirror.id),
- retries: 3,
- ttl: remote_mirror.max_runtime,
- sleep_sec: LOCK_WAIT_TIME) do
+ in_lock(
+ remote_mirror_update_lock(remote_mirror.id),
+ retries: 3,
+ ttl: remote_mirror.max_runtime,
+ sleep_sec: LOCK_WAIT_TIME
+ ) do
update_mirror(remote_mirror, scheduled_time, tries)
end
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index a7037863ef5..4ca366efcad 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -63,15 +63,17 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker
end
def track_error(schedule, error)
- Gitlab::ErrorTracking
- .track_and_raise_for_dev_exception(error,
- issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
- schedule_id: schedule.id)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
+ error,
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
+ schedule_id: schedule.id
+ )
end
def failed_creation_counter
- @failed_creation_counter ||=
- Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total,
- "Counter of failed attempts of pipeline schedule creation")
+ @failed_creation_counter ||= Gitlab::Metrics.counter(
+ :pipeline_schedule_creation_failed_total,
+ "Counter of failed attempts of pipeline schedule creation"
+ )
end
end
diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb
index b3b36ca2ada..c41ba05abaa 100644
--- a/app/workers/service_desk_email_receiver_worker.rb
+++ b/app/workers/service_desk_email_receiver_worker.rb
@@ -10,7 +10,7 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Sca
sidekiq_options retry: 3
def should_perform?
- ::Gitlab::ServiceDeskEmail.enabled?
+ ::Gitlab::Email::ServiceDeskEmail.enabled?
end
def receiver
diff --git a/app/workers/stuck_export_jobs_worker.rb b/app/workers/stuck_export_jobs_worker.rb
index 486d40c443a..ab06ca3107e 100644
--- a/app/workers/stuck_export_jobs_worker.rb
+++ b/app/workers/stuck_export_jobs_worker.rb
@@ -20,8 +20,7 @@ class StuckExportJobsWorker
def perform
failed_jobs_count = mark_stuck_jobs_as_failed!
- Gitlab::Metrics.add_event(:stuck_export_jobs,
- failed_jobs_count: failed_jobs_count)
+ Gitlab::Metrics.add_event(:stuck_export_jobs, failed_jobs_count: failed_jobs_count)
end
private
diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb
index dccf88e1b1a..ec24ee15895 100644
--- a/app/workers/update_highest_role_worker.rb
+++ b/app/workers/update_highest_role_worker.rb
@@ -7,7 +7,7 @@ class UpdateHighestRoleWorker
sidekiq_options retry: 3
- feature_category :subscription_cost_management
+ feature_category :seat_cost_management
urgency :high
weight 2
diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb
index c3799480b12..d024109e754 100644
--- a/app/workers/users/deactivate_dormant_users_worker.rb
+++ b/app/workers/users/deactivate_dormant_users_worker.rb
@@ -8,7 +8,7 @@ module Users
include CronjobQueue
- feature_category :subscription_cost_management
+ feature_category :seat_cost_management
def perform
return if Gitlab.com?
diff --git a/app/workers/work_items/import_work_items_csv_worker.rb b/app/workers/work_items/import_work_items_csv_worker.rb
new file mode 100644
index 00000000000..be7294866df
--- /dev/null
+++ b/app/workers/work_items/import_work_items_csv_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class ImportWorkItemsCsvWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+
+ idempotent!
+ feature_category :team_planning
+
+ sidekiq_retries_exhausted do |job|
+ Upload.find(job['args'][2]).destroy
+ end
+
+ def perform(current_user_id, project_id, upload_id)
+ upload = Upload.find(upload_id)
+ user = User.find(current_user_id)
+ project = Project.find(project_id)
+
+ WorkItems::ImportCsvService.new(user, project, upload.retrieve_uploader).execute
+ upload.destroy!
+ rescue ActiveRecord::RecordNotFound
+ # Resources have been removed, job should not be retried
+ end
+ end
+end
diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb
index cb5bae7ca4e..58084405769 100644
--- a/app/workers/x509_issuer_crl_check_worker.rb
+++ b/app/workers/x509_issuer_crl_check_worker.rb
@@ -40,14 +40,16 @@ class X509IssuerCrlCheckWorker
certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord
certs.find_each do |cert|
- logger.info(message: "Certificate revoked",
- id: cert.id,
- email: cert.email,
- subject: cert.subject,
- serial_number: cert.serial_number,
- issuer: cert.x509_issuer.id,
- issuer_subject: cert.x509_issuer.subject,
- issuer_crl_url: cert.x509_issuer.crl_url)
+ logger.info(
+ message: "Certificate revoked",
+ id: cert.id,
+ email: cert.email,
+ subject: cert.subject,
+ serial_number: cert.serial_number,
+ issuer: cert.x509_issuer.id,
+ issuer_subject: cert.x509_issuer.subject,
+ issuer_crl_url: cert.x509_issuer.crl_url
+ )
end
certs.update_all(certificate_status: :revoked)
@@ -60,19 +62,23 @@ class X509IssuerCrlCheckWorker
if response&.code == 200
OpenSSL::X509::CRL.new(response.body)
else
- logger.warn(message: "Failed to download certificate revocation list",
- issuer: issuer.id,
- issuer_subject: issuer.subject,
- issuer_crl_url: issuer.crl_url)
+ logger.warn(
+ message: "Failed to download certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url
+ )
nil
end
rescue OpenSSL::X509::CRLError
- logger.warn(message: "Failed to parse certificate revocation list",
- issuer: issuer.id,
- issuer_subject: issuer.subject,
- issuer_crl_url: issuer.crl_url)
+ logger.warn(
+ message: "Failed to parse certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url
+ )
nil
end