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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
commit43a25d93ebdabea52f99b05e15b06250cd8f07d7 (patch)
treedceebdc68925362117480a5d672bcff122fb625b /app
parent20c84b99005abd1c82101dfeff264ac50d2df211 (diff)
Add latest changes from gitlab-org/gitlab@16-0-stable-eev16.0.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/learn_gitlab/section_code.svg4
-rw-r--r--app/assets/images/mr_favicons/favicon_status_merged.pngbin0 -> 326 bytes
-rw-r--r--app/assets/images/vulnerability/secureflag-logo.svg25
-rw-r--r--app/assets/javascripts/access_level/constants.js20
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue2
-rw-r--r--app/assets/javascripts/achievements/components/achievements_app.vue31
-rw-r--r--app/assets/javascripts/achievements/constants.js7
-rw-r--r--app/assets/javascripts/achievements/routes.js16
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue126
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/date_option.vue17
-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.js30
-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_report/components/abuse_report_app.vue35
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/history_items.vue51
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_header.vue46
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/reported_content.vue141
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_detail.vue27
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_details.vue115
-rw-r--r--app/assets/javascripts/admin/abuse_report/constants.js61
-rw-r--r--app/assets/javascripts/admin/abuse_report/index.js27
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue177
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue56
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue109
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/app.vue63
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js90
-rw-r--r--app/assets/javascripts/admin/abuse_reports/index.js31
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js9
-rw-r--r--app/assets/javascripts/admin/application_settings/network_outbound.js28
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/base.vue6
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue109
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue34
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue24
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/constants.js2
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/edit.js4
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/index.js11
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue2
-rw-r--r--app/assets/javascripts/admin/statistics_panel/store/actions.js2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue14
-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.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue77
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue4
-rw-r--r--app/assets/javascripts/airflow/dags/components/dags.vue111
-rw-r--r--app/assets/javascripts/alert.js (renamed from app/assets/javascripts/flash.js)2
-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/components/alerts_form.vue35
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js4
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js3
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js2
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/bundle.js1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue41
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue17
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue11
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue8
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/constants.js10
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/actions.js23
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/getters.js4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/mutations.js7
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/state.js6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/utils.js44
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_tile.vue4
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue5
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue20
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue31
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js39
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js34
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue2
-rw-r--r--app/assets/javascripts/api.js2
-rw-r--r--app/assets/javascripts/api/analytics_api.js58
-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_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql4
-rw-r--r--app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue2
-rw-r--r--app/assets/javascripts/authentication/mount_2fa.js26
-rw-r--r--app/assets/javascripts/authentication/password/components/password_input.vue97
-rw-r--r--app/assets/javascripts/authentication/password/constants.js4
-rw-r--r--app/assets/javascripts/authentication/password/index.js36
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue12
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue2
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js2
-rw-r--r--app/assets/javascripts/authentication/u2f/authenticate.js106
-rw-r--r--app/assets/javascripts/authentication/u2f/error.js26
-rw-r--r--app/assets/javascripts/authentication/u2f/index.js17
-rw-r--r--app/assets/javascripts/authentication/u2f/register.js102
-rw-r--r--app/assets/javascripts/authentication/u2f/util.js40
-rw-r--r--app/assets/javascripts/authentication/webauthn/authenticate.js3
-rw-r--r--app/assets/javascripts/authentication/webauthn/components/registration.vue226
-rw-r--r--app/assets/javascripts/authentication/webauthn/constants.js46
-rw-r--r--app/assets/javascripts/authentication/webauthn/error.js7
-rw-r--r--app/assets/javascripts/authentication/webauthn/index.js18
-rw-r--r--app/assets/javascripts/authentication/webauthn/register.js3
-rw-r--r--app/assets/javascripts/authentication/webauthn/registration.js22
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js8
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue4
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue13
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue22
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue33
-rw-r--r--app/assets/javascripts/batch_comments/index.js13
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js4
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js6
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js1
-rw-r--r--app/assets/javascripts/behaviors/components/json_table.vue1
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js2
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js4
-rw-r--r--app/assets/javascripts/behaviors/date_picker.js7
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js6
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_json_table.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js23
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_observability.js57
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js2
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js37
-rw-r--r--app/assets/javascripts/behaviors/shortcuts.js6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js95
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js123
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js8
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js20
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js22
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js58
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js16
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js6
-rw-r--r--app/assets/javascripts/blame/blame_redirect.js2
-rw-r--r--app/assets/javascripts/blame/streaming/index.js56
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_header.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue2
-rw-r--r--app/assets/javascripts/blob/csv/index.js1
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue5
-rw-r--r--app/assets/javascripts/blob/openapi/index.js13
-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/viewer/index.js2
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js7
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue116
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue101
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue124
-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_column.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue124
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue148
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue42
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue28
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue233
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue96
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue45
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue39
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue28
-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.js29
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js7
-rw-r--r--app/assets/javascripts/boards/issue_board_filters.js21
-rw-r--r--app/assets/javascripts/boards/stores/actions.js27
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js11
-rw-r--r--app/assets/javascripts/branches/components/delete_merged_branches.vue45
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js2
-rw-r--r--app/assets/javascripts/branches/init_new_branch_ref_selector.js1
-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)8
-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)56
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue78
-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)18
-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)186
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_checkbox.vue70
-rw-r--r--app/assets/javascripts/ci/artifacts/constants.js (renamed from app/assets/javascripts/artifacts/constants.js)43
-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.graphql7
-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)10
-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.vue14
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue116
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue45
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js21
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql4
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql10
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/settings.js49
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue26
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue109
-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.vue50
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue90
-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/accordion_items/services_item.vue90
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js113
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue168
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js53
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js18
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/event_hub.js5
-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.js6
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue26
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue32
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue93
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql2
-rw-r--r--app/assets/javascripts/ci/pipeline_new/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/format_refs.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue3
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue9
-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.vue12
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue14
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql6
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/constants.js2
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/ci/reports/constants.js14
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue84
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/index.js8
-rw-r--r--app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/admin_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue30
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/index.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue71
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/cli_command.vue42
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue135
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue50
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue80
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue41
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue256
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh12
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh11
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps113
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/utils.js81
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_create_form.vue120
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs.vue7
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue12
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue26
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue2
-rw-r--r--app/assets/javascripts/ci/runner/constants.js72
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql7
-rw-r--r--app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql9
-rw-r--r--app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql8
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue83
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/index.js32
-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_runner_show/group_runner_show_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue46
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js6
-rw-r--r--app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js2
-rw-r--r--app/assets/javascripts/ci/runner/project_new_runner/index.js32
-rw-r--r--app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue83
-rw-r--r--app/assets/javascripts/ci/runner/project_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/register/index.js39
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_modal.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/revoke_token_button.vue14
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue3
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql9
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js2
-rw-r--r--app/assets/javascripts/clusters/constants.js16
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue42
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue11
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js2
-rw-r--r--app/assets/javascripts/code_review/signals.js51
-rw-r--r--app/assets/javascripts/comment_templates/components/app.vue (renamed from app/assets/javascripts/saved_replies/components/app.vue)6
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue182
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue67
-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.vue68
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue67
-rw-r--r--app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql5
-rw-r--r--app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql10
-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)4
-rw-r--r--app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/comment_templates/routes.js (renamed from app/assets/javascripts/saved_replies/routes.js)7
-rw-r--r--app/assets/javascripts/commit/components/signature_badge.vue94
-rw-r--r--app/assets/javascripts/commit/components/x509_certificate_details.vue45
-rw-r--r--app/assets/javascripts/commit/constants.js104
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js11
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue1
-rw-r--r--app/assets/javascripts/commit_merge_requests.js2
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js12
-rw-r--r--app/assets/javascripts/commons/vue.js2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/constants.js3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue133
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue167
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue83
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue109
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue5
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue15
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue191
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue135
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue64
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue5
-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_more_dropdown.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/details.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue1
-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/components/wrappers/table_cell_base.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue4
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js11
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_item.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_list.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/drawio_diagram.js41
-rw-r--r--app/assets/javascripts/content_editor/extensions/external_keydown_handler.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure_caption.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_reference.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnotes_section.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js26
-rw-r--r--app/assets/javascripts/content_editor/extensions/list_item.js13
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js24
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js13
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js10
-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/selection.js26
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js52
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_list.js13
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js16
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js7
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js6
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js24
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js262
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js22
-rw-r--r--app/assets/javascripts/contextual_sidebar.js3
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue42
-rw-r--r--app/assets/javascripts/contributors/stores/actions.js2
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue16
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue2
-rw-r--r--app/assets/javascripts/deploy_keys/components/confirm_modal.vue4
-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/deploy_tokens/components/new_deploy_token.vue9
-rw-r--r--app/assets/javascripts/deploy_tokens/deploy_token_translations.js5
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js8
-rw-r--r--app/assets/javascripts/deprecated_notes.js74
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue139
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue91
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue112
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue25
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue10
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue3
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue3
-rw-r--r--app/assets/javascripts/design_management/constants.js5
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql8
-rw-r--r--app/assets/javascripts/design_management/index.js10
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js2
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue85
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue21
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js11
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js8
-rw-r--r--app/assets/javascripts/diff.js2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue189
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue34
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality_item.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue40
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue5
-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/file_row_stats.vue2
-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.vue113
-rw-r--r--app/assets/javascripts/diffs/constants.js12
-rw-r--r--app/assets/javascripts/diffs/i18n.js6
-rw-r--r--app/assets/javascripts/diffs/index.js18
-rw-r--r--app/assets/javascripts/diffs/store/actions.js164
-rw-r--r--app/assets/javascripts/diffs/store/getters.js8
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js15
-rw-r--r--app/assets/javascripts/diffs/store/utils.js43
-rw-r--r--app/assets/javascripts/diffs/utils/diff_file.js4
-rw-r--r--app/assets/javascripts/diffs/utils/merge_request.js23
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js5
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js19
-rw-r--r--app/assets/javascripts/docs/docs_bundle.js2
-rw-r--r--app/assets/javascripts/drawio/constants.js15
-rw-r--r--app/assets/javascripts/drawio/content_editor_facade.js80
-rw-r--r--app/assets/javascripts/drawio/drawio_editor.js277
-rw-r--r--app/assets/javascripts/drawio/markdown_field_editor_facade.js72
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue43
-rw-r--r--app/assets/javascripts/editor/constants.js26
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js45
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js29
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js46
-rw-r--r--app/assets/javascripts/editor/schema/ci.json81
-rw-r--r--app/assets/javascripts/editor/utils.js9
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js6
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue5
-rw-r--r--app/assets/javascripts/emoji/index.js4
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js6
-rw-r--r--app/assets/javascripts/entrypoints/tracker.js50
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue26
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue4
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/deploy_freeze_alert.vue79
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue2
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/empty_state.vue51
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue49
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue17
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue14
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue97
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue161
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_agent_info.vue99
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue117
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_pods.vue111
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_summary.vue180
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_tabs.vue166
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue52
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue7
-rw-r--r--app/assets/javascripts/environments/constants.js4
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue93
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js7
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue14
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue42
-rw-r--r--app/assets/javascripts/environments/graphql/client.js79
-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/mutations/stop_environment.mutation.graphql2
-rw-r--r--app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql12
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql27
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql50
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js137
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql104
-rw-r--r--app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js56
-rw-r--r--app/assets/javascripts/environments/helpers/k8s_integration_helper.js141
-rw-r--r--app/assets/javascripts/environments/index.js3
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js2
-rw-r--r--app/assets/javascripts/environments/mount_show.js18
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue90
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details_info.vue174
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue16
-rw-r--r--app/assets/javascripts/error_tracking/events_tracking.js60
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/details/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking/utils.js36
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js2
-rw-r--r--app/assets/javascripts/featurable/constants.js6
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue22
-rw-r--r--app/assets/javascripts/feature_flags/components/environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue25
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-rw-r--r--app/assets/javascripts/feature_flags/constants.js8
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/actions.js2
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js2
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js38
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js2
-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.js15
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js4
-rw-r--r--app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue4
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/google_cloud/aiml/panel.vue63
-rw-r--r--app/assets/javascripts/google_cloud/aiml/service_table.vue115
-rw-r--r--app/assets/javascripts/google_cloud/components/google_cloud_menu.vue21
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js11
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/grafana_integration/index.js3
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js4
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js3
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js65
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json28
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql8
-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/graphql_shared/utils.js52
-rw-r--r--app/assets/javascripts/group.js2
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue38
-rw-r--r--app/assets/javascripts/group_settings/constants.js11
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js5
-rw-r--r--app/assets/javascripts/groups/components/app.vue4
-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/group_name_and_path.vue2
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue6
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue16
-rw-r--r--app/assets/javascripts/groups/constants.js36
-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/access_dropdown.vue2
-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/groups/store/utils.js6
-rw-r--r--app/assets/javascripts/header.js8
-rw-r--r--app/assets/javascripts/header_search/components/app.vue109
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue14
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue6
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue6
-rw-r--r--app/assets/javascripts/header_search/constants.js50
-rw-r--r--app/assets/javascripts/header_search/init.js36
-rw-r--r--app/assets/javascripts/header_search/store/getters.js20
-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/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue6
-rw-r--r--app/assets/javascripts/ide/components/pipelines/empty_state.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue26
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue1
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue2
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js1
-rw-r--r--app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js14
-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.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js5
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/constants.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/state.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js2
-rw-r--r--app/assets/javascripts/import/constants.js28
-rw-r--r--app/assets/javascripts/import/details/api.js11
-rw-r--r--app/assets/javascripts/import/details/components/import_details_app.vue18
-rw-r--r--app/assets/javascripts/import/details/components/import_details_table.vue160
-rw-r--r--app/assets/javascripts/import/details/index.js23
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue6
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue84
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue25
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue26
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/services/status_poller.js2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue73
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue104
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue111
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue96
-rw-r--r--app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql18
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js11
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js14
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/state.js2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue8
-rw-r--r--app/assets/javascripts/incidents/constants.js10
-rw-r--r--app/assets/javascripts/incidents_settings/incidents_settings_service.js2
-rw-r--r--app/assets/javascripts/init_deprecated_notes.js4
-rw-r--r--app/assets/javascripts/init_diff_stats_dropdown.js7
-rw-r--r--app/assets/javascripts/integrations/constants.js6
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue73
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/google_play.vue75
-rw-r--r--app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue143
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue12
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue21
-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.vue107
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue80
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue42
-rw-r--r--app/assets/javascripts/invite_members/components/project_select.vue4
-rw-r--r--app/assets/javascripts/invite_members/constants.js28
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js10
-rw-r--r--app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js2
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue13
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue83
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue12
-rw-r--r--app/assets/javascripts/issuable/components/issue_assignees.vue1
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue119
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue20
-rw-r--r--app/assets/javascripts/issuable/constants.js10
-rw-r--r--app/assets/javascripts/issuable/index.js4
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_actions.js2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js35
-rw-r--r--app/assets/javascripts/issuable/issuable_label_selector.js13
-rw-r--r--app/assets/javascripts/issuable/mixins/related_issuable_mixin.js12
-rw-r--r--app/assets/javascripts/issuable/popover/components/mr_popover.vue24
-rw-r--r--app/assets/javascripts/issuable/popover/constants.js13
-rw-r--r--app/assets/javascripts/issuable/popover/index.js1
-rw-r--r--app/assets/javascripts/issues/constants.js35
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js129
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue57
-rw-r--r--app/assets/javascripts/issues/index.js19
-rw-r--r--app/assets/javascripts/issues/issue.js2
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue18
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue15
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue106
-rw-r--r--app/assets/javascripts/issues/list/constants.js34
-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/list/utils.js107
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js2
-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/related_merge_requests/components/related_merge_requests.vue33
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/actions.js2
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue70
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue196
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue78
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue16
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue32
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue197
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue24
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/router.js20
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue8
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js2
-rw-r--r--app/assets/javascripts/issues/show/components/locked_warning.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/new_header_actions_popover.vue82
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue42
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue28
-rw-r--r--app/assets/javascripts/issues/show/constants.js3
-rw-r--r--app/assets/javascripts/issues/show/index.js18
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue48
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue28
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue40
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue39
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue15
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue21
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue22
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue101
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/actions.js32
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/mutations.js9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/state.js3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js27
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql6
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/job_log_controllers.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue150
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue8
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue16
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue22
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue10
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue22
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue22
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue4
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js1
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/cache_config.js60
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue17
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue96
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue51
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/store/actions.js4
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js3
-rw-r--r--app/assets/javascripts/jobs/store/utils.js30
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue11
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js25
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js5
-rw-r--r--app/assets/javascripts/labels/index.js5
-rw-r--r--app/assets/javascripts/labels/label_manager.js4
-rw-r--r--app/assets/javascripts/labels/labels.js12
-rw-r--r--app/assets/javascripts/labels/labels_select.js2
-rw-r--r--app/assets/javascripts/labels/project_label_subscription.js5
-rw-r--r--app/assets/javascripts/layout_nav.js9
-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.js69
-rw-r--r--app/assets/javascripts/lib/mermaid.js11
-rw-r--r--app/assets/javascripts/lib/mousetrap.js59
-rw-r--r--app/assets/javascripts/lib/swagger.js9
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js38
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js28
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js2
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue7
-rw-r--r--app/assets/javascripts/lib/utils/constants.js5
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/datetime/constants.js7
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime/timeago_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js15
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js7
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js49
-rw-r--r--app/assets/javascripts/lib/utils/ref_validator.js145
-rw-r--r--app/assets/javascripts/lib/utils/resize_observer.js10
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js45
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js60
-rw-r--r--app/assets/javascripts/lib/utils/tappable_promise.js49
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js57
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js45
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js14
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/mark_raw.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/normalize_children.js11
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js78
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js115
-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/listbox/redirect_behavior.js4
-rw-r--r--app/assets/javascripts/locale/ensure_single_line.cjs2
-rw-r--r--app/assets/javascripts/locale/sprintf.js4
-rw-r--r--app/assets/javascripts/main.js14
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue16
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue23
-rw-r--r--app/assets/javascripts/members/constants.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue15
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js2
-rw-r--r--app/assets/javascripts/merge_request.js5
-rw-r--r--app/assets/javascripts/merge_request_tabs.js69
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_dropdown.vue8
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue30
-rw-r--r--app/assets/javascripts/merge_requests/queries/title.subscription.graphql8
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue8
-rw-r--r--app/assets/javascripts/milestones/components/promote_milestone_modal.vue6
-rw-r--r--app/assets/javascripts/milestones/index.js9
-rw-r--r--app/assets/javascripts/milestones/milestone.js2
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js2
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js2
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue104
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue115
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue200
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue35
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/constants.js19
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue41
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue123
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue26
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js21
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue254
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js24
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue61
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue6
-rw-r--r--app/assets/javascripts/monitoring/constants.js19
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js4
-rw-r--r--app/assets/javascripts/monitoring/utils.js9
-rw-r--r--app/assets/javascripts/mr_notes/index.js23
-rw-r--r--app/assets/javascripts/mr_notes/init.js4
-rw-r--r--app/assets/javascripts/mr_notes/init_mr_notes.js22
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js11
-rw-r--r--app/assets/javascripts/mr_notes/mount_app.js6
-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/namespaces/leave_by_url.js2
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue35
-rw-r--r--app/assets/javascripts/nav/components/top_nav_new_dropdown.vue17
-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.js108
-rw-r--r--app/assets/javascripts/notebook/cells/output/error.vue40
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue14
-rw-r--r--app/assets/javascripts/notebook/index.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue144
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue76
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue3
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue3
-rw-r--r--app/assets/javascripts/notes/components/mr_discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue101
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue16
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue46
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue121
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue43
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue16
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue16
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue29
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue14
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue6
-rw-r--r--app/assets/javascripts/notes/constants.js86
-rw-r--r--app/assets/javascripts/notes/i18n.js5
-rw-r--r--app/assets/javascripts/notes/index.js11
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js30
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js2
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js10
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js38
-rw-r--r--app/assets/javascripts/notes/stores/getters.js41
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js8
-rw-r--r--app/assets/javascripts/notes/utils.js8
-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/observability/components/observability_app.vue60
-rw-r--r--app/assets/javascripts/observability/components/skeleton/embed.vue15
-rw-r--r--app/assets/javascripts/observability/components/skeleton/index.vue21
-rw-r--r--app/assets/javascripts/observability/constants.js12
-rw-r--r--app/assets/javascripts/observability/index.js32
-rw-r--r--app/assets/javascripts/operation_settings/index.js2
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_modal.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue)42
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue18
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue93
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue8
-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/constants/list.js4
-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/graphql/queries/get_container_repository_tags.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue75
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue50
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue59
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue44
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js2
-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/infrastructure_registry/list/components/packages_list_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js4
-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/maven_installation.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue140
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue21
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue11
-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.js42
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql2
-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.graphql39
-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.vue121
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue48
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue2
-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.js11
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue2
-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/components/registry_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue2
-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/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue39
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/network/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.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)8
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/constants.js29
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue26
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue259
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue28
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue39
-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.graphql85
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql5
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql5
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/constants.js12
-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/admin/projects/index/components/delete_project_modal.vue2
-rw-r--r--app/assets/javascripts/pages/admin/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js2
-rw-r--r--app/assets/javascripts/pages/groups/achievements/index.js43
-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.vue30
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js14
-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/bulk_imports/history/components/bulk_imports_history_app.vue9
-rw-r--r--app/assets/javascripts/pages/import/github/details/index.js3
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js9
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_error_details.vue2
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/import/phabricator/new/index.js3
-rw-r--r--app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js7
-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/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/saved_replies/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/airflow/dags/index/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blame/streaming/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js88
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js2
-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/forks/new/components/fork_form.vue8
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue4
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue8
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js1
-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.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js6
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/page.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/candidates/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js32
-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.js147
-rw-r--r--app/assets/javascripts/pages/projects/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue9
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/wikis/diff/index.js4
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js9
-rw-r--r--app/assets/javascripts/pages/sessions/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue6
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js11
-rw-r--r--app/assets/javascripts/pages/time_tracking/timelogs/index.js3
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js19
-rw-r--r--app/assets/javascripts/pages/users/show/index.js19
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js12
-rw-r--r--app/assets/javascripts/performance_bar/components/add_request.vue11
-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_callout.js2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue13
-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/job_item.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js39
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue36
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/utils.js33
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue10
-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_stop_modal.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue82
-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.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue6
-rw-r--r--app/assets/javascripts/pipelines/constants.js7
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql6
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql24
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js13
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js15
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutations.js2
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue11
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue9
-rw-r--r--app/assets/javascripts/profile/components/activity_calendar.vue100
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue15
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue15
-rw-r--r--app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql21
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue33
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue36
-rw-r--r--app/assets/javascripts/profile/components/user_achievements.vue98
-rw-r--r--app/assets/javascripts/profile/constants.js7
-rw-r--r--app/assets/javascripts/profile/gl_crop.js17
-rw-r--r--app/assets/javascripts/profile/index.js38
-rw-r--r--app/assets/javascripts/profile/preferences/components/diffs_colors.vue10
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue6
-rw-r--r--app/assets/javascripts/profile/profile.js2
-rw-r--r--app/assets/javascripts/profile/utils.js13
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue33
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue24
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue32
-rw-r--r--app/assets/javascripts/projects/commit/init_revert_commit_modal.js1
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_details_button.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js3
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue6
-rw-r--r--app/assets/javascripts/projects/commits/index.js18
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue4
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue4
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue10
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js8
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue54
-rw-r--r--app/assets/javascripts/projects/new/index.js10
-rw-r--r--app/assets/javascripts/projects/project_find_file.js2
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js33
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js24
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue77
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue3
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js7
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue7
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue25
-rw-r--r--app/assets/javascripts/projects/settings/constants.js1
-rw-r--r--app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js31
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue13
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue19
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js12
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue64
-rw-r--r--app/assets/javascripts/projects/settings/utils.js21
-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.vue6
-rw-r--r--app/assets/javascripts/projects/star.js2
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue2
-rw-r--r--app/assets/javascripts/protected_branches/constants.js11
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js63
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js2
-rw-r--r--app/assets/javascripts/protected_tags/constants.js11
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js81
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js112
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js4
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue9
-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/add_issuable_form.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue119
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue14
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue2
-rw-r--r--app/assets/javascripts/related_issues/constants.js31
-rw-r--r--app/assets/javascripts/related_issues/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue20
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue18
-rw-r--r--app/assets/javascripts/releases/components/tag_create.vue91
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue283
-rw-r--r--app/assets/javascripts/releases/components/tag_search.vue121
-rw-r--r--app/assets/javascripts/releases/constants.js5
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/release_notification_service.js25
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js24
-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.js31
-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/releases/util.js2
-rw-r--r--app/assets/javascripts/repository/commits_service.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue18
-rw-r--r--app/assets/javascripts/repository/components/blob_controls.vue3
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue19
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue20
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue249
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue137
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue18
-rw-r--r--app/assets/javascripts/repository/components/new_directory_modal.vue22
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue15
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue22
-rw-r--r--app/assets/javascripts/repository/constants.js10
-rw-r--r--app/assets/javascripts/repository/event_hub.js3
-rw-r--r--app/assets/javascripts/repository/index.js33
-rw-r--r--app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql11
-rw-r--r--app/assets/javascripts/repository/queries/fork_details.query.graphql2
-rw-r--r--app/assets/javascripts/right_sidebar.js8
-rw-r--r--app/assets/javascripts/saved_replies/components/list.vue57
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue19
-rw-r--r--app/assets/javascripts/saved_replies/pages/index.vue15
-rw-r--r--app/assets/javascripts/search/index.js1
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue32
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue45
-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)88
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/tracking.js39
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue12
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue15
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue28
-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.js29
-rw-r--r--app/assets/javascripts/search/store/actions.js28
-rw-r--r--app/assets/javascripts/search/store/constants.js14
-rw-r--r--app/assets/javascripts/search/store/getters.js27
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/mutations.js2
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/search/store/utils.js36
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue79
-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.vue53
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js68
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue58
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade_banner.vue61
-rw-r--r--app/assets/javascripts/security_configuration/index.js6
-rw-r--r--app/assets/javascripts/security_configuration/utils.js8
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue174
-rw-r--r--app/assets/javascripts/self_monitor/index.js20
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js127
-rw-r--r--app/assets/javascripts/self_monitor/store/index.js21
-rw-r--r--app/assets/javascripts/self_monitor/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/self_monitor/store/mutations.js22
-rw-r--r--app/assets/javascripts/self_monitor/store/state.js15
-rw-r--r--app/assets/javascripts/sentry/index.js5
-rw-r--r--app/assets/javascripts/sentry/legacy_constants.js (renamed from app/assets/javascripts/sentry/constants.js)4
-rw-r--r--app/assets/javascripts/sentry/legacy_sentry_config.js2
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js4
-rw-r--r--app/assets/javascripts/service_ping_consent.js2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue31
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue34
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js14
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue29
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js8
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue29
-rw-r--r--app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue42
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issues_button.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue69
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue13
-rw-r--r--app/assets/javascripts/sidebar/constants.js56
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js66
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-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.js6
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue14
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue26
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue2
-rw-r--r--app/assets/javascripts/streaming/chunk_writer.js144
-rw-r--r--app/assets/javascripts/streaming/constants.js9
-rw-r--r--app/assets/javascripts/streaming/handle_streamed_anchor_link.js26
-rw-r--r--app/assets/javascripts/streaming/html_stream.js33
-rw-r--r--app/assets/javascripts/streaming/polyfills.js5
-rw-r--r--app/assets/javascripts/streaming/rate_limit_stream_requests.js87
-rw-r--r--app/assets/javascripts/streaming/render_balancer.js36
-rw-r--r--app/assets/javascripts/streaming/render_html_streams.js40
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue255
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue37
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue13
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue77
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue96
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue316
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue90
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue38
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue54
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js28
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/actions.js45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js222
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/index.js25
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/state.js19
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/utils.js81
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue81
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue165
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue58
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue122
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue23
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue159
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item_link.vue35
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue37
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue101
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue82
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue99
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue178
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue122
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_portal.vue30
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue164
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue80
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue185
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue340
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue90
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js54
-rw-r--r--app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql24
-rw-r--r--app/assets/javascripts/super_sidebar/mock_data.js59
-rw-r--r--app/assets/javascripts/super_sidebar/popper_max_size_modifier.js43
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js109
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js61
-rw-r--r--app/assets/javascripts/super_sidebar/user_counts_manager.js69
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js87
-rw-r--r--app/assets/javascripts/syntax_highlight.js7
-rw-r--r--app/assets/javascripts/tags/init_new_tag_ref_selector.js1
-rw-r--r--app/assets/javascripts/task_list.js2
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue29
-rw-r--r--app/assets/javascripts/terraform/components/init_command_modal.vue9
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue4
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue2
-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/toggles/index.js16
-rw-r--r--app/assets/javascripts/token_access/components/inbound_token_access.vue4
-rw-r--r--app/assets/javascripts/token_access/components/opt_in_jwt.vue125
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue53
-rw-r--r--app/assets/javascripts/token_access/components/token_access_app.vue13
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue7
-rw-r--r--app/assets/javascripts/token_access/constants.js14
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql4
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql4
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql8
-rw-r--r--app/assets/javascripts/token_access/index.js1
-rw-r--r--app/assets/javascripts/tracking/constants.js4
-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.js38
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql1
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue4
-rw-r--r--app/assets/javascripts/user_lists/store/edit/actions.js4
-rw-r--r--app/assets/javascripts/user_lists/store/new/actions.js4
-rw-r--r--app/assets/javascripts/users_select/index.js18
-rw-r--r--app/assets/javascripts/validators/length_validator.js (renamed from app/assets/javascripts/pages/sessions/new/length_validator.js)15
-rw-r--r--app/assets/javascripts/visibility_level/constants.js32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue141
-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.vue153
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue95
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql (renamed from app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql)10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue46
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue6
-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_author_time.vue2
-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/review_app_link.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue57
-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.vue21
-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.vue10
-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.vue218
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue89
-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/squash_before_merge.vue6
-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.vue9
-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/components/widget/widget_content_row.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js67
-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.vue92
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js21
-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/components/system_notes/system_note.vue6
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/router.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue46
-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/color_select_dropdown/color_select_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue8
-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/dismissible_feedback_alert.vue2
-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.vue8
-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/file_finder/index.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js2
-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/filtered_search_bar/tokens/branch_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue134
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/eventhub.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue511
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue176
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js116
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue4
-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/pagination/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue257
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/history_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue11
-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/source_viewer/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue127
-rw-r--r--app/assets/javascripts/vue_shared/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js19
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js16
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js77
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue36
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js17
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue5
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue4
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js4
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue2
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue100
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue31
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/webhooks/components/test_dropdown.vue45
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue1
-rw-r--r--app/assets/javascripts/work_items/components/notes/activity_filter.vue113
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue8
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue116
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue180
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue190
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue6
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue218
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue63
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue288
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue137
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_body.vue5
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue20
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue91
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue182
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_award_emoji.vue144
-rw-r--r--app/assets/javascripts/work_items/components/work_item_created_updated.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue45
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue603
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue100
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue29
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue242
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue233
-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.vue191
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue10
-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.vue107
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue32
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue258
-rw-r--r--app/assets/javascripts/work_items/components/work_item_todos.vue116
-rw-r--r--app/assets/javascripts/work_items/constants.js59
-rw-r--r--app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js120
-rw-r--r--app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql17
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql27
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql5
-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.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql40
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql25
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql24
-rw-r--r--app/assets/javascripts/work_items/index.js5
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue61
-rw-r--r--app/assets/javascripts/work_items/router/index.js2
-rw-r--r--app/assets/javascripts/work_items/utils.js56
-rw-r--r--app/assets/javascripts/zen_mode.js13
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss4
-rw-r--r--app/assets/stylesheets/components/content_editor.scss35
-rw-r--r--app/assets/stylesheets/components/detail_page.scss (renamed from app/assets/stylesheets/pages/detail_page.scss)13
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss73
-rw-r--r--app/assets/stylesheets/components/whats_new.scss14
-rw-r--r--app/assets/stylesheets/framework/animations.scss1
-rw-r--r--app/assets/stylesheets/framework/blocks.scss8
-rw-r--r--app/assets/stylesheets/framework/buttons.scss1
-rw-r--r--app/assets/stylesheets/framework/calendar.scss53
-rw-r--r--app/assets/stylesheets/framework/common.scss77
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss8
-rw-r--r--app/assets/stylesheets/framework/diffs.scss117
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss38
-rw-r--r--app/assets/stylesheets/framework/files.scss59
-rw-r--r--app/assets/stylesheets/framework/filters.scss24
-rw-r--r--app/assets/stylesheets/framework/flash.scss16
-rw-r--r--app/assets/stylesheets/framework/forms.scss27
-rw-r--r--app/assets/stylesheets/framework/header.scss80
-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.scss45
-rw-r--r--app/assets/stylesheets/framework/mixins.scss26
-rw-r--r--app/assets/stylesheets/framework/modal.scss2
-rw-r--r--app/assets/stylesheets/framework/page_title.scss4
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss97
-rw-r--r--app/assets/stylesheets/framework/sortable.scss9
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss24
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss310
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss55
-rw-r--r--app/assets/stylesheets/framework/tables.scss6
-rw-r--r--app/assets/stylesheets/framework/timeline.scss1
-rw-r--r--app/assets/stylesheets/framework/typography.scss3
-rw-r--r--app/assets/stylesheets/framework/variables.scss48
-rw-r--r--app/assets/stylesheets/framework/wells.scss4
-rw-r--r--app/assets/stylesheets/highlight/diff_custom_colors_addition.scss2
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/admin/geo_sites.scss (renamed from app/assets/stylesheets/page_bundles/admin/geo_nodes.scss)12
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss28
-rw-r--r--app/assets/stylesheets/page_bundles/branches.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/incidents.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss34
-rw-r--r--app/assets/stylesheets/page_bundles/issuable_list.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/issues_list.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect_users.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss (renamed from app/assets/stylesheets/pages/login.scss)18
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss181
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss40
-rw-r--r--app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss (renamed from app/assets/stylesheets/pages/ml_experiment_tracking.scss)18
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss36
-rw-r--r--app/assets/stylesheets/page_bundles/profile.scss4
-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/settings.scss22
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/tree.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss86
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss19
-rw-r--r--app/assets/stylesheets/pages/colors.scss1
-rw-r--r--app/assets/stylesheets/pages/commits.scss31
-rw-r--r--app/assets/stylesheets/pages/issues.scss61
-rw-r--r--app/assets/stylesheets/pages/labels.scss68
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss22
-rw-r--r--app/assets/stylesheets/pages/note_form.scss19
-rw-r--r--app/assets/stylesheets/pages/notes.scss141
-rw-r--r--app/assets/stylesheets/pages/profile.scss7
-rw-r--r--app/assets/stylesheets/pages/projects.scss16
-rw-r--r--app/assets/stylesheets/pages/registry.scss2
-rw-r--r--app/assets/stylesheets/pages/settings.scss39
-rw-r--r--app/assets/stylesheets/performance_bar.scss3
-rw-r--r--app/assets/stylesheets/print.scss7
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss169
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss147
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss287
-rw-r--r--app/assets/stylesheets/test_environment.scss6
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss21
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss16
-rw-r--r--app/assets/stylesheets/utilities.scss189
-rw-r--r--app/channels/awareness_channel.rb85
-rw-r--r--app/components/diffs/base_component.rb2
-rw-r--r--app/components/diffs/overflow_warning_component.html.haml6
-rw-r--r--app/components/diffs/overflow_warning_component.rb4
-rw-r--r--app/components/layouts/horizontal_section_component.rb2
-rw-r--r--app/components/pajamas/component.rb2
-rw-r--r--app/controllers/abuse_reports_controller.rb2
-rw-r--r--app/controllers/admin/abuse_reports_controller.rb29
-rw-r--r--app/controllers/admin/application_settings_controller.rb104
-rw-r--r--app/controllers/admin/applications_controller.rb28
-rw-r--r--app/controllers/admin/background_migrations_controller.rb1
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb26
-rw-r--r--app/controllers/admin/ci/variables_controller.rb7
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/dev_ops_report_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb6
-rw-r--r--app/controllers/admin/hooks_controller.rb6
-rw-r--r--app/controllers/admin/identities_controller.rb2
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb2
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/instance_review_controller.rb2
-rw-r--r--app/controllers/admin/keys_controller.rb2
-rw-r--r--app/controllers/admin/plan_limits_controller.rb1
-rw-r--r--app/controllers/admin/projects_controller.rb32
-rw-r--r--app/controllers/admin/runner_projects_controller.rb8
-rw-r--r--app/controllers/admin/runners_controller.rb9
-rw-r--r--app/controllers/admin/sessions_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb6
-rw-r--r--app/controllers/admin/topics_controller.rb4
-rw-r--r--app/controllers/admin/usage_trends_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb31
-rw-r--r--app/controllers/application_controller.rb48
-rw-r--r--app/controllers/chaos_controller.rb5
-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/authenticates_with_two_factor.rb47
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb22
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb2
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb5
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb3
-rw-r--r--app/controllers/concerns/integrations/actions.rb3
-rw-r--r--app/controllers/concerns/integrations/params.rb7
-rw-r--r--app/controllers/concerns/invisible_captcha_on_signup.rb14
-rw-r--r--app/controllers/concerns/issuable_actions.rb27
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb4
-rw-r--r--app/controllers/concerns/kas_cookie.rb28
-rw-r--r--app/controllers/concerns/known_sign_in.rb9
-rw-r--r--app/controllers/concerns/membership_actions.rb6
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb3
-rw-r--r--app/controllers/concerns/notes_actions.rb3
-rw-r--r--app/controllers/concerns/observability/content_security_policy.rb12
-rw-r--r--app/controllers/concerns/product_analytics_tracking.rb77
-rw-r--r--app/controllers/concerns/redis_tracking.rb49
-rw-r--r--app/controllers/concerns/registrations_tracking.rb15
-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_notes.rb4
-rw-r--r--app/controllers/concerns/renders_projects_list.rb4
-rw-r--r--app/controllers/concerns/search_rate_limitable.rb18
-rw-r--r--app/controllers/concerns/send_file_upload.rb7
-rw-r--r--app/controllers/concerns/snippets_actions.rb4
-rw-r--r--app/controllers/concerns/sorting_preference.rb4
-rw-r--r--app/controllers/concerns/uploads_actions.rb3
-rw-r--r--app/controllers/concerns/verifies_with_email.rb2
-rw-r--r--app/controllers/concerns/vscode_cdn_csp.rb17
-rw-r--r--app/controllers/concerns/web_hooks/hook_actions.rb6
-rw-r--r--app/controllers/concerns/web_ide_csp.rb29
-rw-r--r--app/controllers/concerns/wiki_actions.rb33
-rw-r--r--app/controllers/confirmations_controller.rb12
-rw-r--r--app/controllers/dashboard/application_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb4
-rw-r--r--app/controllers/dashboard/todos_controller.rb4
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/explore/groups_controller.rb7
-rw-r--r--app/controllers/explore/projects_controller.rb4
-rw-r--r--app/controllers/google_api/authorizations_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb23
-rw-r--r--app/controllers/groups/achievements_controller.rb16
-rw-r--r--app/controllers/groups/children_controller.rb31
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb2
-rw-r--r--app/controllers/groups/group_links_controller.rb2
-rw-r--r--app/controllers/groups/group_members_controller.rb9
-rw-r--r--app/controllers/groups/milestones_controller.rb28
-rw-r--r--app/controllers/groups/observability_controller.rb2
-rw-r--r--app/controllers/groups/runners_controller.rb26
-rw-r--r--app/controllers/groups/settings/access_tokens_controller.rb2
-rw-r--r--app/controllers/groups/settings/applications_controller.rb29
-rw-r--r--app/controllers/groups/settings/ci_cd_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.rb18
-rw-r--r--app/controllers/ide_controller.rb18
-rw-r--r--app/controllers/import/base_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_controller.rb4
-rw-r--r--app/controllers/import/bulk_imports_controller.rb3
-rw-r--r--app/controllers/import/fogbugz_controller.rb2
-rw-r--r--app/controllers/import/gitea_controller.rb7
-rw-r--r--app/controllers/import/github_controller.rb47
-rw-r--r--app/controllers/import/gitlab_controller.rb92
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb2
-rw-r--r--app/controllers/import/manifest_controller.rb4
-rw-r--r--app/controllers/import/phabricator_controller.rb35
-rw-r--r--app/controllers/invites_controller.rb8
-rw-r--r--app/controllers/jira_connect/branches_controller.rb3
-rw-r--r--app/controllers/jira_connect/public_keys_controller.rb2
-rw-r--r--app/controllers/jira_connect/subscriptions_controller.rb6
-rw-r--r--app/controllers/jira_connect/users_controller.rb21
-rw-r--r--app/controllers/jwt_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb7
-rw-r--r--app/controllers/oauth/applications_controller.rb26
-rw-r--r--app/controllers/oauth/authorizations_controller.rb8
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb4
-rw-r--r--app/controllers/oauth/jira_dvcs/authorizations_controller.rb22
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb28
-rw-r--r--app/controllers/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/accounts_controller.rb2
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb2
-rw-r--r--app/controllers/profiles/chat_names_controller.rb12
-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/emails_controller.rb4
-rw-r--r--app/controllers/profiles/notifications_controller.rb5
-rw-r--r--app/controllers/profiles/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb7
-rw-r--r--app/controllers/profiles/preferences_controller.rb6
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb62
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb11
-rw-r--r--app/controllers/profiles/webauthn_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb6
-rw-r--r--app/controllers/projects/airflow/dags_controller.rb38
-rw-r--r--app/controllers/projects/analytics/cycle_analytics/stages_controller.rb2
-rw-r--r--app/controllers/projects/analytics/cycle_analytics/summary_controller.rb7
-rw-r--r--app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb2
-rw-r--r--app/controllers/projects/artifacts_controller.rb12
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/aws/base_controller.rb39
-rw-r--r--app/controllers/projects/aws/configuration_controller.rb13
-rw-r--r--app/controllers/projects/badges_controller.rb1
-rw-r--r--app/controllers/projects/blame_controller.rb42
-rw-r--r--app/controllers/projects/blob_controller.rb46
-rw-r--r--app/controllers/projects/branches_controller.rb2
-rw-r--r--app/controllers/projects/ci/lints_controller.rb2
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb2
-rw-r--r--app/controllers/projects/cluster_agents_controller.rb15
-rw-r--r--app/controllers/projects/commit_controller.rb25
-rw-r--r--app/controllers/projects/commits_controller.rb14
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb2
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb25
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/design_management/designs/raw_images_controller.rb2
-rw-r--r--app/controllers/projects/design_management/designs/resized_image_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb41
-rw-r--r--app/controllers/projects/error_tracking_controller.rb3
-rw-r--r--app/controllers/projects/feature_flags_controller.rb53
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb4
-rw-r--r--app/controllers/projects/google_cloud/configuration_controller.rb3
-rw-r--r--app/controllers/projects/google_cloud/databases_controller.rb3
-rw-r--r--app/controllers/projects/grafana_api_controller.rb2
-rw-r--r--app/controllers/projects/graphs_controller.rb2
-rw-r--r--app/controllers/projects/hooks_controller.rb12
-rw-r--r--app/controllers/projects/imports_controller.rb2
-rw-r--r--app/controllers/projects/incidents_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb29
-rw-r--r--app/controllers/projects/jobs_controller.rb21
-rw-r--r--app/controllers/projects/labels_controller.rb24
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests_controller.rb49
-rw-r--r--app/controllers/projects/metrics/dashboards/builder_controller.rb2
-rw-r--r--app/controllers/projects/metrics_dashboard_controller.rb3
-rw-r--r--app/controllers/projects/milestones_controller.rb28
-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.rb14
-rw-r--r--app/controllers/projects/pages_domains_controller.rb4
-rw-r--r--app/controllers/projects/performance_monitoring/dashboards_controller.rb4
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb11
-rw-r--r--app/controllers/projects/pipelines_controller.rb103
-rw-r--r--app/controllers/projects/project_members_controller.rb10
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb4
-rw-r--r--app/controllers/projects/raw_controller.rb2
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/repositories_controller.rb16
-rw-r--r--app/controllers/projects/runner_projects_controller.rb6
-rw-r--r--app/controllers/projects/runners_controller.rb15
-rw-r--r--app/controllers/projects/security/configuration_controller.rb4
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb8
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb1
-rw-r--r--app/controllers/projects/settings/operations_controller.rb6
-rw-r--r--app/controllers/projects/settings/repository_controller.rb17
-rw-r--r--app/controllers/projects/tree_controller.rb12
-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/web_ide_terminals_controller.rb5
-rw-r--r--app/controllers/projects/work_items_controller.rb58
-rw-r--r--app/controllers/projects_controller.rb11
-rw-r--r--app/controllers/registrations/welcome_controller.rb19
-rw-r--r--app/controllers/registrations_controller.rb48
-rw-r--r--app/controllers/repositories/git_http_controller.rb6
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb16
-rw-r--r--app/controllers/repositories/lfs_locks_api_controller.rb4
-rw-r--r--app/controllers/search_controller.rb19
-rw-r--r--app/controllers/sessions_controller.rb12
-rw-r--r--app/controllers/snippets_controller.rb10
-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/controllers/users_controller.rb44
-rw-r--r--app/controllers/web_ide/remote_ide_controller.rb2
-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.rb87
-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.rb6
-rw-r--r--app/finders/ci/pipelines_finder.rb9
-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.rb22
-rw-r--r--app/finders/clusters/agents/authorizations/ci_access/finder.rb75
-rw-r--r--app/finders/clusters/agents/authorizations/user_access/finder.rb69
-rw-r--r--app/finders/clusters/agents_finder.rb2
-rw-r--r--app/finders/concerns/finder_with_group_hierarchy.rb12
-rw-r--r--app/finders/concerns/updated_at_filter.rb14
-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.rb27
-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.rb61
-rw-r--r--app/finders/groups/accepting_project_creations_finder.rb101
-rw-r--r--app/finders/groups/accepting_project_imports_finder.rb31
-rw-r--r--app/finders/groups/accepting_project_shares_finder.rb24
-rw-r--r--app/finders/groups/user_groups_finder.rb8
-rw-r--r--app/finders/issuable_finder.rb8
-rw-r--r--app/finders/labels_finder.rb9
-rw-r--r--app/finders/members_finder.rb21
-rw-r--r--app/finders/merge_requests_finder.rb15
-rw-r--r--app/finders/merge_requests_finder/params.rb2
-rw-r--r--app/finders/milestones_finder.rb16
-rw-r--r--app/finders/notes_finder.rb8
-rw-r--r--app/finders/packages/conan/package_finder.rb28
-rw-r--r--app/finders/packages/npm/package_finder.rb6
-rw-r--r--app/finders/pending_todos_finder.rb28
-rw-r--r--app/finders/projects_finder.rb2
-rw-r--r--app/finders/security/security_jobs_finder.rb2
-rw-r--r--app/finders/serverless_domain_finder.rb35
-rw-r--r--app/finders/snippets_finder.rb2
-rw-r--r--app/finders/template_finder.rb15
-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/award.rb38
-rw-r--r--app/graphql/mutations/achievements/delete.rb33
-rw-r--r--app/graphql/mutations/achievements/revoke.rb33
-rw-r--r--app/graphql/mutations/achievements/update.rb46
-rw-r--r--app/graphql/mutations/alert_management/base.rb2
-rw-r--r--app/graphql/mutations/award_emojis/base.rb2
-rw-r--r--app/graphql/mutations/base_mutation.rb6
-rw-r--r--app/graphql/mutations/boards/update.rb6
-rw-r--r--app/graphql/mutations/ci/ci_cd_settings_update.rb9
-rw-r--r--app/graphql/mutations/ci/job_artifact/bulk_destroy.rb69
-rw-r--r--app/graphql/mutations/ci/job_token_scope/add_project.rb16
-rw-r--r--app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb2
-rw-r--r--app/graphql/mutations/ci/project_ci_cd_settings_update.rb12
-rw-r--r--app/graphql/mutations/ci/runner/common_mutation_arguments.rb45
-rw-r--r--app/graphql/mutations/ci/runner/create.rb107
-rw-r--r--app/graphql/mutations/ci/runner/delete.rb6
-rw-r--r--app/graphql/mutations/ci/runner/update.rb42
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/create.rb10
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/revoke.rb9
-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/spam_protection.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb12
-rw-r--r--app/graphql/mutations/container_repositories/destroy_base.rb6
-rw-r--r--app/graphql/mutations/design_management/update.rb33
-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/environments/stop.rb39
-rw-r--r--app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb2
-rw-r--r--app/graphql/mutations/issues/bulk_update.rb23
-rw-r--r--app/graphql/mutations/members/bulk_update_base.rb88
-rw-r--r--app/graphql/mutations/members/groups/bulk_update.rb77
-rw-r--r--app/graphql/mutations/members/projects/bulk_update.rb29
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb8
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/delete.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.rb70
-rw-r--r--app/graphql/mutations/release_asset_links/create.rb14
-rw-r--r--app/graphql/mutations/release_asset_links/delete.rb18
-rw-r--r--app/graphql/mutations/release_asset_links/update.rb18
-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/user_preferences/update.rb3
-rw-r--r--app/graphql/mutations/work_items/convert.rb70
-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/export.rb54
-rw-r--r--app/graphql/mutations/work_items/update.rb25
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql25
-rw-r--r--app/graphql/resolvers/achievements/achievements_resolver.rb35
-rw-r--r--app/graphql/resolvers/achievements/user_achievements_resolver.rb33
-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.rb50
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb54
-rw-r--r--app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb37
-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.rb27
-rw-r--r--app/graphql/resolvers/ci/inherited_variables_resolver.rb13
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb7
-rw-r--r--app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_jobs_resolver.rb6
-rw-r--r--app/graphql/resolvers/ci/runner_projects_resolver.rb27
-rw-r--r--app/graphql/resolvers/ci/runner_resolver.rb8
-rw-r--r--app/graphql/resolvers/ci/runner_status_resolver.rb11
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb29
-rw-r--r--app/graphql/resolvers/clusters/agent_tokens_resolver.rb8
-rw-r--r--app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb19
-rw-r--r--app/graphql/resolvers/clusters/agents/authorizations/user_access_resolver.rb19
-rw-r--r--app/graphql/resolvers/clusters/agents_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb1
-rw-r--r--app/graphql/resolvers/concerns/time_frame_arguments.rb42
-rw-r--r--app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb28
-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.rb4
-rw-r--r--app/graphql/resolvers/issues_resolver.rb18
-rw-r--r--app/graphql/resolvers/kas/agent_configurations_resolver.rb2
-rw-r--r--app/graphql/resolvers/labels_resolver.rb9
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb7
-rw-r--r--app/graphql/resolvers/metrics/dashboard_resolver.rb1
-rw-r--r--app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb1
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb2
-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/project_merge_requests_resolver.rb8
-rw-r--r--app/graphql/resolvers/projects/commit_references_resolver.rb29
-rw-r--r--app/graphql/resolvers/projects/fork_details_resolver.rb9
-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/user_resolver.rb2
-rw-r--r--app/graphql/resolvers/work_item_resolver.rb6
-rw-r--r--app/graphql/resolvers/work_items_resolver.rb39
-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/achievement_type.rb9
-rw-r--r--app/graphql/types/achievements/user_achievement_type.rb51
-rw-r--r--app/graphql/types/alert_management/alert_type.rb10
-rw-r--r--app/graphql/types/analytics/cycle_analytics/flow_metrics.rb30
-rw-r--r--app/graphql/types/analytics/cycle_analytics/link_type.rb33
-rw-r--r--app/graphql/types/analytics/cycle_analytics/metric_type.rb39
-rw-r--r--app/graphql/types/board_list_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/ci_cd_setting_type.rb6
-rw-r--r--app/graphql/types/ci/config/include_type_enum.rb1
-rw-r--r--app/graphql/types/ci/inherited_ci_variable_type.rb48
-rw-r--r--app/graphql/types/ci/job_trace_type.rb19
-rw-r--r--app/graphql/types/ci/job_type.rb60
-rw-r--r--app/graphql/types/ci/runner_manager_type.rb49
-rw-r--r--app/graphql/types/ci/runner_type.rb44
-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/clusters/agents/authorizations/ci_access_type.rb21
-rw-r--r--app/graphql/types/clusters/agents/authorizations/user_access_type.rb21
-rw-r--r--app/graphql/types/commit_references_type.rb67
-rw-r--r--app/graphql/types/commit_signatures/ssh_signature_type.rb17
-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/design_management/design_type.rb7
-rw-r--r--app/graphql/types/environment_type.rb3
-rw-r--r--app/graphql/types/group_type.rb13
-rw-r--r--app/graphql/types/issuable_subscription_event_enum.rb11
-rw-r--r--app/graphql/types/issue_type.rb9
-rw-r--r--app/graphql/types/merge_request_type.rb14
-rw-r--r--app/graphql/types/merge_requests/detailed_merge_status_enum.rb3
-rw-r--r--app/graphql/types/mutation_type.rb24
-rw-r--r--app/graphql/types/namespace_type.rb8
-rw-r--r--app/graphql/types/packages/package_base_type.rb2
-rw-r--r--app/graphql/types/packages/package_details_type.rb4
-rw-r--r--app/graphql/types/packages/package_file_type.rb2
-rw-r--r--app/graphql/types/permission_types/ci/pipeline_schedules.rb9
-rw-r--r--app/graphql/types/permission_types/group_enum.rb3
-rw-r--r--app/graphql/types/permission_types/issue.rb2
-rw-r--r--app/graphql/types/permission_types/work_item.rb3
-rw-r--r--app/graphql/types/project_statistics_redirect_type.rb27
-rw-r--r--app/graphql/types/project_type.rb81
-rw-r--r--app/graphql/types/projects/commit_parent_names_type.rb13
-rw-r--r--app/graphql/types/projects/fork_details_type.rb26
-rw-r--r--app/graphql/types/projects/namespace_project_sort_enum.rb5
-rw-r--r--app/graphql/types/query_type.rb16
-rw-r--r--app/graphql/types/relative_position_type_enum.rb11
-rw-r--r--app/graphql/types/release_asset_link_type.rb6
-rw-r--r--app/graphql/types/repository_type.rb2
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb1
-rw-r--r--app/graphql/types/time_tracking/timelog_connection_type.rb2
-rw-r--r--app/graphql/types/timelog_type.rb4
-rw-r--r--app/graphql/types/user_interface.rb13
-rw-r--r--app/graphql/types/user_preferences_type.rb4
-rw-r--r--app/graphql/types/visibility_pipeline_id_type_enum.rb12
-rw-r--r--app/graphql/types/work_item_type.rb21
-rw-r--r--app/graphql/types/work_items/available_export_fields_enum.rb18
-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.rb11
-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/graphql/types/work_items/widgets/notifications_type.rb26
-rw-r--r--app/graphql/types/work_items/widgets/notifications_update_input_type.rb16
-rw-r--r--app/helpers/abuse_reports_helper.rb9
-rw-r--r--app/helpers/accounts_helper.rb2
-rw-r--r--app/helpers/admin/abuse_reports_helper.rb25
-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/analytics/cycle_analytics_helper.rb29
-rw-r--r--app/helpers/application_helper.rb33
-rw-r--r--app/helpers/application_settings_helper.rb51
-rw-r--r--app/helpers/artifacts_helper.rb1
-rw-r--r--app/helpers/auth_helper.rb8
-rw-r--r--app/helpers/avatars_helper.rb2
-rw-r--r--app/helpers/blame_helper.rb8
-rw-r--r--app/helpers/blob_helper.rb43
-rw-r--r--app/helpers/breadcrumbs_helper.rb2
-rw-r--r--app/helpers/broadcast_messages_helper.rb23
-rw-r--r--app/helpers/ci/builds_helper.rb22
-rw-r--r--app/helpers/ci/catalog/resources_helper.rb17
-rw-r--r--app/helpers/ci/jobs_helper.rb18
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb2
-rw-r--r--app/helpers/ci/pipelines_helper.rb8
-rw-r--r--app/helpers/ci/runners_helper.rb14
-rw-r--r--app/helpers/ci/status_helper.rb4
-rw-r--r--app/helpers/clusters_helper.rb13
-rw-r--r--app/helpers/commits_helper.rb28
-rw-r--r--app/helpers/dashboard_helper.rb13
-rw-r--r--app/helpers/device_registration_helper.rb11
-rw-r--r--app/helpers/diff_helper.rb6
-rw-r--r--app/helpers/dropdowns_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb11
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/events_helper.rb46
-rw-r--r--app/helpers/explore_helper.rb2
-rw-r--r--app/helpers/feature_flags_helper.rb6
-rw-r--r--app/helpers/form_helper.rb3
-rw-r--r--app/helpers/groups/observability_helper.rb19
-rw-r--r--app/helpers/groups_helper.rb32
-rw-r--r--app/helpers/ide_helper.rb28
-rw-r--r--app/helpers/issuables_helper.rb66
-rw-r--r--app/helpers/issues_helper.rb16
-rw-r--r--app/helpers/jira_connect_helper.rb6
-rw-r--r--app/helpers/labels_helper.rb21
-rw-r--r--app/helpers/markup_helper.rb23
-rw-r--r--app/helpers/merge_requests_helper.rb47
-rw-r--r--app/helpers/mirror_helper.rb2
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb38
-rw-r--r--app/helpers/nav/top_nav_helper.rb76
-rw-r--r--app/helpers/nav_helper.rb42
-rw-r--r--app/helpers/notes_helper.rb7
-rw-r--r--app/helpers/operations_helper.rb2
-rw-r--r--app/helpers/packages_helper.rb16
-rw-r--r--app/helpers/page_layout_helper.rb2
-rw-r--r--app/helpers/plan_limits_helper.rb26
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/product_analytics_helper.rb11
-rw-r--r--app/helpers/projects/error_tracking_helper.rb3
-rw-r--r--app/helpers/projects/ml/experiments_helper.rb28
-rw-r--r--app/helpers/projects/pipeline_helper.rb3
-rw-r--r--app/helpers/projects/settings/branch_rules_helper.rb23
-rw-r--r--app/helpers/projects_helper.rb74
-rw-r--r--app/helpers/protected_branches_helper.rb2
-rw-r--r--app/helpers/protected_refs_helper.rb22
-rw-r--r--app/helpers/registrations_helper.rb4
-rw-r--r--app/helpers/routing/projects_helper.rb12
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb5
-rw-r--r--app/helpers/safe_format_helper.rb23
-rw-r--r--app/helpers/search_helper.rb8
-rw-r--r--app/helpers/sessions_helper.rb4
-rw-r--r--app/helpers/sidebars_helper.rb269
-rw-r--r--app/helpers/snippets_helper.rb37
-rw-r--r--app/helpers/sorting_helper.rb6
-rw-r--r--app/helpers/submodule_helper.rb2
-rw-r--r--app/helpers/system_note_helper.rb16
-rw-r--r--app/helpers/todos_helper.rb14
-rw-r--r--app/helpers/users/callouts_helper.rb35
-rw-r--r--app/helpers/users/group_callouts_helper.rb8
-rw-r--r--app/helpers/users_helper.rb53
-rw-r--r--app/helpers/version_check_helper.rb21
-rw-r--r--app/helpers/visibility_level_helper.rb38
-rw-r--r--app/helpers/web_hooks/web_hooks_helper.rb22
-rw-r--r--app/helpers/work_items_helper.rb4
-rw-r--r--app/mailers/emails/issues.rb113
-rw-r--r--app/mailers/emails/profile.rb15
-rw-r--r--app/mailers/emails/projects.rb32
-rw-r--r--app/mailers/emails/service_desk.rb133
-rw-r--r--app/mailers/emails/shared.rb20
-rw-r--r--app/mailers/emails/work_items.rb19
-rw-r--r--app/mailers/notify.rb21
-rw-r--r--app/mailers/previews/notify_preview.rb105
-rw-r--r--app/models/ability.rb7
-rw-r--r--app/models/abuse/trust_score.rb37
-rw-r--r--app/models/abuse_report.rb135
-rw-r--r--app/models/achievements/achievement.rb9
-rw-r--r--app/models/achievements/user_achievement.rb19
-rw-r--r--app/models/active_session.rb24
-rw-r--r--app/models/airflow/dags.rb14
-rw-r--r--app/models/alert_management/alert.rb7
-rw-r--r--app/models/alert_management/alert_assignee.rb2
-rw-r--r--app/models/alert_management/alert_user_mention.rb5
-rw-r--r--app/models/analytics/cycle_analytics/project_level.rb8
-rw-r--r--app/models/analytics/cycle_analytics/stage.rb2
-rw-r--r--app/models/analytics/cycle_analytics/value_stream.rb7
-rw-r--r--app/models/appearance.rb29
-rw-r--r--app/models/application_setting.rb586
-rw-r--r--app/models/application_setting_implementation.rb8
-rw-r--r--app/models/atlassian/identity.rb20
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/authentication_event.rb4
-rw-r--r--app/models/award_emoji.rb3
-rw-r--r--app/models/awareness_session.rb245
-rw-r--r--app/models/badge.rb2
-rw-r--r--app/models/badges/project_badge.rb2
-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/metrics_dashboard_yml.rb4
-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/board.rb6
-rw-r--r--app/models/broadcast_message.rb16
-rw-r--r--app/models/bulk_import.rb13
-rw-r--r--app/models/bulk_imports/batch_tracker.rb46
-rw-r--r--app/models/bulk_imports/configuration.rb2
-rw-r--r--app/models/bulk_imports/entity.rb57
-rw-r--r--app/models/bulk_imports/export.rb2
-rw-r--r--app/models/bulk_imports/export_batch.rb33
-rw-r--r--app/models/bulk_imports/export_upload.rb1
-rw-r--r--app/models/bulk_imports/file_transfer.rb4
-rw-r--r--app/models/bulk_imports/file_transfer/base_config.rb25
-rw-r--r--app/models/bulk_imports/tracker.rb3
-rw-r--r--app/models/chat_name.rb4
-rw-r--r--app/models/ci/bridge.rb6
-rw-r--r--app/models/ci/build.rb81
-rw-r--r--app/models/ci/build_metadata.rb3
-rw-r--r--app/models/ci/build_need.rb2
-rw-r--r--app/models/ci/build_pending_state.rb2
-rw-r--r--app/models/ci/build_trace_chunk.rb2
-rw-r--r--app/models/ci/build_trace_metadata.rb4
-rw-r--r--app/models/ci/catalog/listing.rb34
-rw-r--r--app/models/ci/catalog/resource.rb28
-rw-r--r--app/models/ci/commit_with_pipeline.rb2
-rw-r--r--app/models/ci/daily_build_group_report_result.rb5
-rw-r--r--app/models/ci/group_variable.rb8
-rw-r--r--app/models/ci/job_artifact.rb6
-rw-r--r--app/models/ci/job_token/scope.rb3
-rw-r--r--app/models/ci/job_variable.rb2
-rw-r--r--app/models/ci/namespace_mirror.rb3
-rw-r--r--app/models/ci/pending_build.rb1
-rw-r--r--app/models/ci/pipeline.rb119
-rw-r--r--app/models/ci/pipeline_schedule.rb13
-rw-r--r--app/models/ci/pipeline_variable.rb3
-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.rb23
-rw-r--r--app/models/ci/runner.rb86
-rw-r--r--app/models/ci/runner_manager.rb (renamed from app/models/ci/runner_machine.rb)60
-rw-r--r--app/models/ci/runner_manager_build.rb29
-rw-r--r--app/models/ci/runner_version.rb5
-rw-r--r--app/models/ci/running_build.rb10
-rw-r--r--app/models/ci/sources/pipeline.rb1
-rw-r--r--app/models/ci/stage.rb6
-rw-r--r--app/models/ci/trigger.rb5
-rw-r--r--app/models/clusters/agent.rb91
-rw-r--r--app/models/clusters/agent_token.rb1
-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.rb71
-rw-r--r--app/models/clusters/agents/authorizations/user_access/project_authorization.rb43
-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/crossplane.rb58
-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.rb156
-rw-r--r--app/models/clusters/applications/prometheus.rb126
-rw-r--r--app/models/clusters/applications/runner.rb69
-rw-r--r--app/models/clusters/cluster.rb58
-rw-r--r--app/models/clusters/kubernetes_namespace.rb6
-rw-r--r--app/models/clusters/platforms/kubernetes.rb3
-rw-r--r--app/models/commit.rb40
-rw-r--r--app/models/commit_collection.rb21
-rw-r--r--app/models/commit_range.rb2
-rw-r--r--app/models/commit_status.rb31
-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/analytics/cycle_analytics/stageable.rb9
-rw-r--r--app/models/concerns/atomic_internal_id.rb12
-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/cached_commit.rb5
-rw-r--r--app/models/concerns/cascading_namespace_setting_attribute.rb21
-rw-r--r--app/models/concerns/ci/has_status.rb3
-rw-r--r--app/models/concerns/ci/metadatable.rb9
-rw-r--r--app/models/concerns/ci/partitionable.rb23
-rw-r--r--app/models/concerns/ci/partitionable/partitioned_filter.rb41
-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/clusters/agents/authorizations/user_access/scopes.rb18
-rw-r--r--app/models/concerns/counter_attribute.rb52
-rw-r--r--app/models/concerns/database_event_tracking.rb20
-rw-r--r--app/models/concerns/discussion_on_diff.rb28
-rw-r--r--app/models/concerns/each_batch.rb76
-rw-r--r--app/models/concerns/enum_with_nil.rb26
-rw-r--r--app/models/concerns/enums/abuse/source.rb18
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb1
-rw-r--r--app/models/concerns/enums/internal_id.rb3
-rw-r--r--app/models/concerns/enums/package_metadata.rb37
-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_unique_internal_users.rb2
-rw-r--r--app/models/concerns/has_user_type.rb29
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb38
-rw-r--r--app/models/concerns/issuable.rb39
-rw-r--r--app/models/concerns/limitable.rb7
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb8
-rw-r--r--app/models/concerns/noteable.rb3
-rw-r--r--app/models/concerns/packages/debian/component_file.rb8
-rw-r--r--app/models/concerns/partitioned_table.rb3
-rw-r--r--app/models/concerns/protected_branch_access.rb6
-rw-r--r--app/models/concerns/protected_ref_access.rb63
-rw-r--r--app/models/concerns/redis_cacheable.rb8
-rw-r--r--app/models/concerns/referable.rb6
-rw-r--r--app/models/concerns/require_email_verification.rb1
-rw-r--r--app/models/concerns/resolvable_discussion.rb16
-rw-r--r--app/models/concerns/routable.rb57
-rw-r--r--app/models/concerns/subscribable.rb13
-rw-r--r--app/models/concerns/taskable.rb12
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb19
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb6
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encryption_helper.rb2
-rw-r--r--app/models/concerns/uniquify.rb40
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb5
-rw-r--r--app/models/concerns/web_hooks/auto_disabling.rb112
-rw-r--r--app/models/concerns/web_hooks/has_web_hooks.rb12
-rw-r--r--app/models/concerns/web_hooks/unstoppable.rb29
-rw-r--r--app/models/concerns/with_uploads.rb7
-rw-r--r--app/models/container_registry/data_repair_detail.rb16
-rw-r--r--app/models/container_registry/event.rb16
-rw-r--r--app/models/container_repository.rb39
-rw-r--r--app/models/cycle_analytics/project_level_stage_adapter.rb12
-rw-r--r--app/models/dependency_proxy/manifest.rb5
-rw-r--r--app/models/dependency_proxy/registry.rb2
-rw-r--r--app/models/deployment.rb8
-rw-r--r--app/models/design_management/design.rb16
-rw-r--r--app/models/design_management/git_repository.rb44
-rw-r--r--app/models/design_management/repository.rb63
-rw-r--r--app/models/design_management/version.rb8
-rw-r--r--app/models/diff_discussion.rb14
-rw-r--r--app/models/diff_viewer/base.rb5
-rw-r--r--app/models/draft_note.rb2
-rw-r--r--app/models/environment_status.rb6
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb2
-rw-r--r--app/models/event.rb5
-rw-r--r--app/models/external_pull_request.rb1
-rw-r--r--app/models/group.rb56
-rw-r--r--app/models/group_group_link.rb8
-rw-r--r--app/models/group_label.rb4
-rw-r--r--app/models/hooks/project_hook.rb1
-rw-r--r--app/models/hooks/service_hook.rb1
-rw-r--r--app/models/hooks/system_hook.rb1
-rw-r--r--app/models/hooks/web_hook.rb57
-rw-r--r--app/models/import_failure.rb7
-rw-r--r--app/models/instance_configuration.rb1
-rw-r--r--app/models/integration.rb5
-rw-r--r--app/models/integrations/apple_app_store.rb34
-rw-r--r--app/models/integrations/bamboo.rb3
-rw-r--r--app/models/integrations/base_issue_tracker.rb6
-rw-r--r--app/models/integrations/base_slack_notification.rb2
-rw-r--r--app/models/integrations/base_slash_commands.rb16
-rw-r--r--app/models/integrations/campfire.rb4
-rw-r--r--app/models/integrations/ewm.rb2
-rw-r--r--app/models/integrations/field.rb6
-rw-r--r--app/models/integrations/gitlab_slack_application.rb176
-rw-r--r--app/models/integrations/google_play.rb101
-rw-r--r--app/models/integrations/harbor.rb5
-rw-r--r--app/models/integrations/jira.rb98
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb14
-rw-r--r--app/models/integrations/prometheus.rb12
-rw-r--r--app/models/integrations/slack_slash_commands.rb10
-rw-r--r--app/models/integrations/slack_workspace/api_scope.rb22
-rw-r--r--app/models/integrations/slack_workspace/integration_api_scope.rb29
-rw-r--r--app/models/integrations/squash_tm.rb82
-rw-r--r--app/models/integrations/youtrack.rb11
-rw-r--r--app/models/issue.rb163
-rw-r--r--app/models/iteration.rb18
-rw-r--r--app/models/key.rb2
-rw-r--r--app/models/label.rb5
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb34
-rw-r--r--app/models/member.rb13
-rw-r--r--app/models/members/group_member.rb11
-rw-r--r--app/models/members/member_role.rb49
-rw-r--r--app/models/members/project_member.rb41
-rw-r--r--app/models/members_preloader.rb14
-rw-r--r--app/models/merge_request.rb98
-rw-r--r--app/models/merge_request/diff_llm_summary.rb13
-rw-r--r--app/models/merge_request/metrics.rb33
-rw-r--r--app/models/merge_request_diff.rb10
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/merge_requests_closing_issues.rb9
-rw-r--r--app/models/milestone.rb5
-rw-r--r--app/models/milestone_note.rb3
-rw-r--r--app/models/ml/candidate.rb52
-rw-r--r--app/models/ml/candidate_metadata.rb6
-rw-r--r--app/models/ml/experiment.rb20
-rw-r--r--app/models/ml/experiment_metadata.rb6
-rw-r--r--app/models/namespace.rb139
-rw-r--r--app/models/namespace/aggregation_schedule.rb12
-rw-r--r--app/models/namespace/root_storage_statistics.rb31
-rw-r--r--app/models/namespace_setting.rb3
-rw-r--r--app/models/namespaces/ldap_setting.rb11
-rw-r--r--app/models/namespaces/project_namespace.rb2
-rw-r--r--app/models/namespaces/traversal/linear.rb30
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb8
-rw-r--r--app/models/note.rb31
-rw-r--r--app/models/note_diff_file.rb10
-rw-r--r--app/models/notes/note_metadata.rb23
-rw-r--r--app/models/oauth_access_token.rb2
-rw-r--r--app/models/onboarding/completion.rb66
-rw-r--r--app/models/operations/feature_flag.rb2
-rw-r--r--app/models/organization.rb26
-rw-r--r--app/models/packages/debian.rb4
-rw-r--r--app/models/packages/debian/file_metadatum.rb94
-rw-r--r--app/models/packages/dependency.rb7
-rw-r--r--app/models/packages/event.rb113
-rw-r--r--app/models/packages/npm/metadata_cache.rb39
-rw-r--r--app/models/packages/npm/metadatum.rb8
-rw-r--r--app/models/packages/package.rb49
-rw-r--r--app/models/packages/package_file.rb7
-rw-r--r--app/models/packages/rpm/repository_file.rb2
-rw-r--r--app/models/pages/lookup_path.rb21
-rw-r--r--app/models/pages_deployment.rb15
-rw-r--r--app/models/pages_domain.rb23
-rw-r--r--app/models/personal_access_token.rb41
-rw-r--r--app/models/preloaders/commit_status_preloader.rb7
-rw-r--r--app/models/preloaders/labels_preloader.rb23
-rw-r--r--app/models/preloaders/project_policy_preloader.rb5
-rw-r--r--app/models/preloaders/project_root_ancestor_preloader.rb2
-rw-r--r--app/models/preloaders/runner_manager_policy_preloader.rb23
-rw-r--r--app/models/preloaders/user_max_access_level_in_groups_preloader.rb12
-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.rb294
-rw-r--r--app/models/project_ci_cd_setting.rb7
-rw-r--r--app/models/project_custom_attribute.rb2
-rw-r--r--app/models/project_feature.rb8
-rw-r--r--app/models/project_label.rb4
-rw-r--r--app/models/project_setting.rb34
-rw-r--r--app/models/project_team.rb2
-rw-r--r--app/models/project_wiki.rb17
-rw-r--r--app/models/projects/data_transfer.rb16
-rw-r--r--app/models/projects/forks/details.rb (renamed from app/models/projects/forks/divergence_counts.rb)50
-rw-r--r--app/models/projects/import_export/relation_export.rb14
-rw-r--r--app/models/protected_branch.rb44
-rw-r--r--app/models/protected_branch/push_access_level.rb6
-rw-r--r--app/models/protected_ref/access_level.rb7
-rw-r--r--app/models/protected_tag.rb1
-rw-r--r--app/models/protected_tag/create_access_level.rb32
-rw-r--r--app/models/releases/link.rb6
-rw-r--r--app/models/repository.rb52
-rw-r--r--app/models/resource_events/abuse_report_event.rb32
-rw-r--r--app/models/resource_events/issue_assignment_event.rb18
-rw-r--r--app/models/resource_events/merge_request_assignment_event.rb18
-rw-r--r--app/models/resource_label_event.rb5
-rw-r--r--app/models/resource_milestone_event.rb6
-rw-r--r--app/models/sent_notification.rb15
-rw-r--r--app/models/serverless/domain.rb44
-rw-r--r--app/models/serverless/domain_cluster.rb39
-rw-r--r--app/models/serverless/function.rb26
-rw-r--r--app/models/serverless/lookup_path.rb30
-rw-r--r--app/models/serverless/virtual_domain.rb22
-rw-r--r--app/models/service_desk.rb (renamed from app/models/airflow.rb)5
-rw-r--r--app/models/service_desk/custom_email_credential.rb66
-rw-r--r--app/models/service_desk/custom_email_verification.rb112
-rw-r--r--app/models/service_desk_setting.rb80
-rw-r--r--app/models/slack_integration.rb93
-rw-r--r--app/models/snippet.rb14
-rw-r--r--app/models/system_note_metadata.rb3
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/terraform/state_version.rb2
-rw-r--r--app/models/todo.rb4
-rw-r--r--app/models/u2f_registration.rb67
-rw-r--r--app/models/user.rb262
-rw-r--r--app/models/user_custom_attribute.rb2
-rw-r--r--app/models/user_detail.rb3
-rw-r--r--app/models/user_preference.rb31
-rw-r--r--app/models/user_status.rb2
-rw-r--r--app/models/user_synced_attributes_metadata.rb26
-rw-r--r--app/models/users/banned_user.rb4
-rw-r--r--app/models/users/callout.rb14
-rw-r--r--app/models/users/credit_card_validation.rb30
-rw-r--r--app/models/users/group_callout.rb17
-rw-r--r--app/models/users/phone_number_validation.rb33
-rw-r--r--app/models/users/project_callout.rb14
-rw-r--r--app/models/users/user_follow_user.rb10
-rw-r--r--app/models/vulnerability.rb8
-rw-r--r--app/models/web_ide_terminal.rb14
-rw-r--r--app/models/webauthn_registration.rb6
-rw-r--r--app/models/wiki.rb49
-rw-r--r--app/models/wiki_directory.rb60
-rw-r--r--app/models/wiki_page.rb10
-rw-r--r--app/models/work_item.rb119
-rw-r--r--app/models/work_items/parent_link.rb4
-rw-r--r--app/models/work_items/resource_link_event.rb16
-rw-r--r--app/models/work_items/widget_definition.rb5
-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/models/work_items/widgets/notifications.rb9
-rw-r--r--app/policies/abuse_report_policy.rb7
-rw-r--r--app/policies/achievements/user_achievement_policy.rb12
-rw-r--r--app/policies/base_policy.rb10
-rw-r--r--app/policies/ci/build_policy.rb6
-rw-r--r--app/policies/ci/pipeline_schedule_policy.rb4
-rw-r--r--app/policies/ci/runner_manager_policy.rb18
-rw-r--r--app/policies/clusters/agent_policy.rb14
-rw-r--r--app/policies/clusters/instance_policy.rb1
-rw-r--r--app/policies/concerns/archived_abilities.rb1
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/policies/global_policy.rb18
-rw-r--r--app/policies/group_label_policy.rb2
-rw-r--r--app/policies/group_policy.rb68
-rw-r--r--app/policies/identity_provider_policy.rb4
-rw-r--r--app/policies/issuable_policy.rb10
-rw-r--r--app/policies/issue_policy.rb6
-rw-r--r--app/policies/namespaces/group_project_namespace_shared_policy.rb11
-rw-r--r--app/policies/namespaces/user_namespace_policy.rb5
-rw-r--r--app/policies/project_hook_policy.rb3
-rw-r--r--app/policies/project_label_policy.rb2
-rw-r--r--app/policies/project_policy.rb83
-rw-r--r--app/policies/project_snippet_policy.rb10
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/presenters/README.md12
-rw-r--r--app/presenters/ci/build_presenter.rb4
-rw-r--r--app/presenters/ci/build_runner_presenter.rb6
-rw-r--r--app/presenters/ci/pipeline_presenter.rb1
-rw-r--r--app/presenters/commit_presenter.rb4
-rw-r--r--app/presenters/event_presenter.rb2
-rw-r--r--app/presenters/label_presenter.rb18
-rw-r--r--app/presenters/merge_request_presenter.rb20
-rw-r--r--app/presenters/ml/candidate_details_presenter.rb88
-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/pages_domain_presenter.rb7
-rw-r--r--app/presenters/project_presenter.rb262
-rw-r--r--app/presenters/search_service_presenter.rb3
-rw-r--r--app/presenters/snippet_blob_presenter.rb8
-rw-r--r--app/serializers/admin/abuse_report_details_entity.rb104
-rw-r--r--app/serializers/admin/abuse_report_details_serializer.rb7
-rw-r--r--app/serializers/admin/abuse_report_entity.rb23
-rw-r--r--app/serializers/admin/abuse_report_serializer.rb7
-rw-r--r--app/serializers/build_details_entity.rb3
-rw-r--r--app/serializers/cluster_application_entity.rb20
-rw-r--r--app/serializers/cluster_entity.rb1
-rw-r--r--app/serializers/cluster_serializer.rb2
-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_entity.rb6
-rw-r--r--app/serializers/environment_serializer.rb6
-rw-r--r--app/serializers/environment_status_entity.rb12
-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/import/github_failure_entity.rb178
-rw-r--r--app/serializers/import/github_failure_serializer.rb9
-rw-r--r--app/serializers/issue_board_entity.rb6
-rw-r--r--app/serializers/issue_entity.rb6
-rw-r--r--app/serializers/jira_connect/app_data_serializer.rb12
-rw-r--r--app/serializers/linked_issue_entity.rb8
-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/note_entity.rb14
-rw-r--r--app/serializers/pipeline_details_entity.rb10
-rw-r--r--app/serializers/profile/event_entity.rb125
-rw-r--r--app/serializers/profile/event_serializer.rb7
-rw-r--r--app/serializers/project_import_entity.rb7
-rw-r--r--app/serializers/rollout_status_entity.rb2
-rw-r--r--app/serializers/runner_entity.rb16
-rw-r--r--app/serializers/stage_entity.rb10
-rw-r--r--app/serializers/test_case_entity.rb2
-rw-r--r--app/services/achievements/award_service.rb49
-rw-r--r--app/services/achievements/destroy_service.rb33
-rw-r--r--app/services/achievements/revoke_service.rb47
-rw-r--r--app/services/achievements/update_service.rb41
-rw-r--r--app/services/admin/abuse_report_update_service.rb89
-rw-r--r--app/services/auth/container_registry_authentication_service.rb36
-rw-r--r--app/services/authorized_project_update/project_recalculate_service.rb2
-rw-r--r--app/services/base_container_service.rb22
-rw-r--r--app/services/branches/validate_new_service.rb2
-rw-r--r--app/services/bulk_imports/archive_extraction_service.rb11
-rw-r--r--app/services/bulk_imports/batched_relation_export_service.rb91
-rw-r--r--app/services/bulk_imports/create_service.rb89
-rw-r--r--app/services/bulk_imports/export_service.rb19
-rw-r--r--app/services/bulk_imports/file_export_service.rb49
-rw-r--r--app/services/bulk_imports/lfs_objects_export_service.rb14
-rw-r--r--app/services/bulk_imports/relation_batch_export_service.rb80
-rw-r--r--app/services/bulk_imports/relation_export_service.rb54
-rw-r--r--app/services/bulk_imports/repository_bundle_export_service.rb2
-rw-r--r--app/services/bulk_imports/tree_export_service.rb43
-rw-r--r--app/services/bulk_imports/uploads_export_service.rb14
-rw-r--r--app/services/ci/archive_trace_service.rb39
-rw-r--r--app/services/ci/catalog/validate_resource_service.rb46
-rw-r--r--app/services/ci/create_pipeline_service.rb4
-rw-r--r--app/services/ci/ensure_stage_service.rb10
-rw-r--r--app/services/ci/generate_kubeconfig_service.rb2
-rw-r--r--app/services/ci/job_artifacts/bulk_delete_by_project_service.rb73
-rw-r--r--app/services/ci/job_artifacts/create_service.rb107
-rw-r--r--app/services/ci/job_artifacts/destroy_all_expired_service.rb22
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb21
-rw-r--r--app/services/ci/job_token_scope/add_project_service.rb6
-rw-r--r--app/services/ci/job_token_scope/remove_project_service.rb2
-rw-r--r--app/services/ci/list_config_variables_service.rb9
-rw-r--r--app/services/ci/parse_dotenv_artifact_service.rb9
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb60
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb130
-rw-r--r--app/services/ci/pipeline_schedules/take_ownership_service.rb2
-rw-r--r--app/services/ci/pipelines/add_job_service.rb6
-rw-r--r--app/services/ci/process_build_service.rb34
-rw-r--r--app/services/ci/queue/build_queue_service.rb4
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb7
-rw-r--r--app/services/ci/register_job_service.rb13
-rw-r--r--app/services/ci/reset_skipped_jobs_service.rb32
-rw-r--r--app/services/ci/runners/create_runner_service.rb27
-rw-r--r--app/services/ci/runners/process_runner_version_update_service.rb5
-rw-r--r--app/services/ci/runners/register_runner_service.rb61
-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/runners/unregister_runner_manager_service.rb33
-rw-r--r--app/services/ci/runners/unregister_runner_service.rb3
-rw-r--r--app/services/ci/stuck_builds/drop_helpers.rb29
-rw-r--r--app/services/ci/track_failed_build_service.rb2
-rw-r--r--app/services/ci/update_build_queue_service.rb2
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb20
-rw-r--r--app/services/clusters/agent_tokens/revoke_service.rb46
-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.rb74
-rw-r--r--app/services/clusters/agents/create_activity_event_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/commits/change_service.rb20
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb22
-rw-r--r--app/services/concerns/incident_management/usage_data.rb4
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb6
-rw-r--r--app/services/concerns/update_repository_storage_methods.rb9
-rw-r--r--app/services/concerns/work_items/widgetable_service.rb30
-rw-r--r--app/services/container_expiration_policies/cleanup_service.rb1
-rw-r--r--app/services/dependency_proxy/head_manifest_service.rb2
-rw-r--r--app/services/discussions/update_diff_position_service.rb2
-rw-r--r--app/services/draft_notes/publish_service.rb3
-rw-r--r--app/services/environments/stop_service.rb16
-rw-r--r--app/services/error_tracking/list_projects_service.rb4
-rw-r--r--app/services/event_create_service.rb49
-rw-r--r--app/services/feature_flags/base_service.rb34
-rw-r--r--app/services/feature_flags/create_service.rb10
-rw-r--r--app/services/feature_flags/destroy_service.rb10
-rw-r--r--app/services/feature_flags/update_service.rb16
-rw-r--r--app/services/files/base_service.rb15
-rw-r--r--app/services/git/base_hooks_service.rb8
-rw-r--r--app/services/git/branch_hooks_service.rb2
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb2
-rw-r--r--app/services/groups/autocomplete_service.rb2
-rw-r--r--app/services/groups/group_links/create_service.rb2
-rw-r--r--app/services/groups/group_links/destroy_service.rb4
-rw-r--r--app/services/groups/group_links/update_service.rb4
-rw-r--r--app/services/groups/transfer_service.rb9
-rw-r--r--app/services/import/base_service.rb2
-rw-r--r--app/services/import/bitbucket_server_service.rb2
-rw-r--r--app/services/import/fogbugz_service.rb4
-rw-r--r--app/services/import/github/cancel_project_import_service.rb6
-rw-r--r--app/services/import/github_service.rb2
-rw-r--r--app/services/import/validate_remote_git_endpoint_service.rb4
-rw-r--r--app/services/import_csv/base_service.rb40
-rw-r--r--app/services/incident_management/timeline_events/base_service.rb2
-rw-r--r--app/services/integrations/slack_event_service.rb61
-rw-r--r--app/services/integrations/slack_events/app_home_opened_service.rb92
-rw-r--r--app/services/integrations/slack_events/url_verification_service.rb26
-rw-r--r--app/services/integrations/slack_interaction_service.rb36
-rw-r--r--app/services/integrations/slack_interactions/block_action_service.rb32
-rw-r--r--app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb58
-rw-r--r--app/services/integrations/slack_interactions/incident_management/incident_modal_opened_service.rb105
-rw-r--r--app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb162
-rw-r--r--app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb131
-rw-r--r--app/services/integrations/slack_option_service.rb42
-rw-r--r--app/services/integrations/slack_options/label_search_handler.rb61
-rw-r--r--app/services/integrations/slack_options/user_search_handler.rb55
-rw-r--r--app/services/issuable/callbacks/base.rb31
-rw-r--r--app/services/issuable/callbacks/milestone.rb81
-rw-r--r--app/services/issuable/clone/base_service.rb5
-rw-r--r--app/services/issuable/destroy_service.rb2
-rw-r--r--app/services/issuable/import_csv/base_service.rb2
-rw-r--r--app/services/issuable_base_service.rb79
-rw-r--r--app/services/issuable_links/create_service.rb17
-rw-r--r--app/services/issue_links/create_service.rb4
-rw-r--r--app/services/issues/after_create_service.rb6
-rw-r--r--app/services/issues/base_service.rb37
-rw-r--r--app/services/issues/build_service.rb63
-rw-r--r--app/services/issues/close_service.rb14
-rw-r--r--app/services/issues/create_service.rb46
-rw-r--r--app/services/issues/duplicate_service.rb5
-rw-r--r--app/services/issues/export_csv_service.rb2
-rw-r--r--app/services/issues/referenced_merge_requests_service.rb12
-rw-r--r--app/services/issues/related_branches_service.rb5
-rw-r--r--app/services/issues/reopen_service.rb17
-rw-r--r--app/services/issues/reorder_service.rb5
-rw-r--r--app/services/issues/update_service.rb55
-rw-r--r--app/services/issues/zoom_link_service.rb2
-rw-r--r--app/services/jira_connect/sync_service.rb4
-rw-r--r--app/services/jira_connect_installations/proxy_lifecycle_event_service.rb6
-rw-r--r--app/services/keys/last_used_service.rb24
-rw-r--r--app/services/keys/revoke_service.rb2
-rw-r--r--app/services/markup/rendering_service.rb2
-rw-r--r--app/services/mattermost/create_team_service.rb2
-rw-r--r--app/services/members/approve_access_request_service.rb2
-rw-r--r--app/services/members/base_service.rb4
-rw-r--r--app/services/members/creator_service.rb96
-rw-r--r--app/services/members/destroy_service.rb59
-rw-r--r--app/services/merge_requests/add_context_service.rb4
-rw-r--r--app/services/merge_requests/after_create_service.rb3
-rw-r--r--app/services/merge_requests/base_service.rb20
-rw-r--r--app/services/merge_requests/build_service.rb24
-rw-r--r--app/services/merge_requests/create_service.rb5
-rw-r--r--app/services/merge_requests/ff_merge_service.rb10
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb1
-rw-r--r--app/services/merge_requests/merge_service.rb7
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb4
-rw-r--r--app/services/merge_requests/mergeability/detailed_merge_status_service.rb7
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb9
-rw-r--r--app/services/merge_requests/rebase_service.rb2
-rw-r--r--app/services/merge_requests/refresh_service.rb15
-rw-r--r--app/services/merge_requests/reload_diffs_service.rb8
-rw-r--r--app/services/merge_requests/retarget_chain_service.rb15
-rw-r--r--app/services/merge_requests/update_service.rb22
-rw-r--r--app/services/metrics/dashboard/annotations/create_service.rb4
-rw-r--r--app/services/metrics/dashboard/annotations/delete_service.rb2
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb4
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb48
-rw-r--r--app/services/metrics/global_metrics_update_service.rb24
-rw-r--r--app/services/metrics_service.rb6
-rw-r--r--app/services/ml/experiment_tracking/candidate_repository.rb19
-rw-r--r--app/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service.rb30
-rw-r--r--app/services/notes/build_service.rb7
-rw-r--r--app/services/notes/create_service.rb54
-rw-r--r--app/services/notes/destroy_service.rb6
-rw-r--r--app/services/notes/quick_actions_service.rb58
-rw-r--r--app/services/notes/update_service.rb6
-rw-r--r--app/services/notification_service.rb40
-rw-r--r--app/services/packages/conan/search_service.rb37
-rw-r--r--app/services/packages/conan/single_package_search_service.rb50
-rw-r--r--app/services/packages/create_event_service.rb9
-rw-r--r--app/services/packages/debian/extract_metadata_service.rb34
-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.rb36
-rw-r--r--app/services/packages/debian/process_package_file_service.rb29
-rw-r--r--app/services/packages/generic/create_package_file_service.rb6
-rw-r--r--app/services/packages/mark_package_for_destruction_service.rb11
-rw-r--r--app/services/packages/mark_packages_for_destruction_service.rb11
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb63
-rw-r--r--app/services/packages/npm/create_metadata_cache_service.rb53
-rw-r--r--app/services/packages/npm/create_package_service.rb64
-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/personal_access_tokens/create_service.rb8
-rw-r--r--app/services/personal_access_tokens/rotate_service.rb49
-rw-r--r--app/services/preview_markdown_service.rb2
-rw-r--r--app/services/projects/android_target_platform_detector_service.rb35
-rw-r--r--app/services/projects/batch_open_merge_requests_count_service.rb18
-rw-r--r--app/services/projects/blame_service.rb69
-rw-r--r--app/services/projects/container_repository/gitlab/cleanup_tags_service.rb4
-rw-r--r--app/services/projects/create_service.rb39
-rw-r--r--app/services/projects/fork_service.rb6
-rw-r--r--app/services/projects/forks/sync_service.rb113
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb2
-rw-r--r--app/services/projects/import_export/relation_export_service.rb1
-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/lfs_pointers/lfs_link_service.rb12
-rw-r--r--app/services/projects/open_issues_count_service.rb2
-rw-r--r--app/services/projects/open_merge_requests_count_service.rb8
-rw-r--r--app/services/projects/operations/update_service.rb2
-rw-r--r--app/services/projects/overwrite_project_service.rb20
-rw-r--r--app/services/projects/protect_default_branch_service.rb6
-rw-r--r--app/services/projects/transfer_service.rb4
-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_repository_storage_service.rb13
-rw-r--r--app/services/projects/update_service.rb22
-rw-r--r--app/services/protected_branches/base_service.rb4
-rw-r--r--app/services/protected_branches/cache_service.rb9
-rw-r--r--app/services/quick_actions/interpret_service.rb10
-rw-r--r--app/services/releases/create_service.rb6
-rw-r--r--app/services/releases/links/base_service.rb35
-rw-r--r--app/services/releases/links/create_service.rb25
-rw-r--r--app/services/releases/links/destroy_service.rb24
-rw-r--r--app/services/releases/links/update_service.rb24
-rw-r--r--app/services/resource_access_tokens/create_service.rb44
-rw-r--r--app/services/resource_events/change_labels_service.rb2
-rw-r--r--app/services/security/ci_configuration/base_create_service.rb19
-rw-r--r--app/services/serverless/associate_domain_service.rb30
-rw-r--r--app/services/spam/spam_action_service.rb2
-rw-r--r--app/services/spam/spam_verdict_service.rb52
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/system_notes/commit_service.rb56
-rw-r--r--app/services/system_notes/issuables_service.rb6
-rw-r--r--app/services/system_notes/time_tracking_service.rb2
-rw-r--r--app/services/tasks_to_be_done/base_service.rb8
-rw-r--r--app/services/terraform/remote_state_handler.rb8
-rw-r--r--app/services/todo_service.rb34
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/approve_service.rb5
-rw-r--r--app/services/users/ban_service.rb2
-rw-r--r--app/services/users/build_service.rb8
-rw-r--r--app/services/users/deactivate_service.rb65
-rw-r--r--app/services/users/email_verification/base_service.rb7
-rw-r--r--app/services/users/email_verification/validate_token_service.rb3
-rw-r--r--app/services/users/unban_service.rb2
-rw-r--r--app/services/users/unblock_service.rb2
-rw-r--r--app/services/users/update_service.rb29
-rw-r--r--app/services/users/upsert_credit_card_validation_service.rb5
-rw-r--r--app/services/users/validate_manual_otp_service.rb3
-rw-r--r--app/services/work_items/create_service.rb10
-rw-r--r--app/services/work_items/export_csv_service.rb18
-rw-r--r--app/services/work_items/import_csv_service.rb116
-rw-r--r--app/services/work_items/parent_links/base_service.rb53
-rw-r--r--app/services/work_items/parent_links/create_service.rb53
-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.rb39
-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/notifications_service/update_service.rb26
-rw-r--r--app/services/work_items/widgets/start_and_due_date_service/update_service.rb2
-rw-r--r--app/uploaders/ci/pipeline_artifact_uploader.rb2
-rw-r--r--app/uploaders/ci/secure_file_uploader.rb2
-rw-r--r--app/uploaders/deleted_object_uploader.rb2
-rw-r--r--app/uploaders/dependency_proxy/file_uploader.rb2
-rw-r--r--app/uploaders/external_diff_uploader.rb2
-rw-r--r--app/uploaders/gitlab_uploader.rb17
-rw-r--r--app/uploaders/job_artifact_uploader.rb2
-rw-r--r--app/uploaders/lfs_object_uploader.rb2
-rw-r--r--app/uploaders/object_storage.rb127
-rw-r--r--app/uploaders/object_storage/cdn/google_cdn.rb2
-rw-r--r--app/uploaders/packages/composer/cache_uploader.rb2
-rw-r--r--app/uploaders/packages/debian/component_file_uploader.rb2
-rw-r--r--app/uploaders/packages/debian/distribution_release_file_uploader.rb2
-rw-r--r--app/uploaders/packages/npm/metadata_cache_uploader.rb31
-rw-r--r--app/uploaders/packages/package_file_uploader.rb2
-rw-r--r--app/uploaders/packages/rpm/repository_file_uploader.rb2
-rw-r--r--app/uploaders/pages/deployment_uploader.rb2
-rw-r--r--app/uploaders/records_uploads.rb10
-rw-r--r--app/uploaders/terraform/state_uploader.rb2
-rw-r--r--app/validators/addressable_url_validator.rb3
-rw-r--r--app/validators/json_schema_validator.rb1
-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/google_service_account_key.json48
-rw-r--r--app/validators/json_schemas/import_failure_external_identifiers.json18
-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/abuse_reports/_abuse_report.html.haml37
-rw-r--r--app/views/admin/abuse_reports/index.html.haml58
-rw-r--r--app/views/admin/abuse_reports/show.html.haml6
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml5
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml24
-rw-r--r--app/views/admin/application_settings/_localization.html.haml2
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml27
-rw-r--r--app/views/admin/application_settings/_projects_api_limits.html.haml21
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml4
-rw-r--r--app/views/admin/application_settings/_registry.html.haml8
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml4
-rw-r--r--app/views/admin/application_settings/_runner_registrars_form.html.haml11
-rw-r--r--app/views/admin/application_settings/_slack.html.haml33
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml6
-rw-r--r--app/views/admin/application_settings/appearances/_form.html.haml12
-rw-r--r--app/views/admin/application_settings/appearances/show.html.haml2
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml4
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/application_settings/integrations.html.haml2
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml4
-rw-r--r--app/views/admin/application_settings/network.html.haml6
-rw-r--r--app/views/admin/application_settings/preferences.html.haml2
-rw-r--r--app/views/admin/application_settings/reporting.html.haml2
-rw-r--r--app/views/admin/application_settings/repository.html.haml2
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml4
-rw-r--r--app/views/admin/applications/_form.html.haml25
-rw-r--r--app/views/admin/applications/index.html.haml10
-rw-r--r--app/views/admin/applications/show.html.haml1
-rw-r--r--app/views/admin/background_migrations/index.html.haml5
-rw-r--r--app/views/admin/broadcast_messages/_preview.html.haml3
-rw-r--r--app/views/admin/broadcast_messages/edit.html.haml13
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml2
-rw-r--r--app/views/admin/dashboard/_security_newsletter_callout.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml26
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml13
-rw-r--r--app/views/admin/groups/_form.html.haml18
-rw-r--r--app/views/admin/groups/_group.html.haml5
-rw-r--r--app/views/admin/groups/show.html.haml23
-rw-r--r--app/views/admin/health_check/show.html.haml9
-rw-r--r--app/views/admin/labels/_label.html.haml24
-rw-r--r--app/views/admin/labels/index.html.haml59
-rw-r--r--app/views/admin/labels/new.html.haml1
-rw-r--r--app/views/admin/projects/_form.html.haml38
-rw-r--r--app/views/admin/projects/_projects.html.haml7
-rw-r--r--app/views/admin/projects/edit.html.haml4
-rw-r--r--app/views/admin/projects/show.html.haml30
-rw-r--r--app/views/admin/runners/edit.html.haml4
-rw-r--r--app/views/admin/runners/new.html.haml2
-rw-r--r--app/views/admin/runners/register.html.haml7
-rw-r--r--app/views/admin/runners/show.html.haml6
-rw-r--r--app/views/admin/sessions/_new_base.html.haml4
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_otp.html.haml8
-rw-r--r--app/views/admin/sessions/new.html.haml9
-rw-r--r--app/views/admin/sessions/two_factor.html.haml10
-rw-r--r--app/views/admin/spam_logs/index.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml81
-rw-r--r--app/views/admin/topics/_topic.html.haml5
-rw-r--r--app/views/admin/users/_form.html.haml4
-rw-r--r--app/views/admin/users/_head.html.haml6
-rw-r--r--app/views/admin/users/_profile.html.haml59
-rw-r--r--app/views/admin/users/_projects.html.haml8
-rw-r--r--app/views/admin/users/_user_detail_note.html.haml4
-rw-r--r--app/views/admin/users/_users.html.haml2
-rw-r--r--app/views/admin/users/projects.html.haml8
-rw-r--r--app/views/admin/users/show.html.haml8
-rw-r--r--app/views/authentication/_authenticate.html.haml11
-rw-r--r--app/views/authentication/_register.html.haml89
-rw-r--r--app/views/ci/variables/_index.html.haml8
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml1
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml5
-rw-r--r--app/views/clusters/clusters/_integrations.html.haml4
-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.haml5
-rw-r--r--app/views/dashboard/_groups_head.html.haml11
-rw-r--r--app/views/dashboard/_no_filter_selected.html.haml (renamed from app/views/shared/dashboard/_no_filter_selected.html.haml)0
-rw-r--r--app/views/dashboard/_projects_head.html.haml5
-rw-r--r--app/views/dashboard/_projects_nav.html.haml7
-rw-r--r--app/views/dashboard/_snippets_head.html.haml15
-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.haml18
-rw-r--r--app/views/dashboard/merge_requests.html.haml3
-rw-r--r--app/views/dashboard/milestones/index.html.haml2
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml8
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml8
-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.haml6
-rw-r--r--app/views/dashboard/todos/index.html.haml17
-rw-r--r--app/views/devise/confirmations/almost_there.haml7
-rw-r--r--app/views/devise/confirmations/new.html.haml7
-rw-r--r--app/views/devise/mailer/user_admin_approval.text.erb4
-rw-r--r--app/views/devise/registrations/new.html.haml4
-rw-r--r--app/views/devise/sessions/_new_base.html.haml27
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml4
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml6
-rw-r--r--app/views/devise/sessions/new.html.haml3
-rw-r--r--app/views/devise/sessions/two_factor.html.haml15
-rw-r--r--app/views/devise/shared/_error_messages.html.haml9
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml34
-rw-r--r--app/views/devise/shared/_sign_in_link.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml20
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml6
-rw-r--r--app/views/doorkeeper/applications/edit.html.haml1
-rw-r--r--app/views/doorkeeper/applications/show.html.haml4
-rw-r--r--app/views/events/_events.html.haml2
-rw-r--r--app/views/explore/_head.html.haml6
-rw-r--r--app/views/explore/groups/_nav.html.haml4
-rw-r--r--app/views/explore/groups/index.html.haml17
-rw-r--r--app/views/explore/projects/_head.html.haml11
-rw-r--r--app/views/explore/projects/_nav.html.haml6
-rw-r--r--app/views/explore/projects/index.html.haml11
-rw-r--r--app/views/explore/projects/page_out_of_bounds.html.haml14
-rw-r--r--app/views/explore/projects/starred.html.haml12
-rw-r--r--app/views/explore/projects/topic.html.haml30
-rw-r--r--app/views/explore/projects/topics.html.haml9
-rw-r--r--app/views/explore/projects/trending.html.haml12
-rw-r--r--app/views/explore/snippets/index.html.haml14
-rw-r--r--app/views/explore/topics/_head.html.haml10
-rw-r--r--app/views/groups/_flash_messages.html.haml2
-rw-r--r--app/views/groups/_group_admin_settings.html.haml7
-rw-r--r--app/views/groups/_group_readme.html.haml3
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml8
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml8
-rw-r--r--app/views/groups/_invite_members_modal.html.haml2
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml8
-rw-r--r--app/views/groups/_invite_members_top_nav_link.html.haml5
-rw-r--r--app/views/groups/_new_group_fields.html.haml3
-rw-r--r--app/views/groups/achievements/index.html.haml14
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml2
-rw-r--r--app/views/groups/edit.html.haml7
-rw-r--r--app/views/groups/group_members/index.html.haml1
-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/labels/index.html.haml12
-rw-r--r--app/views/groups/milestones/_form.html.haml49
-rw-r--r--app/views/groups/milestones/new.html.haml5
-rw-r--r--app/views/groups/new.html.haml20
-rw-r--r--app/views/groups/packages/index.html.haml2
-rw-r--r--app/views/groups/projects.html.haml6
-rw-r--r--app/views/groups/registry/repositories/index.html.haml1
-rw-r--r--app/views/groups/runners/edit.html.haml2
-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/runners/show.html.haml6
-rw-r--r--app/views/groups/settings/_export.html.haml9
-rw-r--r--app/views/groups/settings/_general.html.haml8
-rw-r--r--app/views/groups/settings/_git_access_protocols.html.haml2
-rw-r--r--app/views/groups/settings/_permissions.html.haml5
-rw-r--r--app/views/groups/settings/_remove_button.html.haml2
-rw-r--r--app/views/groups/settings/_transfer.html.haml4
-rw-r--r--app/views/groups/settings/access_tokens/index.html.haml8
-rw-r--r--app/views/groups/settings/applications/edit.html.haml1
-rw-r--r--app/views/groups/settings/applications/index.html.haml1
-rw-r--r--app/views/groups/settings/applications/show.html.haml4
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml7
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml3
-rw-r--r--app/views/groups/settings/integrations/index.html.haml2
-rw-r--r--app/views/groups/settings/packages_and_registries/show.html.haml2
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/groups/show.html.haml8
-rw-r--r--app/views/help/instance_configuration/_ci_cd_limits.html.haml18
-rw-r--r--app/views/ide/_show.html.haml4
-rw-r--r--app/views/import/_githubish_status.html.haml4
-rw-r--r--app/views/import/github/details.html.haml4
-rw-r--r--app/views/import/github/status.html.haml2
-rw-r--r--app/views/import/phabricator/new.html.haml26
-rw-r--r--app/views/jira_connect/branches/new.html.haml1
-rw-r--r--app/views/jira_connect/users/show.html.haml21
-rw-r--r--app/views/layouts/_head.html.haml41
-rw-r--r--app/views/layouts/_loading_hints.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml19
-rw-r--r--app/views/layouts/_search.html.haml42
-rw-r--r--app/views/layouts/_snowplow.html.haml14
-rw-r--r--app/views/layouts/component_preview.html.haml4
-rw-r--r--app/views/layouts/dashboard.html.haml5
-rw-r--r--app/views/layouts/devise.html.haml40
-rw-r--r--app/views/layouts/devise_empty.html.haml1
-rw-r--r--app/views/layouts/explore.html.haml11
-rw-r--r--app/views/layouts/group.html.haml5
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml8
-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/header/_new_dropdown.html.haml15
-rw-r--r--app/views/layouts/help.html.haml2
-rw-r--r--app/views/layouts/minimal.html.haml7
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml27
-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/_explore.html.haml1
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml3
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml170
-rw-r--r--app/views/layouts/nav/sidebar/_search.html.haml1
-rw-r--r--app/views/layouts/nav/sidebar/_user_profile.html.haml1
-rw-r--r--app/views/layouts/nav/sidebar/_your_work.html.haml2
-rw-r--r--app/views/layouts/project.html.haml4
-rw-r--r--app/views/layouts/search.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.haml5
-rw-r--r--app/views/layouts/terms.html.haml2
-rw-r--r--app/views/notify/_issuable_csv_export.html.haml6
-rw-r--r--app/views/notify/_issuable_csv_export.text.erb7
-rw-r--r--app/views/notify/_note_email.text.erb6
-rw-r--r--app/views/notify/access_token_created_email.html.haml2
-rw-r--r--app/views/notify/export_work_items_csv_email.html.haml1
-rw-r--r--app/views/notify/export_work_items_csv_email.text.erb1
-rw-r--r--app/views/notify/import_work_items_csv_email.html.haml49
-rw-r--r--app/views/notify/import_work_items_csv_email.text.erb48
-rw-r--r--app/views/notify/issues_csv_email.text.erb6
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merge_requests_csv_email.text.erb6
-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/new_review_email.text.erb1
-rw-r--r--app/views/notify/pages_domain_auto_ssl_failed_email.html.haml2
-rw-r--r--app/views/notify/pages_domain_auto_ssl_failed_email.text.haml2
-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/notify/two_factor_otp_attempt_failed_email.html.haml4
-rw-r--r--app/views/notify/two_factor_otp_attempt_failed_email.text.haml4
-rw-r--r--app/views/notify/unknown_sign_in_email.html.haml5
-rw-r--r--app/views/notify/unknown_sign_in_email.text.haml2
-rw-r--r--app/views/peek/_bar.html.haml3
-rw-r--r--app/views/profiles/accounts/show.html.haml2
-rw-r--r--app/views/profiles/active_sessions/index.html.haml2
-rw-r--r--app/views/profiles/audit_log.html.haml2
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml16
-rw-r--r--app/views/profiles/chat_names/index.html.haml9
-rw-r--r--app/views/profiles/chat_names/new.html.haml40
-rw-r--r--app/views/profiles/comment_templates/index.html.haml10
-rw-r--r--app/views/profiles/emails/index.html.haml4
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml2
-rw-r--r--app/views/profiles/keys/_key.html.haml2
-rw-r--r--app/views/profiles/keys/index.html.haml2
-rw-r--r--app/views/profiles/keys/show.html.haml1
-rw-r--r--app/views/profiles/notifications/show.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml43
-rw-r--r--app/views/profiles/saved_replies/index.html.haml10
-rw-r--r--app/views/profiles/show.html.haml26
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml84
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_deletion_failed.html.haml2
-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/_import_project_pane.html.haml12
-rw-r--r--app/views/projects/_invite_members_empty_project.html.haml6
-rw-r--r--app/views/projects/_invite_members_modal.html.haml2
-rw-r--r--app/views/projects/_invite_members_side_nav_link.html.haml8
-rw-r--r--app/views/projects/_invite_members_top_nav_link.html.haml5
-rw-r--r--app/views/projects/_last_push.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml12
-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/airflow/dags/index.html.haml11
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/aws/configuration/index.html.haml7
-rw-r--r--app/views/projects/blame/show.html.haml34
-rw-r--r--app/views/projects/blob/_blob.html.haml12
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml2
-rw-r--r--app/views/projects/blob/_editor.html.haml27
-rw-r--r--app/views/projects/blob/edit.html.haml18
-rw-r--r--app/views/projects/blob/viewers/_csv.html.haml2
-rw-r--r--app/views/projects/branch_defaults/_branch_names_fields.html.haml3
-rw-r--r--app/views/projects/branch_rules/_show.html.haml8
-rw-r--r--app/views/projects/branches/_branch.html.haml91
-rw-r--r--app/views/projects/branches/_branch_rules_info.haml12
-rw-r--r--app/views/projects/branches/_commit.html.haml2
-rw-r--r--app/views/projects/branches/_panel.html.haml13
-rw-r--r--app/views/projects/branches/index.html.haml17
-rw-r--r--app/views/projects/branches/new.html.haml31
-rw-r--r--app/views/projects/buttons/_clone.html.haml8
-rw-r--r--app/views/projects/commit/_pipelines_list.haml3
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/diff_files.html.haml6
-rw-r--r--app/views/projects/commit/show.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml8
-rw-r--r--app/views/projects/commits/_commit_list.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml9
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/compare/index.html.haml4
-rw-r--r--app/views/projects/compare/show.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml1
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml12
-rw-r--r--app/views/projects/edit.html.haml16
-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/environments/new.html.haml5
-rw-r--r--app/views/projects/environments/show.html.haml4
-rw-r--r--app/views/projects/feature_flags/edit.html.haml4
-rw-r--r--app/views/projects/feature_flags/index.html.haml4
-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/find_file/show.html.haml4
-rw-r--r--app/views/projects/forks/error.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/graphs/show.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.haml2
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/imports/show.html.haml1
-rw-r--r--app/views/projects/issues/_design_management.html.haml5
-rw-r--r--app/views/projects/issues/_discussion.html.haml3
-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/_related_issues.html.haml3
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml4
-rw-r--r--app/views/projects/issues/new.html.haml2
-rw-r--r--app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml2
-rw-r--r--app/views/projects/issues/service_desk/_nav_btns.html.haml30
-rw-r--r--app/views/projects/issues/show.html.haml1
-rw-r--r--app/views/projects/labels/index.html.haml34
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml7
-rw-r--r--app/views/projects/mattermosts/new.html.haml3
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml3
-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.haml40
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml8
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml25
-rw-r--r--app/views/projects/merge_requests/_page.html.haml14
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml6
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml3
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml22
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml2
-rw-r--r--app/views/projects/milestones/_form.html.haml54
-rw-r--r--app/views/projects/milestones/show.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos_list.html.haml8
-rw-r--r--app/views/projects/ml/candidates/show.html.haml4
-rw-r--r--app/views/projects/ml/experiments/_experiment.html.haml3
-rw-r--r--app/views/projects/ml/experiments/_experiment_list.html.haml7
-rw-r--r--app/views/projects/ml/experiments/_incubation_banner.html.haml8
-rw-r--r--app/views/projects/ml/experiments/index.html.haml3
-rw-r--r--app/views/projects/ml/experiments/show.html.haml10
-rw-r--r--app/views/projects/new.html.haml18
-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/_access.html.haml14
-rw-r--r--app/views/projects/pages/_destroy.haml17
-rw-r--r--app/views/projects/pages/_header.html.haml7
-rw-r--r--app/views/projects/pages/_list.html.haml80
-rw-r--r--app/views/projects/pages/_no_domains.html.haml9
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml11
-rw-r--r--app/views/projects/pages/_use.html.haml4
-rw-r--r--app/views/projects/pages_domains/_certificate.html.haml6
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml3
-rw-r--r--app/views/projects/pages_domains/_form.html.haml2
-rw-r--r--app/views/projects/pages_domains/show.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml20
-rw-r--r--app/views/projects/pipelines/_pipeline_stats_text.html.haml1
-rw-r--r--app/views/projects/pipelines/new.html.haml6
-rw-r--r--app/views/projects/pipelines/show.html.haml16
-rw-r--r--app/views/projects/project_members/index.html.haml1
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/_protected_tag_create_access_levels.haml8
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml6
-rw-r--r--app/views/projects/readme_templates/default.md.tt4
-rw-r--r--app/views/projects/registry/repositories/index.html.haml2
-rw-r--r--app/views/projects/releases/new.html.haml4
-rw-r--r--app/views/projects/runners/_project_runners.html.haml30
-rw-r--r--app/views/projects/runners/edit.html.haml7
-rw-r--r--app/views/projects/runners/new.html.haml5
-rw-r--r--app/views/projects/runners/register.html.haml6
-rw-r--r--app/views/projects/runners/show.html.haml3
-rw-r--r--app/views/projects/security/configuration/show.html.haml5
-rw-r--r--app/views/projects/settings/_general.html.haml4
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml2
-rw-r--r--app/views/projects/settings/branch_rules/index.html.haml7
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/_badge.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/settings/integrations/_form.html.haml5
-rw-r--r--app/views/projects/settings/integrations/edit.html.haml1
-rw-r--r--app/views/projects/settings/integrations/index.html.haml4
-rw-r--r--app/views/projects/settings/members/show.html.haml2
-rw-r--r--app/views/projects/settings/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml11
-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.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml3
-rw-r--r--app/views/projects/snippets/edit.html.haml1
-rw-r--r--app/views/projects/snippets/index.html.haml2
-rw-r--r--app/views/projects/snippets/new.html.haml2
-rw-r--r--app/views/projects/starrers/_starrer.html.haml4
-rw-r--r--app/views/projects/tags/_edit_release_button.html.haml4
-rw-r--r--app/views/projects/tags/_release_link.html.haml6
-rw-r--r--app/views/projects/tags/_tag.html.haml18
-rw-r--r--app/views/projects/tags/new.html.haml6
-rw-r--r--app/views/projects/tags/show.html.haml8
-rw-r--r--app/views/projects/terraform/index.html.haml4
-rw-r--r--app/views/projects/tree/_tree_header.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml1
-rw-r--r--app/views/projects/triggers/_index.html.haml6
-rw-r--r--app/views/projects/usage_quotas/index.html.haml8
-rw-r--r--app/views/projects/work_items/index.html.haml4
-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/protected_branches/shared/_update_protected_branch.html.haml24
-rw-r--r--app/views/registrations/welcome/show.html.haml8
-rw-r--r--app/views/search/_results.html.haml10
-rw-r--r--app/views/search/_results_list.html.haml29
-rw-r--r--app/views/search/_results_status.html.haml49
-rw-r--r--app/views/search/results/_blob.html.haml8
-rw-r--r--app/views/search/show.html.haml13
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml2
-rw-r--r--app/views/shared/_broadcast_message.html.haml4
-rw-r--r--app/views/shared/_captcha_check.html.haml4
-rw-r--r--app/views/shared/_commit_message_container.html.haml25
-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/_issues.html.haml8
-rw-r--r--app/views/shared/_label.html.haml101
-rw-r--r--app/views/shared/_label_full_path.html.haml6
-rw-r--r--app/views/shared/_label_row.html.haml35
-rw-r--r--app/views/shared/_md_preview.html.haml21
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml4
-rw-r--r--app/views/shared/_model_version_conflict.html.haml6
-rw-r--r--app/views/shared/_new_commit_form.html.haml11
-rw-r--r--app/views/shared/_ref_switcher.html.haml22
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_zen.html.haml1
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml70
-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/_form.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml15
-rw-r--r--app/views/shared/empty_states/_issues.html.haml3
-rw-r--r--app/views/shared/empty_states/_labels.html.haml4
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml4
-rw-r--r--app/views/shared/empty_states/_priority_labels.html.haml10
-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/empty_states/_wikis.html.haml3
-rw-r--r--app/views/shared/form_elements/_description.html.haml24
-rw-r--r--app/views/shared/groups/_list.html.haml2
-rw-r--r--app/views/shared/hook_logs/_index.html.haml2
-rw-r--r--app/views/shared/icons/_mr_widget_empty_state.svg1
-rw-r--r--app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml20
-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/integrations/prometheus/_custom_metrics.html.haml10
-rw-r--r--app/views/shared/integrations/prometheus/_metrics.html.haml18
-rw-r--r--app/views/shared/issuable/_feed_buttons.html.haml12
-rw-r--r--app/views/shared/issuable/_form.html.haml14
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml6
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml11
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml35
-rw-r--r--app/views/shared/issuable/_sidebar_user_dropdown.html.haml4
-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/_metadata.html.haml15
-rw-r--r--app/views/shared/issuable/form/_title.html.haml4
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml26
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml8
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml4
-rw-r--r--app/views/shared/labels/_form.html.haml10
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml8
-rw-r--r--app/views/shared/milestones/_description.html.haml4
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml21
-rw-r--r--app/views/shared/milestones/_header.html.haml73
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--app/views/shared/nav/_admin_scope_header.html.haml6
-rw-r--r--app/views/shared/nav/_explore_scope_header.html.haml6
-rw-r--r--app/views/shared/nav/_user_settings_scope_header.html.haml4
-rw-r--r--app/views/shared/notes/_edit_form.html.haml3
-rw-r--r--app/views/shared/notes/_hints.html.haml6
-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.haml23
-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/runners/_runner_details.html.haml5
-rw-r--r--app/views/shared/topics/_topic.html.haml5
-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.haml10
-rw-r--r--app/views/shared/wikis/diff.html.haml2
-rw-r--r--app/views/shared/wikis/empty.html.haml10
-rw-r--r--app/views/snippets/_snippets.html.haml2
-rw-r--r--app/views/snippets/show.html.haml5
-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/_profile_basic_info.html.haml8
-rw-r--r--app/views/users/show.html.haml253
-rw-r--r--app/workers/all_queues.yml311
-rw-r--r--app/workers/authorized_project_update/project_recalculate_per_user_worker.rb2
-rw-r--r--app/workers/authorized_project_update/project_recalculate_worker.rb4
-rw-r--r--app/workers/authorized_project_update/user_refresh_from_replica_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb2
-rw-r--r--app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb2
-rw-r--r--app/workers/authorized_projects_worker.rb3
-rw-r--r--app/workers/bulk_imports/finish_batched_relation_export_worker.rb62
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb1
-rw-r--r--app/workers/bulk_imports/relation_batch_export_worker.rb16
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb12
-rw-r--r--app/workers/ci/archive_traces_cron_worker.rb10
-rw-r--r--app/workers/ci/create_cross_project_pipeline_worker.rb19
-rw-r--r--app/workers/ci/runners/stale_machines_cleanup_cron_worker.rb2
-rw-r--r--app/workers/clusters/agents/notify_git_push_worker.rb23
-rw-r--r--app/workers/concerns/application_worker.rb3
-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.rb41
-rw-r--r--app/workers/concerns/gitlab/github_import/rescheduling_methods.rb23
-rw-r--r--app/workers/concerns/self_monitoring_project_worker.rb37
-rw-r--r--app/workers/concerns/waitable_worker.rb13
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb9
-rw-r--r--app/workers/container_registry/cleanup_worker.rb24
-rw-r--r--app/workers/container_registry/record_data_repair_detail_worker.rb87
-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/delete_user_worker.rb11
-rw-r--r--app/workers/deployments/drop_older_deployments_worker.rb18
-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/advance_stage_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/import_collaborator_worker.rb21
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_review_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/import_release_attachments_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/pull_requests/import_merged_by_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/pull_requests/import_review_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/stage/import_attachments_worker.rb6
-rw-r--r--app/workers/gitlab/github_import/stage/import_collaborators_worker.rb68
-rw-r--r--app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb6
-rw-r--r--app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb8
-rw-r--r--app/workers/gitlab/phabricator_import/base_worker.rb81
-rw-r--r--app/workers/gitlab/phabricator_import/import_tasks_worker.rb17
-rw-r--r--app/workers/gitlab_service_ping_worker.rb2
-rw-r--r--app/workers/group_destroy_worker.rb5
-rw-r--r--app/workers/groups/update_two_factor_requirement_for_members_worker.rb2
-rw-r--r--app/workers/integrations/slack_event_worker.rb54
-rw-r--r--app/workers/issuable_export_csv_worker.rb20
-rw-r--r--app/workers/issues/placement_worker.rb2
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb7
-rw-r--r--app/workers/jira_connect/sync_project_worker.rb19
-rw-r--r--app/workers/loose_foreign_keys/cleanup_worker.rb2
-rw-r--r--app/workers/members_destroyer/unassign_issuables_worker.rb2
-rw-r--r--app/workers/merge_requests/set_reviewer_reviewed_worker.rb30
-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.rb4
-rw-r--r--app/workers/new_merge_request_worker.rb1
-rw-r--r--app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb87
-rw-r--r--app/workers/packages/debian/cleanup_dangling_package_files_worker.rb32
-rw-r--r--app/workers/packages/debian/generate_distribution_worker.rb2
-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/personal_access_tokens/expired_notification_worker.rb2
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb2
-rw-r--r--app/workers/pipeline_process_worker.rb2
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/project_destroy_worker.rb5
-rw-r--r--app/workers/projects/forks/sync_worker.rb22
-rw-r--r--app/workers/projects/import_export/create_relation_exports_worker.rb48
-rw-r--r--app/workers/projects/import_export/relation_export_worker.rb23
-rw-r--r--app/workers/projects/import_export/wait_relation_exports_worker.rb82
-rw-r--r--app/workers/projects/process_sync_events_worker.rb4
-rw-r--r--app/workers/projects/record_target_platforms_worker.rb22
-rw-r--r--app/workers/prune_old_events_worker.rb10
-rw-r--r--app/workers/remove_expired_group_links_worker.rb2
-rw-r--r--app/workers/remove_expired_members_worker.rb2
-rw-r--r--app/workers/remove_unaccepted_member_invites_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb5
-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/self_monitoring_project_create_worker.rb17
-rw-r--r--app/workers/self_monitoring_project_delete_worker.rb17
-rw-r--r--app/workers/service_desk_email_receiver_worker.rb2
-rw-r--r--app/workers/ssh_keys/update_last_used_at_worker.rb21
-rw-r--r--app/workers/stage_update_worker.rb1
-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
3595 files changed, 60607 insertions, 27777 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/learn_gitlab/section_code.svg b/app/assets/images/learn_gitlab/section_code.svg
new file mode 100644
index 00000000000..da170c93be6
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_code.svg
@@ -0,0 +1,4 @@
+<svg width="25" height="30" viewBox="0 0 20 23" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M15 8L15 9.5C15 10.3284 15.6716 11 16.5 11C17.3284 11 18 10.3284 18 9.5V7.74264C18 6.94699 17.6839 6.18393 17.1213 5.62132L12.8787 1.37868C12.3161 0.816071 11.553 0.5 10.7574 0.5H1.5C0.671573 0.5 0 1.17157 0 2V20C0 20.8284 0.671573 21.5 1.5 21.5H6C6.82843 21.5 7.5 20.8284 7.5 20C7.5 19.1716 6.82843 18.5 6 18.5H3V3.5H10.5V6.5C10.5 7.32843 11.1716 8 12 8H15Z" fill="#6E49CB"/>
+<path d="M10.5 18.5C10.5 17.6716 11.1716 17 12 17H13.5V15.5C13.5 14.6716 14.1716 14 15 14C15.8284 14 16.5 14.6716 16.5 15.5V17H18C18.8284 17 19.5 17.6716 19.5 18.5C19.5 19.3284 18.8284 20 18 20H16.5V21.5C16.5 22.3284 15.8284 23 15 23C14.1716 23 13.5 22.3284 13.5 21.5V20H12C11.1716 20 10.5 19.3284 10.5 18.5Z" fill="#6E49CB"/>
+</svg>
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/images/vulnerability/secureflag-logo.svg b/app/assets/images/vulnerability/secureflag-logo.svg
new file mode 100644
index 00000000000..621c56b9043
--- /dev/null
+++ b/app/assets/images/vulnerability/secureflag-logo.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
+ <defs>
+ <linearGradient id="paint1_linear_117_388" x1="8.32922" y1="0.701083" x2="25.6103" y2="8.8381"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(5.965987, 0, 0, 5.965987, -1.949537, -0.014549)">
+ <stop stop-color="#005AEC" />
+ <stop offset="1" stop-color="#12DFE7" />
+ </linearGradient>
+ <linearGradient id="paint2_linear_117_388" x1="3.30485" y1="11.2131" x2="20.4972" y2="19.4227"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(5.965987, 0, 0, 5.965987, -1.949537, -0.014549)">
+ <stop stop-color="#005AEC" />
+ <stop offset="1" stop-color="#12DFE7" />
+ </linearGradient>
+ </defs>
+ <g style="" transform="matrix(3.098345, 0, 0, 3.098345, 46.765705, 8.335629)"
+ bx:origin="0.503455 0.502894">
+ <path d="M 65.436 0.003 L 65.436 26.662 L 87.144 12.772 L 65.436 0.003 Z"
+ fill="url(#paint1_linear_117_388)" style="" />
+ <path
+ d="M 108.686 77.337 L 87.143 65.001 L 87.143 38.543 L 65.434 51.815 L 43.562 38.543 L 43.809 64.746 L 22.512 77.592 L 0.393 64.321 L 0.393 116.301 L 65.352 155.095 L 129.901 116.301 L 129.901 64.406 L 108.686 77.337 Z M 65.434 103.2 L 43.562 90.609 L 43.562 114.344 L 22.923 102.945 L 43.562 90.609 L 65.434 77.848 C 65.434 77.848 85.663 90.694 86.156 90.609 C 86.65 90.523 65.434 103.2 65.434 103.2 Z M 86.156 114.344 L 86.156 90.609 L 106.795 102.945 L 86.156 114.344 Z"
+ fill="url(#paint2_linear_117_388)" style="" />
+ </g>
+</svg> \ No newline at end of file
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/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index d24285af5c3..02159d4d524 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { __, n__, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
diff --git a/app/assets/javascripts/achievements/components/achievements_app.vue b/app/assets/javascripts/achievements/components/achievements_app.vue
new file mode 100644
index 00000000000..5f40231856f
--- /dev/null
+++ b/app/assets/javascripts/achievements/components/achievements_app.vue
@@ -0,0 +1,31 @@
+<script>
+export default {
+ inject: {
+ canAdminAchievement: {
+ type: Boolean,
+ required: true,
+ },
+ canAwardAchievement: {
+ type: Boolean,
+ required: true,
+ },
+ groupFullPath: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ textQuery: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/achievements/constants.js b/app/assets/javascripts/achievements/constants.js
new file mode 100644
index 00000000000..82a56588c96
--- /dev/null
+++ b/app/assets/javascripts/achievements/constants.js
@@ -0,0 +1,7 @@
+export const INDEX_ROUTE_NAME = 'index';
+export const NEW_ROUTE_NAME = 'new';
+export const EDIT_ROUTE_NAME = 'edit';
+export const trackViewsOptions = {
+ category: 'Achievements' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_achievements_list',
+};
diff --git a/app/assets/javascripts/achievements/routes.js b/app/assets/javascripts/achievements/routes.js
new file mode 100644
index 00000000000..12aa17d73b6
--- /dev/null
+++ b/app/assets/javascripts/achievements/routes.js
@@ -0,0 +1,16 @@
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: INDEX_ROUTE_NAME,
+ path: '/',
+ },
+ {
+ name: NEW_ROUTE_NAME,
+ path: '/new',
+ },
+ {
+ name: EDIT_ROUTE_NAME,
+ path: '/:id/edit',
+ },
+];
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 6fc37e9331f..d6fdcea468a 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
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 a41ff42df20..a9fb692b299 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 '~/flash';
+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,8 @@ import {
removeIfReadyToBeRemoved,
removeIfPresent,
} from '../utils';
+import Token from './token.vue';
+import DateOption from './date_option.vue';
export default {
components: {
@@ -19,9 +26,9 @@ export default {
GlTabs,
GlTab,
ReviewTabContainer,
- GlSearchBoxByType,
GlSprintf,
GlBadge,
+ GlFilteredSearch,
},
props: {
contextCommitsPath: {
@@ -41,6 +48,51 @@ 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,
+ optionComponent: DateOption,
+ },
+ {
+ 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,
+ optionComponent: DateOption,
+ },
+ ],
+ };
+ },
computed: {
...mapState([
'tabIndex',
@@ -98,8 +150,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('openModal', this.openModal);
- clearTimeout(this.timeout);
- this.timeout = null;
},
methods: {
...mapActions([
@@ -114,10 +164,8 @@ export default {
'setSearchText',
'setToRemoveCommits',
'resetModalState',
+ 'fetchAuthors',
]),
- focusSearch() {
- this.$refs.searchInput.focusInput();
- },
openModal() {
this.searchCommits();
this.fetchContextCommits();
@@ -125,7 +173,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 +180,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 +274,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 +290,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 +313,15 @@ 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"
/>
+
<review-tab-container
:is-loading="isLoadingCommits"
:loading-error="commitsLoadingError"
diff --git a/app/assets/javascripts/add_context_commits_modal/components/date_option.vue b/app/assets/javascripts/add_context_commits_modal/components/date_option.vue
new file mode 100644
index 00000000000..1945e048029
--- /dev/null
+++ b/app/assets/javascripts/add_context_commits_modal/components/date_option.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ props: {
+ option: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ >{{ option.title }}
+ <span class="title-hint-text">&lt;{{ __('yyyy-mm-dd') }}&gt;</span>
+ </span>
+</template>
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 d4c9db2fa33..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 '~/flash';
+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_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
new file mode 100644
index 00000000000..9355c1c788f
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
@@ -0,0 +1,35 @@
+<script>
+import ReportHeader from './report_header.vue';
+import UserDetails from './user_details.vue';
+import ReportedContent from './reported_content.vue';
+import HistoryItems from './history_items.vue';
+
+export default {
+ name: 'AbuseReportApp',
+ components: {
+ ReportHeader,
+ UserDetails,
+ ReportedContent,
+ HistoryItems,
+ },
+ props: {
+ abuseReport: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <report-header
+ v-if="abuseReport.user"
+ :user="abuseReport.user"
+ :actions="abuseReport.actions"
+ />
+ <user-details v-if="abuseReport.user" :user="abuseReport.user" />
+ <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" />
+ <history-items :report="abuseReport.report" :reporter="abuseReport.reporter" />
+ </section>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/history_items.vue b/app/assets/javascripts/admin/abuse_report/components/history_items.vue
new file mode 100644
index 00000000000..28b66db84a2
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/history_items.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import { HISTORY_ITEMS_I18N } from '../constants';
+
+export default {
+ name: 'HistoryItems',
+ components: {
+ GlSprintf,
+ TimeAgoTooltip,
+ HistoryItem,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ reporter: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ reporterName() {
+ return this.reporter?.name || this.$options.i18n.deletedReporter;
+ },
+ },
+ i18n: HISTORY_ITEMS_I18N,
+};
+</script>
+
+<template>
+ <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below
+ are declared in app/assets/stylesheets/pages/notes.scss -->
+ <section class="gl-pt-6 issuable-discussion">
+ <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2>
+ <ul class="timeline main-notes-list notes">
+ <history-item icon="warning">
+ <div class="gl-display-flex gl-xs-flex-direction-column">
+ <gl-sprintf :message="$options.i18n.reportedByForCategory">
+ <template #name>{{ reporterName }}</template>
+ <template #category>{{ report.category }}</template>
+ </gl-sprintf>
+ <time-ago-tooltip :time="report.reportedAt" class="gl-text-secondary gl-sm-ml-3" />
+ </div>
+ </history-item>
+ </ul>
+ </section>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
new file mode 100644
index 00000000000..54586041354
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlAvatar, GlButton, GlLink } from '@gitlab/ui';
+import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
+import { REPORT_HEADER_I18N } from '../constants';
+
+export default {
+ name: 'ReportHeader',
+ components: {
+ GlAvatar,
+ GlButton,
+ GlLink,
+ AbuseReportActions,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ actions: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: REPORT_HEADER_I18N,
+};
+</script>
+
+<template>
+ <header
+ class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar :size="48" :src="user.avatarUrl" />
+ <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3">
+ {{ user.name }}
+ </h1>
+ <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link>
+ </div>
+ <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0">
+ <gl-button :href="user.adminPath" class="flex-grow-1">
+ {{ $options.i18n.adminProfile }}
+ </gl-button>
+ <abuse-report-actions :report="actions" class="gl-sm-ml-3" />
+ </nav>
+ </header>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
new file mode 100644
index 00000000000..b5ffba26360
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui';
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
+import { REPORTED_CONTENT_I18N } from '../constants';
+
+export default {
+ name: 'ReportedContent',
+ components: {
+ GlButton,
+ GlModal,
+ GlCard,
+ GlLink,
+ GlAvatar,
+ TimeAgoTooltip,
+ TruncatedText,
+ },
+ modalId: 'abuse-report-screenshot-modal',
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ reporter: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ showScreenshotModal: false,
+ };
+ },
+ computed: {
+ reporterName() {
+ return this.reporter?.name || this.$options.i18n.deletedReporter;
+ },
+ reportType() {
+ return this.report.type || 'unknown';
+ },
+ },
+ mounted() {
+ renderGFM(this.$refs.gfmContent);
+ },
+ methods: {
+ toggleScreenshotModal() {
+ this.showScreenshotModal = !this.showScreenshotModal;
+ },
+ },
+ i18n: REPORTED_CONTENT_I18N,
+ screenshotModalButtonAttributes: {
+ text: __('Close'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+};
+</script>
+
+<template>
+ <div class="gl-pt-6">
+ <div
+ class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
+ >
+ <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">
+ {{ $options.i18n.reportTypes[reportType] }}
+ </h2>
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-xs-flex-direction-column gl-mt-3 gl-sm-mt-0"
+ >
+ <template v-if="report.screenshot">
+ <gl-button data-testid="screenshot-button" @click="toggleScreenshotModal">
+ {{ $options.i18n.viewScreenshot }}
+ </gl-button>
+ <gl-modal
+ v-model="showScreenshotModal"
+ :title="$options.i18n.screenshotTitle"
+ :modal-id="$options.modalId"
+ :action-primary="$options.screenshotModalButtonAttributes"
+ >
+ <img
+ :src="report.screenshot"
+ :alt="$options.i18n.screenshotTitle"
+ class="gl-w-full gl-h-auto"
+ />
+ </gl-modal>
+ </template>
+ <gl-button
+ v-if="report.url"
+ data-testid="report-url-button"
+ :href="report.url"
+ class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0"
+ >
+ {{ $options.i18n.goToType[reportType] }}
+ </gl-button>
+ </div>
+ </div>
+ <gl-card
+ header-class="gl-bg-white js-test-card-header"
+ body-class="gl-bg-gray-50 gl-px-5 gl-py-3 js-test-card-body"
+ footer-class="gl-bg-white js-test-card-footer"
+ >
+ <template v-if="report.content" #header>
+ <truncated-text>
+ <div
+ ref="gfmContent"
+ v-safe-html:[$options.safeHtmlConfig]="report.content"
+ class="md"
+ ></div>
+ </truncated-text>
+ </template>
+ {{ $options.i18n.reportedBy }}
+ <template #footer>
+ <div class="gl-display-flex gl-align-items-center gl-mb-2">
+ <gl-avatar :size="32" :src="reporter && reporter.avatarUrl" />
+ <div class="gl-display-flex gl-flex-wrap">
+ <span class="gl-ml-3 gl-font-weight-bold">
+ {{ reporterName }}
+ </span>
+ <gl-link v-if="reporter" :href="reporter.path" class="gl-ml-3">
+ @{{ reporter.username }}
+ </gl-link>
+ <time-ago-tooltip
+ :time="report.reportedAt"
+ class="gl-ml-3 gl-text-secondary gl-xs-w-full"
+ />
+ </div>
+ </div>
+ <p v-if="report.message" class="gl-pl-8 gl-mb-0">{{ report.message }}</p>
+ </template>
+ </gl-card>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/user_detail.vue b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue
new file mode 100644
index 00000000000..0aeee5e05f8
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ name: 'UserDetail',
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mb-4">
+ <p class="gl-font-weight-bold gl-flex-grow-1 gl-flex-basis-0 gl-mb-0">
+ {{ label }}
+ </p>
+ <div class="gl-flex-grow-1 gl-flex-basis-two-thirds">
+ <slot>{{ value }}</slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
new file mode 100644
index 00000000000..3dc03a8748f
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { formatNumber } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { USER_DETAILS_I18N } from '../constants';
+import UserDetail from './user_detail.vue';
+
+export default {
+ name: 'UserDetails',
+ components: {
+ GlLink,
+ GlSprintf,
+ TimeAgoTooltip,
+ UserDetail,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ verificationState() {
+ return Object.entries(this.user.verificationState)
+ .filter(([, v]) => v)
+ .map(([k]) => this.$options.i18n.verificationMethods[k])
+ .join(', ');
+ },
+ showSimilarRecords() {
+ return this.user.creditCard.similarRecordsCount > 1;
+ },
+ similarRecordsCount() {
+ return formatNumber(this.user.creditCard.similarRecordsCount);
+ },
+ },
+ i18n: USER_DETAILS_I18N,
+};
+</script>
+
+<template>
+ <div class="gl-mt-6">
+ <user-detail data-testid="createdAt" :label="$options.i18n.createdAt">
+ <time-ago-tooltip :time="user.createdAt" />
+ </user-detail>
+ <user-detail data-testid="email" :label="$options.i18n.email">
+ <gl-link :href="`mailto:${user.email}`">{{ user.email }}</gl-link>
+ </user-detail>
+ <user-detail data-testid="plan" :label="$options.i18n.plan" :value="user.plan" />
+ <user-detail
+ data-testid="verification"
+ :label="$options.i18n.verification"
+ :value="verificationState"
+ />
+ <user-detail v-if="user.creditCard" data-testid="creditCard" :label="$options.i18n.creditCard">
+ <gl-sprintf :message="$options.i18n.registeredWith">
+ <template #name>{{ user.creditCard.name }}</template>
+ </gl-sprintf>
+ <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords">
+ <template #cardMatchesLink="{ content }">
+ <gl-link :href="user.creditCard.cardMatchesLink">
+ <gl-sprintf :message="content">
+ <template #count>{{ similarRecordsCount }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </user-detail>
+ <user-detail
+ v-if="user.otherReports.length"
+ data-testid="otherReports"
+ :label="$options.i18n.otherReports"
+ >
+ <div
+ v-for="(report, index) in user.otherReports"
+ :key="index"
+ :data-testid="`other-report-${index}`"
+ >
+ <gl-sprintf :message="$options.i18n.otherReport">
+ <template #reportLink="{ content }">
+ <gl-link :href="report.reportPath">{{ content }}</gl-link>
+ </template>
+ <template #category>{{ report.category }}</template>
+ <template #timeAgo>
+ <time-ago-tooltip :time="report.createdAt" />
+ </template>
+ </gl-sprintf>
+ </div>
+ </user-detail>
+ <user-detail
+ data-testid="normalLocation"
+ :label="$options.i18n.normalLocation"
+ :value="user.mostUsedIp || user.lastSignInIp"
+ />
+ <user-detail
+ data-testid="lastSignInIp"
+ :label="$options.i18n.lastSignInIp"
+ :value="user.lastSignInIp"
+ />
+ <user-detail
+ data-testid="snippets"
+ :label="$options.i18n.snippets"
+ :value="$options.i18n.snippetsCount(user.snippetsCount)"
+ />
+ <user-detail
+ data-testid="groups"
+ :label="$options.i18n.groups"
+ :value="$options.i18n.groupsCount(user.groupsCount)"
+ />
+ <user-detail
+ data-testid="notes"
+ :label="$options.i18n.notes"
+ :value="$options.i18n.notesCount(user.notesCount)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js
new file mode 100644
index 00000000000..a59e10b5d4a
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/constants.js
@@ -0,0 +1,61 @@
+import { s__, n__ } from '~/locale';
+
+export const REPORT_HEADER_I18N = {
+ adminProfile: s__('AbuseReport|Admin profile'),
+};
+
+export const USER_DETAILS_I18N = {
+ createdAt: s__('AbuseReport|Member since'),
+ email: s__('AbuseReport|Email'),
+ plan: s__('AbuseReport|Tier'),
+ verification: s__('AbuseReport|Verification'),
+ creditCard: s__('AbuseReport|Credit card'),
+ otherReports: s__('AbuseReport|Abuse reports'),
+ normalLocation: s__('AbuseReport|Normal location'),
+ lastSignInIp: s__('AbuseReport|Last login'),
+ snippets: s__('AbuseReport|Snippets'),
+ groups: s__('AbuseReport|Groups'),
+ notes: s__('AbuseReport|Comments'),
+ snippetsCount: (count) => n__(`%d snippet`, `%d snippets`, count),
+ groupsCount: (count) => n__(`%d group`, `%d groups`, count),
+ notesCount: (count) => n__(`%d comment`, `%d comments`, count),
+ verificationMethods: {
+ email: s__('AbuseReport|Email'),
+ phone: s__('AbuseReport|Phone'),
+ creditCard: s__('AbuseReport|Credit card'),
+ },
+ otherReport: s__(
+ 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.',
+ ),
+ registeredWith: s__('AbuseReport|Registered with name %{name}.'),
+ similarRecords: s__(
+ 'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}',
+ ),
+};
+
+export const REPORTED_CONTENT_I18N = {
+ reportTypes: {
+ profile: s__('AbuseReport|Reported profile'),
+ comment: s__('AbuseReport|Reported comment'),
+ issue: s__('AbuseReport|Reported issue'),
+ merge_request: s__('AbuseReport|Reported merge request'),
+ unknown: s__('AbuseReport|Reported content'),
+ },
+ viewScreenshot: s__('AbuseReport|View screenshot'),
+ screenshotTitle: s__('AbuseReport|Screenshot of reported abuse'),
+ goToType: {
+ profile: s__('AbuseReport|Go to profile'),
+ comment: s__('AbuseReport|Go to comment'),
+ issue: s__('AbuseReport|Go to issue'),
+ merge_request: s__('AbuseReport|Go to merge request'),
+ unknown: s__('AbuseReport|Go to content'),
+ },
+ reportedBy: s__('AbuseReport|Reported by'),
+ deletedReporter: s__('AbuseReport|No user found'),
+};
+
+export const HISTORY_ITEMS_I18N = {
+ activity: s__('AbuseReport|Activity'),
+ reportedByForCategory: s__('AbuseReport|Reported by %{name} for %{category}.'),
+ deletedReporter: s__('AbuseReport|No user found'),
+};
diff --git a/app/assets/javascripts/admin/abuse_report/index.js b/app/assets/javascripts/admin/abuse_report/index.js
new file mode 100644
index 00000000000..8ff3e690127
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AbuseReportApp from './components/abuse_report_app.vue';
+
+export const initAbuseReportApp = () => {
+ const el = document.querySelector('#js-abuse-reports-detail-view');
+
+ if (!el) {
+ return null;
+ }
+
+ const { abuseReportData } = el.dataset;
+ const abuseReport = convertObjectPropsToCamelCase(JSON.parse(abuseReportData), {
+ deep: true,
+ });
+
+ return new Vue({
+ el,
+ name: 'AbuseReportAppRoot',
+ render: (createElement) =>
+ createElement(AbuseReportApp, {
+ props: {
+ abuseReport,
+ },
+ }),
+ });
+};
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..5d42caa75ab
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
@@ -0,0 +1,177 @@
+<script>
+import { GlDisclosureDropdown, GlModal } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+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',
+ },
+ },
+};
+const BLOCK_ACTION = 'block';
+const REMOVE_USER_AND_REPORT_ACTION = 'removeUserAndReport';
+
+export default {
+ name: 'AbuseReportActions',
+ components: {
+ GlDisclosureDropdown,
+ 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]: {
+ action: this.blockUser,
+ confirmText: this.$options.i18n.blockUserConfirm,
+ },
+ [REMOVE_USER_AND_REPORT_ACTION]: {
+ action: this.removeUserAndReport,
+ confirmText: this.removeUserAndReportConfirmText,
+ },
+ };
+ },
+ reportActionsDropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.removeUserAndReport,
+ action: () => {
+ this.showConfirmModal(REMOVE_USER_AND_REPORT_ACTION);
+ },
+ extraAttrs: { class: 'gl-text-red-500!' },
+ },
+ {
+ text: this.blockUserButtonText,
+ action: () => {
+ this.showConfirmModal(BLOCK_ACTION);
+ },
+ extraAttrs: {
+ disabled: this.userBlocked,
+ 'data-testid': 'block-user-button',
+ },
+ },
+ {
+ text: this.$options.i18n.removeReport,
+ action: () => {
+ this.removeReport();
+ },
+ },
+ ];
+ },
+ },
+ 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() {
+ // eslint-disable-next-line import/no-deprecated
+ if (this.report.redirectPath) redirectTo(this.report.redirectPath);
+ else refreshCurrentPage();
+ },
+ 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>
+ <div>
+ <gl-disclosure-dropdown
+ :toggle-text="$options.i18n.actionsToggleText"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ placement="right"
+ :items="reportActionsDropdownItems"
+ />
+ <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"
+ />
+ </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
new file mode 100644
index 00000000000..b8a4640de59
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { queryToObject } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { SORT_UPDATED_AT } from '../constants';
+
+export default {
+ name: 'AbuseReportRow',
+ components: {
+ GlLink,
+ ListItem,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ 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) });
+ },
+ title() {
+ const { reportedUser, category, reporter } = this.report;
+ const template = s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}');
+ return sprintf(template, {
+ reportedUser: reportedUser?.name || s__('AbuseReports|Deleted user'),
+ reporter: reporter?.name || s__('AbuseReports|Deleted user'),
+ category,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item data-testid="abuse-report-row">
+ <template #left-primary>
+ <gl-link :href="report.reportPath" class="gl-font-weight-normal gl-mb-2" data-testid="title">
+ {{ title }}
+ </gl-link>
+ </template>
+
+ <template #right-secondary>
+ <div data-testid="abuse-report-date">{{ displayDate }}</div>
+ </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
new file mode 100644
index 00000000000..b1eb5371a35
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -0,0 +1,109 @@
+<script>
+import { setUrlParams, redirectTo, queryToObject, updateHistory } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ FILTERED_SEARCH_TOKENS,
+ DEFAULT_SORT,
+ SORT_OPTIONS,
+ isValidSortKey,
+} from '~/admin/abuse_reports/constants';
+import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
+
+export default {
+ name: 'AbuseReportsFilteredSearchBar',
+ components: { FilteredSearchBar },
+ sortOptions: SORT_OPTIONS,
+ inject: ['categories'],
+ data() {
+ return {
+ initialFilterValue: [],
+ initialSortBy: DEFAULT_SORT,
+ };
+ },
+ computed: {
+ tokens() {
+ return [...FILTERED_SEARCH_TOKENS, buildFilteredSearchCategoryToken(this.categories)];
+ },
+ },
+ created() {
+ const query = queryToObject(window.location.search);
+
+ // 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 (!isValidStatus(query.status)) {
+ query.status = 'open';
+ updateHistory({ url: setUrlParams(query), replace: true });
+ }
+
+ const sort = this.currentSortKey();
+ if (sort) {
+ this.initialSortBy = query.sort;
+ }
+
+ const tokens = this.tokens
+ .filter((token) => query[token.type])
+ .map((token) => ({
+ type: token.type,
+ value: {
+ data: query[token.type],
+ operator: '=',
+ },
+ }));
+
+ this.initialFilterValue = tokens;
+ },
+ methods: {
+ currentSortKey() {
+ const { sort } = queryToObject(window.location.search);
+
+ return isValidSortKey(sort) ? sort : undefined;
+ },
+ handleFilter(tokens) {
+ let params = tokens.reduce((accumulator, token) => {
+ const { type, value } = token;
+
+ // We don't support filtering reports by search term for now
+ if (!value || !type || type === FILTERED_SEARCH_TERM) {
+ return accumulator;
+ }
+
+ return {
+ ...accumulator,
+ [type]: value.data,
+ };
+ }, {});
+
+ const sort = this.currentSortKey();
+ if (sort) {
+ params = { ...params, sort };
+ }
+
+ redirectTo(setUrlParams(params, window.location.href, true)); // eslint-disable-line import/no-deprecated
+ },
+ handleSort(sort) {
+ const { page, ...query } = queryToObject(window.location.search);
+
+ redirectTo(setUrlParams({ ...query, sort }, window.location.href, true)); // eslint-disable-line import/no-deprecated
+ },
+ },
+ filteredSearchNamespace: 'abuse_reports',
+ recentSearchesStorageKey: 'abuse_reports',
+};
+</script>
+
+<template>
+ <filtered-search-bar
+ :namespace="$options.filteredSearchNamespace"
+ :tokens="tokens"
+ :recent-searches-storage-key="$options.recentSearchesStorageKey"
+ :search-input-placeholder="__('Filter reports')"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :sort-options="$options.sortOptions"
+ data-testid="abuse-reports-filtered-search-bar"
+ @onFilter="handleFilter"
+ @onSort="handleSort"
+ />
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/app.vue b/app/assets/javascripts/admin/abuse_reports/components/app.vue
new file mode 100644
index 00000000000..e1e75a4f8d0
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/app.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlEmptyState, GlPagination } from '@gitlab/ui';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import FilteredSearchBar from './abuse_reports_filtered_search_bar.vue';
+import AbuseReportRow from './abuse_report_row.vue';
+
+export default {
+ name: 'AbuseReportsApp',
+ components: {
+ AbuseReportRow,
+ FilteredSearchBar,
+ GlEmptyState,
+ GlPagination,
+ },
+ props: {
+ abuseReports: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showPagination() {
+ return this.pagination.totalItems > this.pagination.perPage;
+ },
+ },
+ methods: {
+ paginationLinkGenerator(page) {
+ return mergeUrlParams({ page }, window.location.href);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <filtered-search-bar />
+
+ <gl-empty-state v-if="abuseReports.length == 0" :title="s__('AbuseReports|No reports found')" />
+ <abuse-report-row
+ v-for="(report, index) in abuseReports"
+ v-else
+ :key="index"
+ :report="report"
+ />
+
+ <gl-pagination
+ v-if="showPagination"
+ :value="pagination.currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.totalItems"
+ :link-gen="paginationLinkGenerator"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ class="gl-mt-3"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
new file mode 100644
index 00000000000..7dd60e9da95
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -0,0 +1,90 @@
+import { getUsers } from '~/rest_api';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
+import {
+ OPERATORS_IS,
+ TOKEN_TITLE_STATUS,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { __ } from '~/locale';
+
+const STATUS_OPTIONS = [
+ { value: 'closed', title: __('Closed') },
+ { value: 'open', title: __('Open') },
+];
+
+export const FILTERED_SEARCH_TOKEN_USER = {
+ type: 'user',
+ icon: 'user',
+ title: __('User'),
+ token: UserToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ fetchUsers: getUsers,
+ defaultUsers: [],
+};
+
+export const FILTERED_SEARCH_TOKEN_REPORTER = {
+ ...FILTERED_SEARCH_TOKEN_USER,
+ type: 'reporter',
+ title: __('Reporter'),
+};
+
+export const FILTERED_SEARCH_TOKEN_STATUS = {
+ type: 'status',
+ icon: 'status',
+ title: TOKEN_TITLE_STATUS,
+ token: BaseToken,
+ unique: true,
+ options: STATUS_OPTIONS,
+ operators: OPERATORS_IS,
+};
+
+export const DEFAULT_SORT = 'created_at_desc';
+export const SORT_UPDATED_AT = Object.freeze({
+ 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(
+ (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
+ );
+
+export const FILTERED_SEARCH_TOKEN_CATEGORY = {
+ type: 'category',
+ icon: 'label',
+ title: __('Category'),
+ token: BaseToken,
+ unique: true,
+ operators: OPERATORS_IS,
+};
+
+export const FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_USER,
+ 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'),
+ actionsToggleText: __('Actions'),
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/index.js b/app/assets/javascripts/admin/abuse_reports/index.js
new file mode 100644
index 00000000000..dbc466af2d2
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AbuseReportsApp from './components/app.vue';
+
+export const initAbuseReportsApp = () => {
+ const el = document.querySelector('#js-abuse-reports-list-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const { abuseReportsData } = el.dataset;
+ const { categories, reports, pagination } = convertObjectPropsToCamelCase(
+ JSON.parse(abuseReportsData),
+ {
+ deep: true,
+ },
+ );
+
+ return new Vue({
+ el,
+ provide: { categories },
+ render: (createElement) =>
+ createElement(AbuseReportsApp, {
+ props: {
+ abuseReports: reports,
+ pagination,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
new file mode 100644
index 00000000000..d30e8fb0ae5
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -0,0 +1,9 @@
+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/application_settings/network_outbound.js b/app/assets/javascripts/admin/application_settings/network_outbound.js
new file mode 100644
index 00000000000..ad7ed85131c
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/network_outbound.js
@@ -0,0 +1,28 @@
+export default () => {
+ const denyAllRequests = document.querySelector('.js-deny-all-requests');
+
+ if (!denyAllRequests) {
+ return;
+ }
+
+ denyAllRequests.addEventListener('change', () => {
+ const denyAll = denyAllRequests.checked;
+ const allowLocalRequests = document.querySelectorAll('.js-allow-local-requests');
+ const denyAllRequestsWarning = document.querySelector('.js-deny-all-requests-warning');
+
+ if (denyAll) {
+ denyAllRequestsWarning.classList.remove('gl-display-none');
+ } else {
+ denyAllRequestsWarning.classList.add('gl-display-none');
+ }
+
+ allowLocalRequests.forEach((allowLocalRequest) => {
+ /* eslint-disable no-param-reassign */
+ if (denyAll) {
+ allowLocalRequest.checked = false;
+ }
+ allowLocalRequest.disabled = denyAll;
+ /* eslint-enable no-param-reassign */
+ });
+ });
+};
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
index f869d21d55f..667ab4c34f5 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
@@ -1,8 +1,8 @@
<script>
import { GlPagination } from '@gitlab/ui';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-import { createAlert, VARIANT_DANGER } from '~/flash';
+import { createAlert, VARIANT_DANGER } from '~/alert';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { NEW_BROADCAST_MESSAGE } from '../constants';
@@ -66,7 +66,7 @@ export default {
// stranded on page 2 when deleting the last message.
// Force a page reload to avoid this edge case.
if (newVal === PER_PAGE && oldVal === PER_PAGE + 1) {
- redirectTo(this.buildPageUrl(1));
+ redirectTo(this.buildPageUrl(1)); // eslint-disable-line import/no-deprecated
}
},
},
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 36796708e78..022f5df9c96 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,
@@ -12,32 +13,45 @@ import {
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-import { createAlert, VARIANT_DANGER } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { BROADCAST_MESSAGES_PATH, THEMES, TYPES, TYPE_BANNER } from '../constants';
-import MessageFormGroup from './message_form_group.vue';
+import { createAlert, VARIANT_DANGER } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+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 DatetimePicker from './datetime_picker.vue';
const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } };
export default {
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
name: 'MessageForm',
components: {
DatetimePicker,
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlFormTextarea,
- MessageFormGroup,
},
- mixins: [glFeatureFlagsMixin()],
- inject: ['targetAccessLevelOptions'],
+ directives: {
+ SafeHtml,
+ },
+ inject: {
+ targetAccessLevelOptions: {
+ default: [[]],
+ },
+ messagesPath: {
+ default: '',
+ },
+ previewPath: {
+ default: '',
+ },
+ },
i18n: {
message: s__('BroadcastMessages|Message'),
messagePlaceholder: s__('BroadcastMessages|Your message here'),
@@ -81,6 +95,7 @@ export default {
})),
startsAt: new Date(this.broadcastMessage.startsAt.getTime()),
endsAt: new Date(this.broadcastMessage.endsAt.getTime()),
+ renderedMessage: '',
};
},
computed: {
@@ -91,15 +106,15 @@ export default {
return this.message.trim() === '';
},
messagePreview() {
- return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.message;
+ return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.renderedMessage;
},
isAddForm() {
return !this.broadcastMessage.id;
},
formPath() {
return this.isAddForm
- ? BROADCAST_MESSAGES_PATH
- : `${BROADCAST_MESSAGES_PATH}/${this.broadcastMessage.id}`;
+ ? this.messagesPath
+ : `${this.messagesPath}/${this.broadcastMessage.id}`;
},
formPayload() {
return JSON.stringify({
@@ -114,13 +129,21 @@ export default {
});
},
},
+ watch: {
+ message: {
+ handler() {
+ this.renderPreview();
+ },
+ immediate: true,
+ },
+ },
methods: {
async onSubmit() {
this.loading = true;
const success = await this.submitForm();
if (success) {
- redirectTo(BROADCAST_MESSAGES_PATH);
+ redirectTo(this.messagesPath); // eslint-disable-line import/no-deprecated
} else {
this.loading = false;
}
@@ -140,39 +163,59 @@ export default {
}
return true;
},
+
+ async renderPreview() {
+ try {
+ const res = await axios.post(this.previewPath, this.formPayload, FORM_HEADERS);
+ this.renderedMessage = res.data;
+ } catch (e) {
+ this.renderedMessage = '';
+ }
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use'],
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
- <gl-broadcast-message class="gl-my-6" :type="type" :theme="theme" :dismissible="dismissable">
- {{ messagePreview }}
+ <gl-broadcast-message
+ class="gl-my-6"
+ :type="type"
+ :theme="theme"
+ :dismissible="dismissable"
+ data-testid="preview-broadcast-message"
+ >
+ <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"
size="sm"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
: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"
@@ -181,36 +224,32 @@ export default {
>
<span>{{ $options.i18n.dismissableDescription }}</span>
</gl-form-checkbox>
- </message-form-group>
+ </gl-form-group>
</template>
- <message-form-group
- v-if="glFeatures.roleTargetedBroadcastMessages"
- :label="$options.i18n.targetRoles"
- data-testid="target-roles-checkboxes"
- >
+ <gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes">
<gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" />
<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-mb-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/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
index a523dd3b391..c95d4c96ea9 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
@@ -1,22 +1,21 @@
<script>
-import { GlButton, GlTableLite } from '@gitlab/ui';
+import { GlBroadcastMessage, GlButton, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!';
export default {
name: 'MessagesTable',
components: {
+ GlBroadcastMessage,
GlButton,
GlTableLite,
},
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
i18n: {
edit: __('Edit'),
delete: __('Delete'),
@@ -27,13 +26,7 @@ export default {
required: true,
},
},
- computed: {
- fields() {
- if (this.glFeatures.roleTargetedBroadcastMessages) return this.$options.allFields;
- return this.$options.allFields.filter((f) => f.key !== 'target_roles');
- },
- },
- allFields: [
+ fields: [
{
key: 'status',
label: __('Status'),
@@ -76,9 +69,6 @@ export default {
tdClass: `${DEFAULT_TD_CLASSES} gl-white-space-nowrap`,
},
],
- safeHtmlConfig: {
- ADD_TAGS: ['use'],
- },
methods: {
formatDate(dateString) {
return formatDate(new Date(dateString));
@@ -89,12 +79,14 @@ export default {
<template>
<gl-table-lite
:items="messages"
- :fields="fields"
+ :fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'message-row' }"
stacked="md"
>
- <template #cell(preview)="{ item: { preview } }">
- <div v-safe-html:[$options.safeHtmlConfig]="preview"></div>
+ <template #cell(preview)="{ item: { message, theme, broadcast_type, dismissable } }">
+ <gl-broadcast-message :theme="theme" :type="broadcast_type" :dismissible="dismissable">
+ {{ message }}
+ </gl-broadcast-message>
</template>
<template #cell(starts_at)="{ item: { starts_at } }">
diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js
index 6250d5a943d..9f64b2dcaa0 100644
--- a/app/assets/javascripts/admin/broadcast_messages/constants.js
+++ b/app/assets/javascripts/admin/broadcast_messages/constants.js
@@ -1,7 +1,5 @@
import { s__ } from '~/locale';
-export const BROADCAST_MESSAGES_PATH = '/admin/broadcast_messages';
-
export const TYPE_BANNER = 'banner';
export const TYPE_NOTIFICATION = 'notification';
diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js
index 70a270f7a56..91dae949d45 100644
--- a/app/assets/javascripts/admin/broadcast_messages/edit.js
+++ b/app/assets/javascripts/admin/broadcast_messages/edit.js
@@ -11,6 +11,8 @@ export default () => {
dismissable,
targetAccessLevels,
targetAccessLevelOptions,
+ messagesPath,
+ previewPath,
targetPath,
startsAt,
endsAt,
@@ -21,6 +23,8 @@ export default () => {
name: 'EditBroadcastMessage',
provide: {
targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions),
+ messagesPath,
+ previewPath,
},
render(createElement) {
return createElement(MessageForm, {
diff --git a/app/assets/javascripts/admin/broadcast_messages/index.js b/app/assets/javascripts/admin/broadcast_messages/index.js
index fd8b2aad4ec..29021043b1a 100644
--- a/app/assets/javascripts/admin/broadcast_messages/index.js
+++ b/app/assets/javascripts/admin/broadcast_messages/index.js
@@ -3,13 +3,22 @@ import BroadcastMessagesBase from './components/base.vue';
export default () => {
const el = document.querySelector('#js-broadcast-messages');
- const { page, targetAccessLevelOptions, messagesCount, messages } = el.dataset;
+ const {
+ page,
+ targetAccessLevelOptions,
+ messagesPath,
+ previewPath,
+ messagesCount,
+ messages,
+ } = el.dataset;
return new Vue({
el,
name: 'BroadcastMessages',
provide: {
targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions),
+ messagesPath,
+ previewPath,
},
render(createElement) {
return createElement(BroadcastMessagesBase, {
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index be85ee43891..134498af348 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -5,7 +5,7 @@ import { __ } from '~/locale';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import csrf from '~/lib/utils/csrf';
export default {
diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js
index 4f952698d7a..7372f03ec0b 100644
--- a/app/assets/javascripts/admin/statistics_panel/store/actions.js
+++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 3a54035c587..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: {
@@ -41,7 +41,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.activate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -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 5a8c675822d..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: {
@@ -43,7 +43,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.approve,
- attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }],
+ attributes: { variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' },
},
messageHtml,
},
@@ -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 898a688c203..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: {
@@ -56,7 +56,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.ban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -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 d25dd400f9b..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: {
@@ -42,7 +42,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.block,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -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 c85f3f01675..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: {
@@ -51,7 +51,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.deactivate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -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 bac08de1d5e..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: {
@@ -54,7 +54,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.reject,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
messageHtml,
},
@@ -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 beede2d37d7..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: {
@@ -37,7 +37,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -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 720f2efd932..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: {
@@ -32,7 +32,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unblock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
@@ -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 55ea3e0aba7..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: {
@@ -31,7 +31,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unlock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
@@ -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 f569cda0a4b..2d2c598f953 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -1,6 +1,6 @@
<script>
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
import { thWidthPercent } from '~/lib/utils/table_utility';
import { s__, __ } from '~/locale';
@@ -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/airflow/dags/components/dags.vue b/app/assets/javascripts/airflow/dags/components/dags.vue
deleted file mode 100644
index 88eb3fd5aba..00000000000
--- a/app/assets/javascripts/airflow/dags/components/dags.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<script>
-import { GlTableLite, GlEmptyState, GlPagination, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import { formatDate } from '~/lib/utils/datetime/date_format_utility';
-import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
-
-export default {
- name: 'AirflowDags',
- components: {
- GlTableLite,
- GlEmptyState,
- IncubationAlert,
- GlPagination,
- TimeAgo,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- dags: {
- type: Array,
- required: true,
- },
- pagination: {
- type: Object,
- required: true,
- },
- },
- computed: {
- fields() {
- return [
- { key: 'dag_name', label: this.$options.i18n.dagLabel },
- { key: 'schedule', label: this.$options.scheduleLabel },
- { key: 'next_run', label: this.$options.nextRunLabel },
- { key: 'is_active', label: this.$options.isActiveLabel },
- { key: 'is_paused', label: this.$options.isPausedLabel },
- { key: 'fileloc', label: this.$options.fileLocLabel },
- ];
- },
- hasPagination() {
- return this.dags.length > 0;
- },
- prevPage() {
- return this.pagination.page > 1 ? this.pagination.page - 1 : null;
- },
- nextPage() {
- return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
- },
- emptyState() {
- return {
- svgPath: '/assets/illustrations/empty-state/empty-dag-md.svg',
- };
- },
- },
- methods: {
- generateLink(page) {
- return setUrlParams({ page });
- },
- formatDate(dateString) {
- return formatDate(new Date(dateString));
- },
- },
- i18n: {
- emptyStateLabel: s__('Airflow|There are no DAGs to show'),
- emptyStateDescription: s__(
- 'Airflow|Either the Airflow instance does not contain DAGs or has yet to be configured',
- ),
- dagLabel: s__('Airflow|DAG'),
- scheduleLabel: s__('Airflow|Schedule'),
- nextRunLabel: s__('Airflow|Next run'),
- isActiveLabel: s__('Airflow|Is active'),
- isPausedLabel: s__('Airflow|Is paused'),
- fileLocLabel: s__('Airflow|DAG file location'),
- featureName: s__('Airflow|GitLab Airflow integration'),
- },
- linkToFeedbackIssue:
- 'https://gitlab.com/gitlab-org/incubation-engineering/airflow/meta/-/issues/2',
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.i18n.featureName"
- :link-to-feedback-issue="$options.linkToFeedbackIssue"
- />
- <gl-empty-state
- v-if="!dags.length"
- :title="$options.i18n.emptyStateLabel"
- :description="$options.i18n.emptyStateDescription"
- :svg-path="emptyState.svgPath"
- />
- <gl-table-lite v-else :items="dags" :fields="fields" class="gl-mt-0!">
- <template #cell(next_run)="data">
- <time-ago v-gl-tooltip.hover :time="data.value" :title="formatDate(data.value)" />
- </template>
- </gl-table-lite>
- <gl-pagination
- v-if="hasPagination"
- :value="pagination.page"
- :prev-page="prevPage"
- :next-page="nextPage"
- :total-items="pagination.totalItems"
- :per-page="pagination.perPage"
- :link-gen="generateLink"
- align="center"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/alert.js
index 483f1d2c7a0..006c4f50d09 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/alert.js
@@ -15,7 +15,7 @@ export const VARIANT_TIP = 'tip';
*
* @example
* // Render a new alert
- * import { createAlert, VARIANT_WARNING } from '~/flash';
+ * import { createAlert, VARIANT_WARNING } from '~/alert';
*
* createAlert({ message: 'My error message' });
* createAlert({ message: 'My warning message', variant: VARIANT_WARNING });
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/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
index 38bcdef3e04..b9e37b9ede7 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
@@ -5,8 +5,7 @@ import {
GlLink,
GlFormGroup,
GlFormCheckbox,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
} from '@gitlab/ui';
import {
I18N_ALERT_SETTINGS_FORM,
@@ -22,8 +21,7 @@ export default {
GlLink,
GlFormGroup,
GlFormCheckbox,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
inject: ['service', 'alertSettings'],
data() {
@@ -40,9 +38,6 @@ export default {
TAKING_INCIDENT_ACTION_DOCS_LINK,
ISSUE_TEMPLATES_DOCS_LINK,
computed: {
- issueTemplateHeader() {
- return this.issueTemplate || NO_ISSUE_TEMPLATE_SELECTED.name;
- },
formData() {
return {
create_issue: this.createIssueEnabled,
@@ -53,12 +48,6 @@ export default {
},
},
methods: {
- selectIssueTemplate(templateKey) {
- this.issueTemplate = templateKey;
- },
- isTemplateSelected(templateKey) {
- return templateKey === this.issueTemplate;
- },
updateAlertsIntegrationSettings() {
this.loading = true;
@@ -99,23 +88,13 @@ export default {
<span class="gl-font-weight-normal gl-pl-2">{{ $options.i18n.introLinkText }}</span>
</gl-link>
</label>
- <gl-dropdown
+ <gl-collapsible-listbox
id="alert-integration-settings-issue-template"
+ v-model="issueTemplate"
+ :items="templates"
+ block
data-qa-selector="incident_templates_dropdown"
- :text="issueTemplateHeader"
- :block="true"
- >
- <gl-dropdown-item
- v-for="template in templates"
- :key="template.key"
- data-qa-selector="incident_templates_item"
- is-check-item
- :is-checked="isTemplateSelected(template.key)"
- @click="selectIssueTemplate(template.key)"
- >
- {{ template.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ />
</gl-form-group>
<gl-form-group class="gl-pl-0 gl-mb-5">
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 7dd33da435a..cc8913c2f45 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -2,7 +2,7 @@
import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { fetchPolicies } from '~/lib/graphql';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { typeSet, i18n, tabIndices } from '../constants';
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index b93119d6e6a..6d914fe8361 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -183,8 +183,8 @@ export const I18N_ALERT_SETTINGS_FORM = {
},
};
-export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') };
+export const NO_ISSUE_TEMPLATE_SELECTED = { value: '', text: __('No template selected') };
export const TAKING_INCIDENT_ACTION_DOCS_LINK =
- '/help/operations/metrics/alerts#trigger-actions-from-alerts';
+ '/help/operations/incident_management/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
'/help/user/project/description_templates#create-an-issue-template';
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/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
index 2e64312b0e0..e03ebffd17a 100644
--- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -1,5 +1,5 @@
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages';
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 a688e2f497b..39da3484dfe 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -4,12 +4,12 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants';
-import { toYmd } from '~/analytics/shared/utils';
+import { toYmd, generateValueStreamsDashboardLink } from '~/analytics/shared/utils';
import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
@@ -48,12 +48,13 @@ export default {
'selectedStageEvents',
'selectedStageError',
'stageCounts',
- 'endpoints',
'features',
'createdBefore',
'createdAfter',
'pagination',
'hasNoAccessError',
+ 'groupPath',
+ 'namespace',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
isLoaded() {
@@ -78,7 +79,9 @@ export default {
}
return this.selectedStageError
? this.selectedStageError
- : __("We don't have enough data to show this stage.");
+ : s__(
+ 'ValueStreamAnalyticsStage|There are 0 items to show in this stage, for these filters, within this time range.',
+ );
},
emptyStageText() {
if (this.displayNoAccess) {
@@ -90,16 +93,24 @@ export default {
},
selectedStageCount() {
if (this.selectedStage) {
- const {
- stageCounts,
- selectedStage: { id },
- } = this;
- return stageCounts[id];
+ return this.stageCounts[this.selectedStage.id];
}
return 0;
},
+ hasCycleAnalyticsForGroups() {
+ return this.features?.cycleAnalyticsForGroups;
+ },
metricsRequests() {
- return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
+ return this.hasCycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
+ },
+ showLinkToDashboard() {
+ return Boolean(this.features?.groupLevelAnalyticsDashboard && this.groupPath);
+ },
+ dashboardsPath() {
+ const { fullPath } = this.namespace;
+ return this.showLinkToDashboard
+ ? generateValueStreamsDashboardLink(this.groupPath, [fullPath])
+ : null;
},
query() {
return {
@@ -111,6 +122,9 @@ export default {
page: this.pagination?.page || null,
};
},
+ filterBarNamespacePath() {
+ return this.groupPath || this.namespace.fullPath;
+ },
},
methods: {
...mapActions([
@@ -150,11 +164,11 @@ export default {
<div>
<h3>{{ $options.i18n.pageTitle }}</h3>
<value-stream-filters
- :group-id="endpoints.groupId"
- :group-path="endpoints.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">
@@ -169,10 +183,11 @@ export default {
/>
</div>
<value-stream-metrics
- :request-path="endpoints.fullPath"
+ :request-path="namespace.fullPath"
:request-params="filterParams"
:requests="metricsRequests"
:group-by="$options.VSA_METRICS_GROUPS"
+ :dashboards-path="dashboardsPath"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<stage-table
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/path_navigation.vue b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
index ac41bc4917c..d305132ae33 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
@@ -66,33 +66,38 @@ export default {
<template #title>{{ pathItem.title }}</template>
<div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
- <div class="gl-pr-4 gl-pb-4">
+ <div class="gl-pr-4 gl-pb-3">
{{ s__('ValueStreamEvent|Stage time (median)') }}
</div>
- <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
+ <div class="gl-pb-3 gl-font-weight-bold">{{ pathItem.metric }}</div>
</div>
</div>
<div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
- <div class="gl-pr-4 gl-pb-4">
+ <div class="gl-pr-4 gl-pb-3">
{{ s__('ValueStreamEvent|Items in stage') }}
</div>
- <div class="gl-pb-4 gl-font-weight-bold">
+ <div class="gl-pb-3 gl-font-weight-bold">
<formatted-stage-count :stage-count="pathItem.stageCount" />
</div>
</div>
</div>
+ <div class="gl-px-4">
+ <div class="gl-pb-3 gl-font-style-italic">
+ {{ s__('ValueStreamEvent|Only items that reached their stop event.') }}
+ </div>
+ </div>
<div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50">
<div
v-if="pathItem.startEventHtmlDescription"
class="gl-display-flex gl-flex-direction-row"
>
- <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label">
+ <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-3 metric-label">
{{ s__('ValueStreamEvent|Start') }}
</div>
<div
v-safe-html="pathItem.startEventHtmlDescription"
- class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
+ class="gl-display-flex gl-flex-direction-column gl-pb-3 stage-event-description"
></div>
</div>
<div
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
index 78ac29426d9..1e158baa925 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
@@ -11,6 +11,7 @@ import {
import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import { scrollToElement } from '~/lib/utils/common_utils';
import {
NOT_ENOUGH_DATA_ERROR,
FIELD_KEY_TITLE,
@@ -102,9 +103,7 @@ export default {
},
data() {
if (this.pagination) {
- const {
- pagination: { sort, direction },
- } = this;
+ const { sort, direction } = this.pagination;
return {
sort,
direction,
@@ -173,6 +172,7 @@ export default {
const { sort, direction } = this.pagination;
this.track('click_button', { label: 'pagination' });
this.$emit('handleUpdatePagination', { sort, direction, page });
+ this.scrollToTop();
},
onSort({ sortBy, sortDesc }) {
const direction = sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC;
@@ -181,11 +181,14 @@ export default {
this.$emit('handleUpdatePagination', { sort: sortBy, direction });
this.track('click_button', { label: `sort_${sortBy}_${direction}` });
},
+ scrollToTop() {
+ scrollToElement(this.$el);
+ },
},
};
</script>
<template>
- <div data-testid="vsa-stage-table">
+ <div data-testid="vsa-stage-table" :class="{ 'gl-min-h-100vh': isLoading || !isEmptyStage }">
<gl-loading-icon v-if="isLoading" class="gl-mt-4" size="lg" />
<gl-empty-state
v-else-if="isEmptyStage"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
index 725952c3518..662c5c64bba 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
@@ -14,9 +14,7 @@ export default {
return Object.keys(this.time).length;
},
calculatedTime() {
- const {
- time: { days = null, mins = null, hours = null, seconds = null },
- } = this;
+ const { days = null, mins = null, hours = null, seconds = null } = this.time;
if (days) {
return {
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 17decb6b448..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,8 +31,8 @@ export default {
required: false,
default: true,
},
- groupId: {
- type: Number,
+ namespacePath: {
+ type: String,
required: true,
},
groupPath: {
@@ -73,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"
@@ -82,9 +82,7 @@ export default {
<div>
<projects-dropdown-filter
v-if="hasProjectFilter"
- :key="groupId"
class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
- :group-id="groupId"
:group-namespace="groupPath"
:query-params="projectsQueryParams"
:multi-select="$options.multiProjectSelect"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js
index 2758d686fb1..bea562fb18c 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/constants.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js
@@ -14,7 +14,7 @@ export const DEFAULT_VALUE_STREAM = {
};
export const NOT_ENOUGH_DATA_ERROR = s__(
- "ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
+ 'ValueStreamAnalyticsStage|There are 0 items to show in this stage, for these filters, within this time range.',
);
export const PAGINATION_TYPE = 'keyset';
@@ -32,11 +32,6 @@ export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the selected stage',
);
-export const OVERVIEW_METRICS = {
- TIME_SUMMARY: 'TIME_SUMMARY',
- RECENT_ACTIVITY: 'RECENT_ACTIVITY',
-};
-
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
@@ -45,3 +40,6 @@ export const METRICS_REQUESTS = [
{ endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
...SUMMARY_METRICS_REQUEST,
];
+
+export const MILESTONES_ENDPOINT = '/-/milestones.json';
+export const LABELS_ENDPOINT = '/-/labels.json';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
index 4a201e00582..32fe0abe83e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
@@ -6,9 +6,15 @@ import {
getValueStreamStageCounts,
} from '~/api/analytics_api';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
-import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants';
+import {
+ DEFAULT_VALUE_STREAM,
+ I18N_VSA_ERROR_STAGE_MEDIAN,
+ LABELS_ENDPOINT,
+ MILESTONES_ENDPOINT,
+} from '../constants';
+import { constructPathWithNamespace } from '../utils';
import * as types from './mutation_types';
export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
@@ -18,7 +24,7 @@ export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
export const fetchValueStreamStages = ({ commit, state }) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
selectedValueStream: { id },
} = state;
commit(types.REQUEST_VALUE_STREAM_STAGES);
@@ -41,7 +47,7 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
export const fetchValueStreams = ({ commit, dispatch, state }) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
} = state;
commit(types.REQUEST_VALUE_STREAMS);
@@ -180,7 +186,8 @@ export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
const {
- endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' },
+ groupPath,
+ namespace,
selectedAuthor,
selectedMilestone,
selectedAssigneeList,
@@ -189,10 +196,10 @@ export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => {
} = initialData;
dispatch('filters/setEndpoints', {
- labelsEndpoint: labelsPath,
- milestonesEndpoint: milestonesPath,
+ labelsEndpoint: constructPathWithNamespace(namespace, LABELS_ENDPOINT),
+ milestonesEndpoint: constructPathWithNamespace(namespace, MILESTONES_ENDPOINT),
groupEndpoint: groupPath,
- projectEndpoint: fullPath,
+ projectEndpoint: namespace.fullPath,
});
dispatch('filters/initialize', {
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/getters.js b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
index 83068cabf0f..f5ed922c602 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
@@ -15,11 +15,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage
export const requestParams = (state) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
selectedValueStream: { id: valueStreamId },
selectedStage: { id: stageId = null },
} = state;
- return { requestPath: fullPath, valueStreamId, stageId };
+ return { namespacePath: fullPath, valueStreamId, stageId };
};
export const paginationParams = ({ pagination: { page, sort, direction } }) => ({
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
index 8567529caf2..4af96fc96e3 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
@@ -1,15 +1,16 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { formatMedianValues } from '../utils';
+import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import * as types from './mutation_types';
export default {
[types.INITIALIZE_VSA](
state,
- { endpoints, features, createdBefore, createdAfter, pagination = {} },
+ { groupPath, features, createdBefore, createdAfter, pagination = {}, namespace = {} },
) {
- state.endpoints = endpoints;
+ state.groupPath = groupPath;
+ state.namespace = namespace;
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
state.features = features;
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js
index 00dd2e53883..0c51656c59f 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js
@@ -6,7 +6,11 @@ import {
export default () => ({
id: null,
features: {},
- endpoints: {},
+ groupPath: {},
+ namespace: {
+ name: null,
+ fullPath: null,
+ },
createdAfter: null,
createdBefore: null,
stages: [],
diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js
index 428bb11b950..d7c3804113e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/utils.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js
@@ -1,5 +1,7 @@
+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';
/**
* Takes the stages and median data, combined with the selected stage, to build an
@@ -63,55 +65,33 @@ 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) => ({
- cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
-});
-
-/**
* 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
* @returns {Object} - The initial data to load the app with
*/
export const buildCycleAnalyticsInitialData = ({
- fullPath,
- requestPath,
projectId,
- groupId,
groupPath,
- labelsPath,
- milestonesPath,
stage,
createdAfter,
createdBefore,
- gon,
+ namespaceName,
+ namespaceFullPath,
} = {}) => {
return {
projectId: parseInt(projectId, 10),
- endpoints: {
- requestPath,
- fullPath,
- labelsPath,
- milestonesPath,
- groupId: parseInt(groupId, 10),
- groupPath,
+ groupPath,
+ namespace: {
+ name: namespaceName,
+ fullPath: namespaceFullPath,
},
createdAfter: new Date(createdAfter),
createdBefore: new Date(createdBefore),
selectedStage: stage ? JSON.parse(stage) : null,
- features: extractFeatures(gon),
+ features: extractVSAFeaturesFromGON(),
};
};
+
+export const constructPathWithNamespace = ({ fullPath }, endpoint) =>
+ joinPaths('/', fullPath, endpoint);
diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
index 845a3386f6c..54dbe329c7a 100644
--- a/app/assets/javascripts/analytics/shared/components/metric_tile.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
@@ -1,6 +1,6 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import MetricPopover from './metric_popover.vue';
export default {
@@ -27,7 +27,7 @@ export default {
methods: {
clickHandler({ links }) {
if (this.hasLinks) {
- redirectTo(links[0].url);
+ redirectTo(links[0].url); // eslint-disable-line import/no-deprecated
}
},
},
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index 5bb60d91f1e..98193de4a12 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -32,11 +32,6 @@ export default {
GlTruncate,
},
props: {
- groupId: {
- type: Number,
- required: false,
- default: null,
- },
groupNamespace: {
type: String,
required: true,
diff --git a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
index cc7b554f32c..3082897af76 100644
--- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,9 +1,10 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { isEqual, keyBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf, s__ } from '~/locale';
import { fetchMetricsData, removeFlash } from '../utils';
+import ValueStreamsDashboardLink from './value_streams_dashboard_link.vue';
import MetricTile from './metric_tile.vue';
const extractMetricsGroupData = (keyList = [], data = []) => {
@@ -28,6 +29,7 @@ export default {
components: {
GlSkeletonLoader,
MetricTile,
+ ValueStreamsDashboardLink,
},
props: {
requestPath: {
@@ -52,6 +54,11 @@ export default {
required: false,
default: () => [],
},
+ dashboardsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -76,6 +83,10 @@ export default {
this.fetchData();
},
methods: {
+ shouldDisplayDashboardLink(index) {
+ // When we have groups of metrics, we should only display the link for the first group
+ return index === 0 && this.dashboardsPath;
+ },
fetchData() {
removeFlash();
this.isLoading = true;
@@ -110,7 +121,7 @@ export default {
<template v-else>
<div v-if="hasGroupedMetrics" class="gl-flex-direction-column">
<div
- v-for="group in groupedMetrics"
+ v-for="(group, groupIndex) in groupedMetrics"
:key="group.key"
class="gl-mb-7"
data-testid="vsa-metrics-group"
@@ -123,6 +134,11 @@ export default {
:metric="metric"
class="gl-mt-5 gl-pr-10"
/>
+ <value-streams-dashboard-link
+ v-if="shouldDisplayDashboardLink(groupIndex)"
+ class="gl-mt-5"
+ :request-path="dashboardsPath"
+ />
</div>
</div>
</div>
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
new file mode 100644
index 00000000000..6c79c8af54a
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ValueStreamsDashboardLink',
+ components: { GlIcon, GlLink },
+ props: {
+ requestPath: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ title: __('Related'),
+ // eslint-disable-next-line @gitlab/require-valid-i18n-helpers
+ linkText: __('Value Streams Dashboard | DORA'),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-flex-direction-column" data-testid="vsd-link">
+ <div class="gl-display-flex gl-mb-2">
+ <span>{{ $options.i18n.title }}</span>
+ </div>
+ <div class="gl-display-flex gl-align-items-baseline">
+ <gl-link :href="requestPath">{{ $options.i18n.linkText }}</gl-link
+ >&nbsp;<gl-icon name="dashboard" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 7ced658f483..c98cf90f406 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -30,18 +30,21 @@ export const DORA_METRICS = {
CHANGE_FAILURE_RATE: 'change_failure_rate',
};
-export const VSA_METRICS_GROUPS = [
- {
- key: 'key_metrics',
- title: s__('ValueStreamAnalytics|Key metrics'),
- keys: Object.values(KEY_METRICS),
- },
- {
- key: 'dora_metrics',
- title: s__('ValueStreamAnalytics|DORA metrics'),
- keys: Object.values(DORA_METRICS),
- },
-];
+const VSA_FLOW_METRICS_GROUP = {
+ key: 'key_metrics',
+ title: s__('ValueStreamAnalytics|Key metrics'),
+ keys: Object.values(KEY_METRICS),
+};
+
+export const VSA_METRICS_GROUPS = [VSA_FLOW_METRICS_GROUP];
+
+export const VULNERABILITY_CRITICAL_TYPE = 'vulnerability_critical';
+export const VULNERABILITY_HIGH_TYPE = 'vulnerability_high';
+
+export const VULNERABILITY_METRICS = {
+ CRITICAL: VULNERABILITY_CRITICAL_TYPE,
+ HIGH: VULNERABILITY_HIGH_TYPE,
+};
export const METRIC_TOOLTIPS = {
[DORA_METRICS.DEPLOYMENT_FREQUENCY]: {
@@ -106,6 +109,18 @@ export const METRIC_TOOLTIPS = {
projectLink: '-/analytics/merge_request_analytics',
docsLink: helpPagePath('user/analytics/merge_request_analytics'),
},
+ [VULNERABILITY_METRICS.CRITICAL]: {
+ description: s__('ValueStreamAnalytics|Critical vulnerabilities over time.'),
+ groupLink: '-/security/vulnerabilities',
+ projectLink: '-/security/vulnerability_report',
+ docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ },
+ [VULNERABILITY_METRICS.HIGH]: {
+ description: s__('ValueStreamAnalytics|High vulnerabilities over time.'),
+ groupLink: '-/security/vulnerabilities',
+ projectLink: '-/security/vulnerability_report',
+ docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ },
};
// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index aafbf642766..8d7e8546626 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,6 +1,7 @@
import { flatten } from 'lodash';
import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
+import { joinPaths } from '~/lib/utils/url_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats, METRICS_POPOVER_CONTENT } from './constants';
@@ -119,3 +120,36 @@ export const fetchMetricsData = (requests = [], requestPath, params) => {
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
);
};
+
+/**
+ * Generates a URL link to the VSD dashboard based on the group
+ * and project paths passed into the method.
+ *
+ * @param {String} groupPath - Path of the specified group
+ * @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 = (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 || '', '/', 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
+ vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity),
+});
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 5651789e2c7..1fd1f91bda3 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { number } from '~/lib/utils/unit_format';
import { __, s__ } from '~/locale';
import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index b02dd9321b3..87c74438d00 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { joinPaths } from './lib/utils/url_utility';
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index 66ed30130bb..35c9fa20e56 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -2,8 +2,8 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils';
-const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics';
-const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
+const PROJECT_VSA_METRICS_BASE = '/:namespace_path/-/analytics/value_stream_analytics';
+const PROJECT_VSA_PATH_BASE = '/:namespace_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
@@ -15,71 +15,77 @@ export const DEPLOYS_METRIC_TYPE = 'deploys';
export const METRIC_TYPE_SUMMARY = 'summary';
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
-const buildProjectMetricsPath = (requestPath) =>
- buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath);
+const buildProjectMetricsPath = (namespacePath) =>
+ buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':namespace_path', namespacePath);
-const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
+const buildProjectValueStreamPath = (namespacePath, valueStreamId = null) => {
if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
- .replace(':request_path', requestPath)
+ .replace(':namespace_path', namespacePath)
.replace(':value_stream_id', valueStreamId);
}
- return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath);
+ return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':namespace_path', namespacePath);
};
-const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) =>
+const buildValueStreamStageDataPath = ({ namespacePath, valueStreamId = null, stageId = null }) =>
buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH)
- .replace(':request_path', requestPath)
+ .replace(':namespace_path', namespacePath)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
-export const getProjectValueStreams = (requestPath) => {
- const url = buildProjectValueStreamPath(requestPath);
+export const getProjectValueStreams = (namespacePath) => {
+ const url = buildProjectValueStreamPath(namespacePath);
return axios.get(url);
};
-export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
- const url = buildProjectValueStreamPath(requestPath, valueStreamId);
+export const getProjectValueStreamStages = (namespacePath, valueStreamId) => {
+ const url = buildProjectValueStreamPath(namespacePath, valueStreamId);
return axios.get(url);
};
// NOTE: legacy VSA request use a different path
-// the `requestPath` provides a full url for the request
-export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) =>
- axios.get(joinPaths(requestPath, 'events', stageId), { params });
+// the `namespacePath` provides a full url for the request
+export const getProjectValueStreamStageData = ({ namespacePath, stageId, params }) =>
+ axios.get(joinPaths(namespacePath, 'events', stageId), { params });
/**
* Dedicated project VSA paths
*/
-export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+export const getValueStreamStageMedian = (
+ { namespacePath, valueStreamId, stageId },
+ params = {},
+) => {
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'median'), { params });
};
export const getValueStreamStageRecords = (
- { requestPath, valueStreamId, stageId },
+ { namespacePath, valueStreamId, stageId },
params = {},
) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'records'), { params });
};
-export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId }, params = {}) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+export const getValueStreamStageCounts = (
+ { namespacePath, valueStreamId, stageId },
+ params = {},
+) => {
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
- requestPath,
+ requestPath: namespacePath,
params = {},
}) => {
- const metricBase = buildProjectMetricsPath(requestPath);
+ const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, endpoint), { params });
};
-export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
- const metricBase = buildProjectMetricsPath(requestPath);
+export const getValueStreamSummaryMetrics = (namespacePath, params = {}) => {
+ const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, 'summary'), { params });
};
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 45fddc3a696..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 '~/flash';
-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_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql b/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
index d50fd665c16..b1664a4ed42 100644
--- a/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
+++ b/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
@@ -1,5 +1,7 @@
mutation updateKeepLatestArtifactProjectSetting($fullPath: ID!, $keepLatestArtifact: Boolean!) {
- ciCdSettingsUpdate(input: { fullPath: $fullPath, keepLatestArtifact: $keepLatestArtifact }) {
+ projectCiCdSettingsUpdate(
+ input: { fullPath: $fullPath, keepLatestArtifact: $keepLatestArtifact }
+ ) {
errors
}
}
diff --git a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
index d797469dd53..8e7ccb80784 100644
--- a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
+++ b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
@@ -75,7 +75,7 @@ export default {
},
});
- if (data.ciCdSettingsUpdate.errors.length) {
+ if (data.projectCiCdSettingsUpdate.errors.length) {
this.reportError(this.$options.errors.updateError);
}
} catch (error) {
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js
index 52ed67b8c7b..29dcab9ed4d 100644
--- a/app/assets/javascripts/authentication/mount_2fa.js
+++ b/app/assets/javascripts/authentication/mount_2fa.js
@@ -1,29 +1,9 @@
-import $ from 'jquery';
-import initU2F from './u2f';
-import U2FRegister from './u2f/register';
-import initWebauthn from './webauthn';
-import WebAuthnRegister from './webauthn/register';
+import { initWebauthnAuthenticate, initWebauthnRegister } from './webauthn';
export const mount2faAuthentication = () => {
- if (gon.webauthn) {
- initWebauthn();
- } else {
- initU2F();
- }
+ initWebauthnAuthenticate();
};
export const mount2faRegistration = () => {
- const el = $('#js-register-token-2fa');
-
- if (!el.length) {
- return;
- }
-
- if (gon.webauthn) {
- const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
- webauthnRegister.start();
- } else {
- const u2fRegister = new U2FRegister(el, gon.u2f);
- u2fRegister.start();
- }
+ initWebauthnRegister();
};
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..fa9a7782b74
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/components/password_input.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { SHOW_PASSWORD, HIDE_PASSWORD } from '../constants';
+
+export default {
+ name: 'PasswordInput',
+ components: {
+ GlFormInput,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ id: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ minimumPasswordLength: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ qaSelector: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ testid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ autocomplete: {
+ type: String,
+ required: false,
+ default: 'current-password',
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMasked: true,
+ };
+ },
+ computed: {
+ type() {
+ return this.isMasked ? 'password' : 'text';
+ },
+ toggleVisibilityLabel() {
+ return this.isMasked ? SHOW_PASSWORD : HIDE_PASSWORD;
+ },
+ 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="id"
+ class="js-password-complexity-validation gl-pr-8!"
+ required
+ :autocomplete="autocomplete"
+ :name="name"
+ :minlength="minimumPasswordLength"
+ :data-qa-selector="qaSelector"
+ :data-testid="testid"
+ :title="title"
+ :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..da617877aec
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/constants.js
@@ -0,0 +1,4 @@
+import { __ } from '~/locale';
+
+export const SHOW_PASSWORD = __('Show password');
+export const HIDE_PASSWORD = __('Hide password');
diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js
new file mode 100644
index 00000000000..a4f2d038cf7
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import GlFieldErrors from '~/gl_field_errors';
+import PasswordInput from './components/password_input.vue';
+
+export const initPasswordInput = () => {
+ document.querySelectorAll('.js-password').forEach((el) => {
+ if (!el) {
+ return null;
+ }
+
+ const { form } = el;
+ const { title, id, minimumPasswordLength, qaSelector, testid, autocomplete, name } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'PasswordInputRoot',
+ render(createElement) {
+ return createElement(PasswordInput, {
+ props: {
+ title,
+ id,
+ minimumPasswordLength,
+ qaSelector,
+ testid,
+ autocomplete,
+ name,
+ },
+ });
+ },
+ });
+
+ // 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 484c6524d0e..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
@@ -6,10 +6,7 @@ import { __ } from '~/locale';
export const i18n = {
currentPassword: __('Current password'),
confirmTitle: __('Are you sure?'),
- confirmWebAuthn: __(
- 'This will invalidate your registered applications and U2F / WebAuthn devices.',
- ),
- confirm: __('This will invalidate your registered applications and U2F devices.'),
+ confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'),
disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
@@ -43,7 +40,6 @@ export default {
GlModal,
},
inject: [
- 'webauthnEnabled',
'isCurrentPasswordRequired',
'profileTwoFactorAuthPath',
'profileTwoFactorAuthMethod',
@@ -61,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/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index 9cf41750efe..f23a6fbcaa0 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
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/authentication/u2f/authenticate.js b/app/assets/javascripts/authentication/u2f/authenticate.js
deleted file mode 100644
index 22eca904f32..00000000000
--- a/app/assets/javascripts/authentication/u2f/authenticate.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import $ from 'jquery';
-import { template as lodashTemplate, omit } from 'lodash';
-import U2FError from './error';
-import importU2FLibrary from './util';
-
-// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
-//
-// State Flow #1: setup -> in_progress -> authenticated -> POST to server
-// State Flow #2: setup -> in_progress -> error -> setup
-export default class U2FAuthenticate {
- constructor(container, form, u2fParams, fallbackButton, fallbackUI) {
- this.u2fUtils = null;
- this.container = container;
- this.renderAuthenticated = this.renderAuthenticated.bind(this);
- this.renderError = this.renderError.bind(this);
- this.renderInProgress = this.renderInProgress.bind(this);
- this.renderTemplate = this.renderTemplate.bind(this);
- this.authenticate = this.authenticate.bind(this);
- this.start = this.start.bind(this);
- this.appId = u2fParams.app_id;
- this.challenge = u2fParams.challenge;
- this.form = form;
- this.fallbackButton = fallbackButton;
- this.fallbackUI = fallbackUI;
- if (this.fallbackButton) {
- this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
- }
-
- // The U2F Javascript API v1.1 requires a single challenge, with
- // _no challenges per-request_. The U2F Javascript API v1.0 requires a
- // challenge per-request, which is done by copying the single challenge
- // into every request.
- //
- // In either case, we don't need the per-request challenges that the server
- // has generated, so we can remove them.
- //
- // Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
- // This can be removed once we upgrade.
- // https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4
- this.signRequests = u2fParams.sign_requests.map((request) => omit(request, 'challenge'));
-
- this.templates = {
- inProgress: '#js-authenticate-token-2fa-in-progress',
- error: '#js-authenticate-token-2fa-error',
- authenticated: '#js-authenticate-token-2fa-authenticated',
- };
- }
-
- start() {
- return importU2FLibrary()
- .then((utils) => {
- this.u2fUtils = utils;
- this.renderInProgress();
- })
- .catch(() => this.switchToFallbackUI());
- }
-
- authenticate() {
- return this.u2fUtils.sign(
- this.appId,
- this.challenge,
- this.signRequests,
- (response) => {
- if (response.errorCode) {
- const error = new U2FError(response.errorCode, 'authenticate');
- return this.renderError(error);
- }
- return this.renderAuthenticated(JSON.stringify(response));
- },
- 10,
- );
- }
-
- renderTemplate(name, params) {
- const templateString = $(this.templates[name]).html();
- const template = lodashTemplate(templateString);
- return this.container.html(template(params));
- }
-
- renderInProgress() {
- this.renderTemplate('inProgress');
- return this.authenticate();
- }
-
- renderError(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_name: error.errorCode,
- });
- return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress);
- }
-
- renderAuthenticated(deviceResponse) {
- this.renderTemplate('authenticated');
- const container = this.container[0];
- container.querySelector('#js-device-response').value = deviceResponse;
- container.querySelector(this.form).submit();
- this.fallbackButton.classList.add('hidden');
- }
-
- switchToFallbackUI() {
- this.fallbackButton.classList.add('hidden');
- this.container[0].classList.add('hidden');
- this.fallbackUI.classList.remove('hidden');
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/error.js b/app/assets/javascripts/authentication/u2f/error.js
deleted file mode 100644
index ca0fc0700ad..00000000000
--- a/app/assets/javascripts/authentication/u2f/error.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { __ } from '~/locale';
-
-export default class U2FError {
- constructor(errorCode, u2fFlowType) {
- this.errorCode = errorCode;
- this.message = this.message.bind(this);
- this.httpsDisabled = window.location.protocol !== 'https:';
- this.u2fFlowType = u2fFlowType;
- }
-
- message() {
- if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
- return __(
- 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.',
- );
- } else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) {
- if (this.u2fFlowType === 'authenticate') {
- return __('This device has not been registered with us.');
- }
- if (this.u2fFlowType === 'register') {
- return __('This device has already been registered with us.');
- }
- }
- return __('There was a problem communicating with your device.');
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/index.js b/app/assets/javascripts/authentication/u2f/index.js
deleted file mode 100644
index f129acca1c3..00000000000
--- a/app/assets/javascripts/authentication/u2f/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import $ from 'jquery';
-import U2FAuthenticate from './authenticate';
-
-export default () => {
- if (!gon.u2f) return;
-
- const u2fAuthenticate = new U2FAuthenticate(
- $('#js-authenticate-token-2fa'),
- '#js-login-token-2fa-form',
- gon.u2f,
- document.querySelector('#js-login-2fa-device'),
- document.querySelector('.js-2fa-form'),
- );
- u2fAuthenticate.start();
- // needed in rspec (FakeU2fDevice)
- gl.u2fAuthenticate = u2fAuthenticate;
-};
diff --git a/app/assets/javascripts/authentication/u2f/register.js b/app/assets/javascripts/authentication/u2f/register.js
deleted file mode 100644
index 6c98f0978bc..00000000000
--- a/app/assets/javascripts/authentication/u2f/register.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import $ from 'jquery';
-import { template as lodashTemplate } from 'lodash';
-import { __ } from '~/locale';
-import U2FError from './error';
-import importU2FLibrary from './util';
-
-// Register U2F (universal 2nd factor) devices for users to authenticate with.
-//
-// State Flow #1: setup -> in_progress -> registered -> POST to server
-// State Flow #2: setup -> in_progress -> error -> setup
-export default class U2FRegister {
- constructor(container, u2fParams) {
- this.u2fUtils = null;
- this.container = container;
- this.renderNotSupported = this.renderNotSupported.bind(this);
- this.renderRegistered = this.renderRegistered.bind(this);
- this.renderError = this.renderError.bind(this);
- this.renderInProgress = this.renderInProgress.bind(this);
- this.renderSetup = this.renderSetup.bind(this);
- this.renderTemplate = this.renderTemplate.bind(this);
- this.register = this.register.bind(this);
- this.start = this.start.bind(this);
- this.appId = u2fParams.app_id;
- this.registerRequests = u2fParams.register_requests;
- this.signRequests = u2fParams.sign_requests;
-
- this.templates = {
- message: '#js-register-2fa-message',
- setup: '#js-register-token-2fa-setup',
- error: '#js-register-token-2fa-error',
- registered: '#js-register-token-2fa-registered',
- };
- }
-
- start() {
- return importU2FLibrary()
- .then((utils) => {
- this.u2fUtils = utils;
- this.renderSetup();
- })
- .catch(() => this.renderNotSupported());
- }
-
- register() {
- return this.u2fUtils.register(
- this.appId,
- this.registerRequests,
- this.signRequests,
- (response) => {
- if (response.errorCode) {
- const error = new U2FError(response.errorCode, 'register');
- return this.renderError(error);
- }
- return this.renderRegistered(JSON.stringify(response));
- },
- 10,
- );
- }
-
- renderTemplate(name, params) {
- const templateString = $(this.templates[name]).html();
- const template = lodashTemplate(templateString);
- return this.container.html(template(params));
- }
-
- renderSetup() {
- this.renderTemplate('setup');
- return this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
- }
-
- renderInProgress() {
- this.renderTemplate('message', {
- message: __(
- 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
- ),
- });
- return this.register();
- }
-
- renderError(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_name: error.errorCode,
- });
- return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup);
- }
-
- renderRegistered(deviceResponse) {
- this.renderTemplate('registered');
- // Prefer to do this instead of interpolating using Underscore templates
- // because of JSON escaping issues.
- return this.container.find('#js-device-response').val(deviceResponse);
- }
-
- renderNotSupported() {
- return this.renderTemplate('message', {
- message: __(
- "Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).",
- ),
- });
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/util.js b/app/assets/javascripts/authentication/u2f/util.js
deleted file mode 100644
index b706481c02f..00000000000
--- a/app/assets/javascripts/authentication/u2f/util.js
+++ /dev/null
@@ -1,40 +0,0 @@
-function isOpera(userAgent) {
- return userAgent.indexOf('Opera') >= 0 || userAgent.indexOf('OPR') >= 0;
-}
-
-function getOperaVersion(userAgent) {
- const match = userAgent.match(/OPR[^0-9]*([0-9]+)[^0-9]+/);
- return match ? parseInt(match[1], 10) : false;
-}
-
-function isChrome(userAgent) {
- return userAgent.indexOf('Chrom') >= 0 && !isOpera(userAgent);
-}
-
-function getChromeVersion(userAgent) {
- const match = userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
- return match ? parseInt(match[1], 10) : false;
-}
-
-export function canInjectU2fApi(userAgent) {
- const isSupportedChrome = isChrome(userAgent) && getChromeVersion(userAgent) >= 41;
- const isSupportedOpera = isOpera(userAgent) && getOperaVersion(userAgent) >= 40;
- const isMobile =
- userAgent.indexOf('droid') >= 0 ||
- userAgent.indexOf('CriOS') >= 0 ||
- /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
- return (isSupportedChrome || isSupportedOpera) && !isMobile;
-}
-
-export default function importU2FLibrary() {
- if (window.u2f) {
- return Promise.resolve(window.u2f);
- }
-
- const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
- if (canInjectU2fApi(userAgent) || (gon && gon.test_env)) {
- return import(/* webpackMode: "eager" */ 'vendor/u2f').then(() => window.u2f);
- }
-
- return Promise.reject();
-}
diff --git a/app/assets/javascripts/authentication/webauthn/authenticate.js b/app/assets/javascripts/authentication/webauthn/authenticate.js
index 47cb7a40f76..748945a680b 100644
--- a/app/assets/javascripts/authentication/webauthn/authenticate.js
+++ b/app/assets/javascripts/authentication/webauthn/authenticate.js
@@ -1,3 +1,4 @@
+import { WEBAUTHN_AUTHENTICATE } from './constants';
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, convertGetParams, convertGetResponse } from './util';
@@ -44,7 +45,7 @@ export default class WebAuthnAuthenticate {
this.renderAuthenticated(JSON.stringify(convertedResponse));
})
.catch((err) => {
- this.flow.renderError(new WebAuthnError(err, 'authenticate'));
+ this.flow.renderError(new WebAuthnError(err, WEBAUTHN_AUTHENTICATE));
});
}
diff --git a/app/assets/javascripts/authentication/webauthn/components/registration.vue b/app/assets/javascripts/authentication/webauthn/components/registration.vue
new file mode 100644
index 00000000000..84132a7d062
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/components/registration.vue
@@ -0,0 +1,226 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+} from '@gitlab/ui';
+import {
+ I18N_BUTTON_REGISTER,
+ I18N_BUTTON_SETUP,
+ I18N_BUTTON_TRY_AGAIN,
+ I18N_DEVICE_NAME,
+ I18N_DEVICE_NAME_DESCRIPTION,
+ I18N_DEVICE_NAME_PLACEHOLDER,
+ I18N_ERROR_HTTP,
+ I18N_ERROR_UNSUPPORTED_BROWSER,
+ I18N_INFO_TEXT,
+ I18N_NOTICE,
+ I18N_PASSWORD,
+ I18N_PASSWORD_DESCRIPTION,
+ I18N_STATUS_SUCCESS,
+ I18N_STATUS_WAITING,
+ STATE_ERROR,
+ STATE_READY,
+ STATE_SUCCESS,
+ STATE_UNSUPPORTED,
+ STATE_WAITING,
+ WEBAUTHN_DOCUMENTATION_PATH,
+ WEBAUTHN_REGISTER,
+} from '~/authentication/webauthn/constants';
+import WebAuthnError from '~/authentication/webauthn/error';
+import {
+ convertCreateParams,
+ convertCreateResponse,
+ isHTTPS,
+ supported,
+} from '~/authentication/webauthn/util';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ name: 'WebAuthnRegistration',
+ components: {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ I18N_BUTTON_REGISTER,
+ I18N_BUTTON_SETUP,
+ I18N_BUTTON_TRY_AGAIN,
+ I18N_DEVICE_NAME,
+ I18N_DEVICE_NAME_DESCRIPTION,
+ I18N_DEVICE_NAME_PLACEHOLDER,
+ I18N_ERROR_HTTP,
+ I18N_ERROR_UNSUPPORTED_BROWSER,
+ I18N_INFO_TEXT,
+ I18N_NOTICE,
+ I18N_PASSWORD,
+ I18N_PASSWORD_DESCRIPTION,
+ I18N_STATUS_SUCCESS,
+ I18N_STATUS_WAITING,
+ STATE_ERROR,
+ STATE_READY,
+ STATE_SUCCESS,
+ STATE_UNSUPPORTED,
+ STATE_WAITING,
+ WEBAUTHN_DOCUMENTATION_PATH,
+ inject: ['initialError', 'passwordRequired', 'targetPath'],
+ data() {
+ return {
+ csrfToken: csrf.token,
+ form: { deviceName: '', password: '' },
+ state: STATE_UNSUPPORTED,
+ errorMessage: this.initialError,
+ credentials: null,
+ };
+ },
+ computed: {
+ disabled() {
+ const isEmptyDeviceName = this.form.deviceName.trim() === '';
+ const isEmptyPassword = this.form.password.trim() === '';
+
+ if (this.passwordRequired === false) {
+ return isEmptyDeviceName;
+ }
+
+ return isEmptyDeviceName || isEmptyPassword;
+ },
+ },
+ created() {
+ if (this.errorMessage) {
+ this.state = STATE_ERROR;
+ return;
+ }
+
+ if (supported()) {
+ this.state = STATE_READY;
+ return;
+ }
+
+ this.errorMessage = isHTTPS() ? I18N_ERROR_UNSUPPORTED_BROWSER : I18N_ERROR_HTTP;
+ },
+ methods: {
+ isCurrentState(state) {
+ return this.state === state;
+ },
+ async onRegister() {
+ this.state = STATE_WAITING;
+
+ try {
+ const credentials = await navigator.credentials.create({
+ publicKey: convertCreateParams(gon.webauthn.options),
+ });
+
+ this.credentials = JSON.stringify(convertCreateResponse(credentials));
+ this.state = STATE_SUCCESS;
+ } catch (error) {
+ this.errorMessage = new WebAuthnError(error, WEBAUTHN_REGISTER).message();
+ this.state = STATE_ERROR;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="isCurrentState($options.STATE_UNSUPPORTED)">
+ <gl-alert variant="danger" :dismissible="false">{{ errorMessage }}</gl-alert>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_READY)">
+ <div class="row">
+ <div class="col-md-5">
+ <gl-button variant="confirm" @click="onRegister">{{
+ $options.I18N_BUTTON_SETUP
+ }}</gl-button>
+ </div>
+ <div class="col-md-7">
+ <p>{{ $options.I18N_INFO_TEXT }}</p>
+ </div>
+ </div>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_WAITING)">
+ <gl-alert :dismissible="false">
+ {{ $options.I18N_STATUS_WAITING }}
+ <gl-loading-icon />
+ </gl-alert>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_SUCCESS)">
+ <p>{{ $options.I18N_STATUS_SUCCESS }}</p>
+ <gl-alert :dismissible="false" class="gl-mb-5">
+ <gl-sprintf :message="$options.I18N_NOTICE">
+ <template #link="{ content }">
+ <gl-link :href="$options.WEBAUTHN_DOCUMENTATION_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <div class="row">
+ <gl-form method="post" :action="targetPath" class="col-md-9" data-testid="create-webauthn">
+ <gl-form-group
+ v-if="passwordRequired"
+ :description="$options.I18N_PASSWORD_DESCRIPTION"
+ :label="$options.I18N_PASSWORD"
+ label-for="webauthn-registration-current-password"
+ >
+ <gl-form-input
+ id="webauthn-registration-current-password"
+ v-model="form.password"
+ name="current_password"
+ type="password"
+ autocomplete="current-password"
+ data-testid="current-password-input"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :description="$options.I18N_DEVICE_NAME_DESCRIPTION"
+ :label="$options.I18N_DEVICE_NAME"
+ label-for="device-name"
+ >
+ <gl-form-input
+ id="device-name"
+ v-model="form.deviceName"
+ name="device_registration[name]"
+ :placeholder="$options.I18N_DEVICE_NAME_PLACEHOLDER"
+ data-testid="device-name-input"
+ />
+ </gl-form-group>
+
+ <input type="hidden" name="device_registration[device_response]" :value="credentials" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+
+ <gl-button type="submit" :disabled="disabled" variant="confirm">{{
+ $options.I18N_BUTTON_REGISTER
+ }}</gl-button>
+ </gl-form>
+ </div>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_ERROR)">
+ <gl-alert
+ variant="danger"
+ :dismissible="false"
+ class="gl-mb-5"
+ :secondary-button-text="$options.I18N_BUTTON_TRY_AGAIN"
+ @secondaryAction="onRegister"
+ >
+ {{ errorMessage }}
+ </gl-alert>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/webauthn/constants.js b/app/assets/javascripts/authentication/webauthn/constants.js
new file mode 100644
index 00000000000..c41e6d2bd58
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/constants.js
@@ -0,0 +1,46 @@
+import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const I18N_BUTTON_REGISTER = __('Register device');
+export const I18N_BUTTON_SETUP = __('Set up new device');
+export const I18N_BUTTON_TRY_AGAIN = __('Try again?');
+export const I18N_DEVICE_NAME = __('Device name');
+export const I18N_DEVICE_NAME_DESCRIPTION = __(
+ 'Excluding USB security keys, you should include the browser name together with the device name.',
+);
+export const I18N_DEVICE_NAME_PLACEHOLDER = __('Macbook Touch ID on Edge');
+export const I18N_ERROR_HTTP = __(
+ 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
+);
+export const I18N_ERROR_UNSUPPORTED_BROWSER = __(
+ "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).",
+);
+export const I18N_INFO_TEXT = __(
+ 'Your device needs to be set up. Plug it in (if needed) and click the button on the left.',
+);
+export const I18N_NOTICE = __(
+ 'You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}',
+);
+export const I18N_PASSWORD = __('Current password');
+export const I18N_PASSWORD_DESCRIPTION = __(
+ 'Your current password is required to register a new device.',
+);
+export const I18N_STATUS_SUCCESS = __(
+ 'Your device was successfully set up! Give it a name and register it with the GitLab server.',
+);
+export const I18N_STATUS_WAITING = __(
+ 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
+);
+
+export const STATE_ERROR = 'error';
+export const STATE_READY = 'ready';
+export const STATE_SUCCESS = 'success';
+export const STATE_UNSUPPORTED = 'unsupported';
+export const STATE_WAITING = 'waiting';
+
+export const WEBAUTHN_AUTHENTICATE = 'authenticate';
+export const WEBAUTHN_REGISTER = 'register';
+export const WEBAUTHN_DOCUMENTATION_PATH = helpPagePath(
+ 'user/profile/account/two_factor_authentication',
+ { anchor: 'set-up-a-webauthn-device' },
+);
diff --git a/app/assets/javascripts/authentication/webauthn/error.js b/app/assets/javascripts/authentication/webauthn/error.js
index a1a3f861c25..40dbecd8bc9 100644
--- a/app/assets/javascripts/authentication/webauthn/error.js
+++ b/app/assets/javascripts/authentication/webauthn/error.js
@@ -1,5 +1,6 @@
import { __ } from '~/locale';
-import { isHTTPS, FLOW_AUTHENTICATE, FLOW_REGISTER } from './util';
+import { WEBAUTHN_AUTHENTICATE, WEBAUTHN_REGISTER } from './constants';
+import { isHTTPS } from './util';
export default class WebAuthnError {
constructor(error, flowType) {
@@ -13,9 +14,9 @@ export default class WebAuthnError {
message() {
if (this.errorName === 'NotSupportedError') {
return __('Your device is not compatible with GitLab. Please try another device');
- } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_AUTHENTICATE) {
+ } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_AUTHENTICATE) {
return __('This device has not been registered with us.');
- } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_REGISTER) {
+ } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_REGISTER) {
return __('This device has already been registered with us.');
} else if (this.errorName === 'SecurityError' && this.httpsDisabled) {
return __(
diff --git a/app/assets/javascripts/authentication/webauthn/index.js b/app/assets/javascripts/authentication/webauthn/index.js
index bbf694c7698..1fbe89d1097 100644
--- a/app/assets/javascripts/authentication/webauthn/index.js
+++ b/app/assets/javascripts/authentication/webauthn/index.js
@@ -1,7 +1,12 @@
import $ from 'jquery';
import WebAuthnAuthenticate from './authenticate';
+import WebAuthnRegister from './register';
+
+export const initWebauthnAuthenticate = () => {
+ if (!gon.webauthn) {
+ return;
+ }
-export default () => {
const webauthnAuthenticate = new WebAuthnAuthenticate(
$('#js-authenticate-token-2fa'),
'#js-login-token-2fa-form',
@@ -11,3 +16,14 @@ export default () => {
);
webauthnAuthenticate.start();
};
+
+export const initWebauthnRegister = () => {
+ const el = $('#js-register-token-2fa');
+
+ if (!el.length) {
+ return;
+ }
+
+ const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
+ webauthnRegister.start();
+};
diff --git a/app/assets/javascripts/authentication/webauthn/register.js b/app/assets/javascripts/authentication/webauthn/register.js
index 62ebf85abe4..c00d3ede2c1 100644
--- a/app/assets/javascripts/authentication/webauthn/register.js
+++ b/app/assets/javascripts/authentication/webauthn/register.js
@@ -2,6 +2,7 @@ import { __ } from '~/locale';
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, isHTTPS, convertCreateParams, convertCreateResponse } from './util';
+import { WEBAUTHN_REGISTER } from './constants';
// Register WebAuthn devices for users to authenticate with.
//
@@ -40,7 +41,7 @@ export default class WebAuthnRegister {
publicKey: this.webauthnOptions,
})
.then((cred) => this.renderRegistered(JSON.stringify(convertCreateResponse(cred))))
- .catch((err) => this.flow.renderError(new WebAuthnError(err, 'register')));
+ .catch((err) => this.flow.renderError(new WebAuthnError(err, WEBAUTHN_REGISTER)));
}
renderSetup() {
diff --git a/app/assets/javascripts/authentication/webauthn/registration.js b/app/assets/javascripts/authentication/webauthn/registration.js
new file mode 100644
index 00000000000..67906a24857
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/registration.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import WebAuthnRegistration from '~/authentication/webauthn/components/registration.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export const initWebAuthnRegistration = () => {
+ const el = document.querySelector('#js-device-registration');
+
+ if (!el) {
+ return null;
+ }
+
+ const { initialError, passwordRequired, targetPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'WebAuthnRegistrationRoot',
+ provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath },
+ render(h) {
+ return h(WebAuthnRegistration);
+ },
+ });
+};
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index 2a0740cf488..0ff0f0e6a29 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -1,9 +1,6 @@
export function supported() {
return Boolean(
- navigator.credentials &&
- navigator.credentials.create &&
- navigator.credentials.get &&
- window.PublicKeyCredential,
+ navigator.credentials?.create && navigator.credentials?.get && window.PublicKeyCredential,
);
}
@@ -11,9 +8,6 @@ export function isHTTPS() {
return window.location.protocol.startsWith('https');
}
-export const FLOW_AUTHENTICATE = 'authenticate';
-export const FLOW_REGISTER = 'register';
-
/**
* Converts a base64 string to an ArrayBuffer
*
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 1855fb9ed8c..de67e01d650 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -7,7 +7,7 @@ import { getEmojiScoreWithIntent } from '~/emoji/utils';
import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
import { dispose, fixTitle } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index c95c90d5daf..1a80030c7e6 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -3,7 +3,7 @@ import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import { escape, debounce } from 'lodash';
import { mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { s__, sprintf } from '~/locale';
import createEmptyBadge from '../empty_badge';
import { PLACEHOLDERS } from '../constants';
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index a7a21d65475..09f997d73aa 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { __, s__ } from '~/locale';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
@@ -26,7 +26,7 @@ export default {
primaryProps() {
return {
text: __('Delete badge'),
- attributes: [{ category: 'primary' }, { variant: 'danger' }],
+ attributes: { category: 'primary', variant: 'danger' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index cc524c71c1e..b78874d372c 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NoteableNote from '~/notes/components/noteable_note.vue';
@@ -11,6 +11,7 @@ export default {
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
props: {
draft: {
@@ -95,7 +96,15 @@ export default {
@mouseleave.native="handleMouseLeave(draft)"
>
<template #note-header-info>
- <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge>
+ <gl-badge
+ v-gl-tooltip
+ variant="warning"
+ size="sm"
+ class="gl-mr-2"
+ :title="__('Pending comments are hidden until you submit your review.')"
+ >
+ {{ __('Pending') }}
+ </gl-badge>
</template>
<template v-if="!isEditingDraft" #after-note-body>
<div
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 4ac0c8c4894..ca9cb03ca37 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -55,21 +55,25 @@ export default {
<template>
<gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown">
<template #toggle>
- <gl-button
- >{{ __('Pending comments') }} <drafts-count variant="neutral" /><gl-icon
- class="dropdown-chevron"
- name="chevron-up"
- /></gl-button>
+ <gl-button>
+ {{ __('Pending comments') }}
+ <drafts-count variant="neutral" />
+ <gl-icon class="dropdown-chevron" name="chevron-up" />
+ </gl-button>
</template>
<template #header>
- <p class="gl-dropdown-header-top">
- {{ n__('%d pending comment', '%d pending comments', draftsCount) }}
- </p>
+ <div
+ class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"
+ >
+ <span class="gl-flex-grow-1 gl-font-weight-bold gl-font-sm gl-pr-2">
+ {{ n__('%d pending comment', '%d pending comments', draftsCount) }}
+ </span>
+ </div>
</template>
<template #list-item="{ item }">
- <preview-item :draft="item" :is-last="item.last" @click="onClickDraft(item)" />
+ <preview-item :draft="item" :is-last="item.last" />
</template>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index ed0481e7a48..d2db61e096a 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 { mapGetters, mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { mapGetters, mapActions, mapState } 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'),
@@ -41,6 +31,7 @@ export default {
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
+ ...mapState('batchComments', ['shouldAnimateReviewButton']),
},
watch: {
'noteData.approve': function noteDataApproveWatch() {
@@ -102,9 +93,6 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
- helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
- anchor: 'submit-a-review',
- }),
};
</script>
@@ -114,6 +102,7 @@ export default {
right
dropup
class="submit-review-dropdown"
+ :class="{ 'submit-review-dropdown-animated': shouldAnimateReviewButton }"
data-qa-selector="submit_review_dropdown"
variant="info"
category="primary"
@@ -126,19 +115,9 @@ 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
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
- >
+ <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden">
<markdown-field
:is-submitting="isSubmitting"
:add-spacing-classes="false"
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 65fd34dcb00..bf9769ff359 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -1,17 +1,28 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import store from '~/mr_notes/stores';
-export const initReviewBar = () => {
+export const initReviewBar = ({ editorAiActions = [] } = {}) => {
const el = document.getElementById('js-review-bar');
+ if (!el) return;
+
+ Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el,
store,
+ apolloProvider,
components: {
ReviewBar: () => import('./components/review_bar.vue'),
},
+ provide: {
+ newCommentTemplatePath: el.dataset.newCommentTemplatePath,
+ editorAiActions,
+ },
computed: {
...mapGetters('batchComments', ['draftsCount']),
},
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index feac6f10b1e..f6eae7c0c83 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,5 +1,5 @@
import { isEmpty } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
@@ -167,3 +167,5 @@ export const expandAllDiscussions = ({ dispatch, state }) =>
export const toggleResolveDiscussion = ({ commit }, draftId) => {
commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId);
};
+
+export const clearDrafts = ({ commit }) => commit(types.CLEAR_DRAFTS);
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
index df523a692d3..67bcc53ac7d 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -14,3 +14,5 @@ export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
+
+export const CLEAR_DRAFTS = 'CLEAR_DRAFTS';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
index dabfe864575..7961cf134be 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -8,6 +8,9 @@ const processDraft = (draft) => ({
export default {
[types.ADD_NEW_DRAFT](state, draft) {
state.drafts.push(processDraft(draft));
+ if (state.drafts.length === 1) {
+ state.shouldAnimateReviewButton = true;
+ }
},
[types.DELETE_DRAFT](state, draftId) {
@@ -62,4 +65,7 @@ export default {
return draft;
});
},
+ [types.CLEAR_DRAFTS](state) {
+ state.drafts = [];
+ },
};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
index 6b97fc242c8..10033ba17f9 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -4,4 +4,5 @@ export default () => ({
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
+ shouldAnimateReviewButton: false,
});
diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue
index bb38d80c1b5..9cbaa02f270 100644
--- a/app/assets/javascripts/behaviors/components/json_table.vue
+++ b/app/assets/javascripts/behaviors/components/json_table.vue
@@ -42,6 +42,7 @@ export default {
key: field.key,
label: field.label,
sortable: field.sortable || false,
+ class: field.class || [],
};
});
},
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index 970864eef74..218a402772f 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -5,6 +5,8 @@ import { setAttributes } from '~/lib/utils/dom_utils';
class CopyCodeButton extends HTMLElement {
connectedCallback() {
+ if (this.querySelector('.btn')) return;
+
this.for = uniqueId('code-');
const target = this.parentNode.querySelector('pre');
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 4b337dce8f3..834defe336b 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -10,10 +10,10 @@ const CLIPBOARD_ERROR_EVENT = 'clipboard-error';
const I18N_ERROR_MESSAGE = __('Copy failed. Please manually copy the value.');
function showTooltip(target, title) {
- const { title: originalTitle } = target.dataset;
+ const { originalTitle } = target.dataset;
once('hidden', (tooltip) => {
- if (tooltip.target === target) {
+ if (originalTitle && tooltip.target === target) {
target.setAttribute('title', originalTitle);
target.setAttribute('aria-label', originalTitle);
fixTitle(target);
diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js
index efd89ec4330..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),
@@ -27,7 +27,10 @@ export default function initDatePickers() {
$('.js-clear-due-date,.js-clear-start-date').on('click', (e) => {
e.preventDefault();
- const calendar = $(e.target).siblings('.datepicker').data('pikaday');
+ const calendar = $(e.target)
+ .siblings('.issuable-form-select-holder')
+ .children('.datepicker')
+ .data('pikaday');
calendar.setDate(null);
});
}
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 19ebab36481..36317444af9 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -164,7 +164,7 @@ export class CopyAsGFM {
static nodeToGFM(node) {
return Promise.all([
- import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ '@tiptap/pm/model'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
])
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 04b3599ea8c..39a7a76e91f 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -39,7 +39,7 @@ export function renderGFM(element) {
'.js-render-mermaid',
'[lang="json"][data-lang-params="table"]',
'.gfm-project_member',
- '.gfm-issue, .gfm-merge_request',
+ '.gfm-issue, .gfm-work_item, .gfm-merge_request',
'.js-render-metrics',
'.js-render-observability',
].map((selector) => Array.from(element.querySelectorAll(selector)));
@@ -50,7 +50,9 @@ export function renderGFM(element) {
renderSandboxedMermaid(mermaidEls);
renderJSONTable(tableEls.map((e) => e.parentNode));
highlightCurrentUser(userEls);
- renderMetrics(metricsEls);
+ if (!window.gon?.features?.removeMonitorMetrics) {
+ renderMetrics(metricsEls);
+ }
renderObservability(observabilityEls);
initPopovers(popoverEls);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js
index 4d9ac1d266b..aa0e7d38113 100644
--- a/app/assets/javascripts/behaviors/markdown/render_json_table.js
+++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js
@@ -1,7 +1,7 @@
import { memoize } from 'lodash';
import Vue from 'vue';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
// Async import component since we might not need it...
const JSONTable = memoize(() =>
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/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js
index d5d46c10efd..6346fb8ab48 100644
--- a/app/assets/javascripts/behaviors/markdown/render_observability.js
+++ b/app/assets/javascripts/behaviors/markdown/render_observability.js
@@ -1,46 +1,25 @@
import Vue from 'vue';
-import { darkModeEnabled } from '~/lib/utils/color_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
-
-export function getFrameSrc(url) {
- return `${setUrlParams({ theme: darkModeEnabled() ? 'dark' : 'light' }, url)}&kiosk`;
-}
+import ObservabilityApp from '~/observability/components/observability_app.vue';
+import { SKELETON_VARIANT_EMBED, INLINE_EMBED_DIMENSIONS } from '~/observability/constants';
const mountVueComponent = (element) => {
- const { frameUrl, observabilityUrl } = element.dataset;
-
- try {
- if (
- !observabilityUrl ||
- !frameUrl ||
- new URL(frameUrl)?.host !== new URL(observabilityUrl).host
- )
- return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: element,
- render(h) {
- return h('iframe', {
- style: {
- height: '366px',
- width: '768px',
- },
- attrs: {
- src: getFrameSrc(frameUrl),
- frameBorder: '0',
- },
- });
- },
- });
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error(e);
- }
+ const url = element.dataset.frameUrl;
+ return new Vue({
+ el: element,
+ render(h) {
+ return h(ObservabilityApp, {
+ props: {
+ observabilityIframeSrc: url,
+ inlineEmbed: true,
+ skeletonVariant: SKELETON_VARIANT_EMBED,
+ height: INLINE_EMBED_DIMENSIONS.HEIGHT,
+ width: INLINE_EMBED_DIMENSIONS.WIDTH,
+ },
+ });
+ },
+ });
};
export default function renderObservability(elements) {
- elements.forEach((element) => {
- mountVueComponent(element);
- });
+ return elements.map(mountVueComponent);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 66007aa9e3d..bd9e41ac0ba 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -8,7 +8,7 @@ import {
} from '~/lib/utils/url_utility';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setAttributes, isElementVisible } from '~/lib/utils/dom_utils';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { unrestrictedPages } from './constants';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
index 1b0f46ff4cb..31bab23c8b0 100644
--- a/app/assets/javascripts/behaviors/markdown/schema.js
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -1,4 +1,4 @@
-import { Schema } from 'prosemirror-model';
+import { Schema } from '@tiptap/pm/model';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions.nodes.reduce(
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 32e395e4f3c..bcd92d09033 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -121,9 +121,7 @@ MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form)
const markdownPreview = new MarkdownPreview();
const previewButtonSelector = '.js-md-preview-button';
-const writeButtonSelector = '.js-md-write-button';
lastTextareaPreviewed = null;
-const markdownToolbar = $('.md-header-toolbar');
$(document).on('markdown-preview:show', (e, $form) => {
if (!$form) {
@@ -134,13 +132,15 @@ $(document).on('markdown-preview:show', (e, $form) => {
lastTextareaHeight = lastTextareaPreviewed.height();
// toggle tabs
- $form.find(writeButtonSelector).parent().removeClass('active');
- $form.find(previewButtonSelector).parent().addClass('active');
+ $form.find(previewButtonSelector).val('edit');
+ $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Continue editing'));
+ $form.find(previewButtonSelector).addClass('gl-shadow-none! gl-bg-transparent!');
// toggle content
$form.find('.md-write-holder').hide();
$form.find('.md-preview-holder').show();
- markdownToolbar.removeClass('active');
+ $form.find('.md-header-toolbar, .js-zen-enter').addClass('gl-display-none!');
+
markdownPreview.showPreview($form);
});
@@ -155,14 +155,14 @@ $(document).on('markdown-preview:hide', (e, $form) => {
}
// toggle tabs
- $form.find(writeButtonSelector).parent().addClass('active');
- $form.find(previewButtonSelector).parent().removeClass('active');
+ $form.find(previewButtonSelector).val('preview');
+ $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Preview'));
// toggle content
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
- markdownToolbar.addClass('active');
+ $form.find('.md-header-toolbar, .js-zen-enter').removeClass('gl-display-none!');
markdownPreview.hideReferencedCommands($form);
});
@@ -183,13 +183,26 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
$(document).on('click', previewButtonSelector, function (e) {
e.preventDefault();
const $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:show', [$form]);
+ const eventName = e.currentTarget.getAttribute('value') === 'preview' ? 'show' : 'hide';
+ $(document).triggerHandler(`markdown-preview:${eventName}`, [$form]);
+});
+
+$(document).on('mousedown', previewButtonSelector, function (e) {
+ e.preventDefault();
+ const $form = $(this).closest('form');
+ $form.find(previewButtonSelector).removeClass('gl-shadow-none! gl-bg-transparent!');
+});
+
+$(document).on('mouseenter', previewButtonSelector, function (e) {
+ e.preventDefault();
+ const $form = $(this).closest('form');
+ $form.find(previewButtonSelector).removeClass('gl-bg-transparent!');
});
-$(document).on('click', writeButtonSelector, function (e) {
+$(document).on('mouseleave', previewButtonSelector, function (e) {
e.preventDefault();
const $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:hide', [$form]);
+ $form.find(previewButtonSelector).addClass('gl-bg-transparent!');
});
export default MarkdownPreview;
diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js
index 12fdb2e2981..22a8be92e52 100644
--- a/app/assets/javascripts/behaviors/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts.js
@@ -26,12 +26,10 @@ export default function initPageShortcuts() {
// the pages above have their own shortcuts sub-classes instantiated elsewhere
// TODO: replace this whitelist with something more automated/maintainable
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/392845
if (page && !pagesWithCustomShortcuts.includes(page)) {
import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts')
- .then(({ default: Shortcuts }) => {
- const shortcuts = new Shortcuts();
- window.toggleShortcutsHelp = shortcuts.onToggleHelp;
- })
+ .then(({ default: Shortcuts }) => new Shortcuts())
.catch(() => {});
}
return false;
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 38ee02938cc..a88cc1834ac 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -2,8 +2,11 @@ import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { __ } from '~/locale';
-const isCustomizable = (command) =>
- 'customizable' in command ? Boolean(command.customizable) : true;
+/**
+ * @param {object} command
+ * @param {boolean} [command.customizable]
+ */
+const isCustomizable = ({ customizable }) => Boolean(customizable ?? true);
export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
@@ -117,6 +120,12 @@ export const HIDE_APPEARING_CONTENT = {
defaultKeys: ['esc'],
};
+export const TOGGLE_SUPER_SIDEBAR = {
+ id: 'globalShortcuts.toggleSuperSidebar',
+ description: __('Toggle the navigation sidebar'),
+ defaultKeys: ['mod+\\'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const TOGGLE_CANARY = {
id: 'globalShortcuts.toggleCanary',
description: __('Toggle GitLab Next'),
@@ -177,7 +186,10 @@ export const TOGGLE_MARKDOWN_PREVIEW = {
defaultKeys: ['ctrl+shift+p', 'command+shift+p'],
};
-export const EDIT_RECENT_COMMENT = {
+/**
+ * @keydown.up event is handled here: https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/notes/components/comment_form.vue#L379
+ */
+const EDIT_RECENT_COMMENT = {
id: 'editing.editRecentComment',
description: __('Edit your most recent comment in a thread (from an empty textarea)'),
defaultKeys: ['up'],
@@ -228,7 +240,7 @@ export const REPO_GRAPH_SCROLL_BOTTOM = {
export const GO_TO_PROJECT_OVERVIEW = {
id: 'project.goToOverview',
description: __("Go to the project's overview page"),
- defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+ defaultKeys: ['g o'], // eslint-disable-line @gitlab/require-i18n-strings
};
export const GO_TO_PROJECT_ACTIVITY_FEED = {
@@ -297,6 +309,12 @@ export const GO_TO_PROJECT_MERGE_REQUESTS = {
defaultKeys: ['g m'], // eslint-disable-line @gitlab/require-i18n-strings
};
+export const GO_TO_PROJECT_PIPELINES = {
+ id: 'project.goToPipelines',
+ description: __('Go to pipelines'),
+ defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const GO_TO_PROJECT_JOBS = {
id: 'project.goToJobs',
description: __('Go to jobs'),
@@ -466,13 +484,22 @@ export const ISSUE_CLOSE_DESIGN = {
defaultKeys: ['esc'],
};
-export const WEB_IDE_GO_TO_FILE = {
+/**
+ * Legacy Web IDE uses the same shortcuts as MR_GO_TO_FILE, from this shared component:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/vue_shared/components/file_finder/index.vue#L6
+ */
+const WEB_IDE_GO_TO_FILE = {
id: 'webIDE.goToFile',
description: __('Go to file'),
- defaultKeys: ['mod+p'],
+ defaultKeys: ['mod+p', 't'],
+ customizable: false /* customize MR_GO_TO_FILE instead */,
};
-export const WEB_IDE_COMMIT = {
+/**
+ * Legacy Web IDE uses @keydown.ctrl.enter and @keydown.meta.enter events here:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/ide/components/shared/commit_message_field.vue#L131-132
+ */
+const WEB_IDE_COMMIT = {
id: 'webIDE.commit',
description: __('Commit (when editing commit message)'),
defaultKeys: ['mod+enter'],
@@ -483,39 +510,28 @@ export const METRICS_EXPAND_PANEL = {
id: 'metrics.expandPanel',
description: __('Expand panel'),
defaultKeys: ['e'],
- customizable: false,
-};
-
-export const METRICS_VIEW_LOGS = {
- id: 'metrics.viewLogs',
- description: __('View logs'),
- defaultKeys: ['l'],
- customizable: false,
};
export const METRICS_DOWNLOAD_CSV = {
id: 'metrics.downloadCSV',
description: __('Download CSV'),
defaultKeys: ['d'],
- customizable: false,
};
export const METRICS_COPY_LINK_TO_CHART = {
id: 'metrics.copyLinkToChart',
description: __('Copy link to chart'),
defaultKeys: ['c'],
- customizable: false,
};
export const METRICS_SHOW_ALERTS = {
id: 'metrics.showAlerts',
description: __('Alerts'),
defaultKeys: ['a'],
- customizable: false,
};
// All keybinding groups
-export const GLOBAL_SHORTCUTS_GROUP = {
+const GLOBAL_SHORTCUTS_GROUP = {
id: 'globalShortcuts',
name: __('Global Shortcuts'),
keybindings: [
@@ -536,6 +552,10 @@ export const GLOBAL_SHORTCUTS_GROUP = {
],
};
+if (gon.use_new_navigation) {
+ GLOBAL_SHORTCUTS_GROUP.keybindings.push(TOGGLE_SUPER_SIDEBAR);
+}
+
export const EDITING_SHORTCUTS_GROUP = {
id: 'editing',
name: __('Editing'),
@@ -549,13 +569,13 @@ export const EDITING_SHORTCUTS_GROUP = {
],
};
-export const WIKI_SHORTCUTS_GROUP = {
+const WIKI_SHORTCUTS_GROUP = {
id: 'wiki',
name: __('Wiki'),
keybindings: [EDIT_WIKI_PAGE],
};
-export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
+const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
id: 'repositoryGraph',
name: __('Repository Graph'),
keybindings: [
@@ -568,7 +588,7 @@ export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
],
};
-export const PROJECT_SHORTCUTS_GROUP = {
+const PROJECT_SHORTCUTS_GROUP = {
id: 'project',
name: __('Project'),
keybindings: [
@@ -584,8 +604,9 @@ export const PROJECT_SHORTCUTS_GROUP = {
NEW_ISSUE,
GO_TO_PROJECT_ISSUE_BOARDS,
GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_PIPELINES,
GO_TO_PROJECT_JOBS,
- GO_TO_PROJECT_METRICS,
+ ...(gon.features?.removeMonitorMetrics ? [] : [GO_TO_PROJECT_METRICS]),
GO_TO_PROJECT_ENVIRONMENTS,
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_SNIPPETS,
@@ -594,7 +615,7 @@ export const PROJECT_SHORTCUTS_GROUP = {
],
};
-export const PROJECT_FILES_SHORTCUTS_GROUP = {
+const PROJECT_FILES_SHORTCUTS_GROUP = {
id: 'projectFiles',
name: __('Project Files'),
keybindings: [
@@ -606,19 +627,19 @@ export const PROJECT_FILES_SHORTCUTS_GROUP = {
],
};
-export const ISSUABLE_SHORTCUTS_GROUP = {
+const ISSUABLE_SHORTCUTS_GROUP = {
id: 'issuables',
name: __('Epics, issues, and merge requests'),
keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
};
-export const ISSUE_MR_SHORTCUTS_GROUP = {
+const ISSUE_MR_SHORTCUTS_GROUP = {
id: 'issuesMRs',
name: __('Issues and merge requests'),
keybindings: [ISSUE_MR_CHANGE_ASSIGNEE, ISSUE_MR_CHANGE_MILESTONE],
};
-export const MR_SHORTCUTS_GROUP = {
+const MR_SHORTCUTS_GROUP = {
id: 'mergeRequests',
name: __('Merge requests'),
keybindings: [
@@ -631,30 +652,29 @@ export const MR_SHORTCUTS_GROUP = {
],
};
-export const MR_COMMITS_SHORTCUTS_GROUP = {
+const MR_COMMITS_SHORTCUTS_GROUP = {
id: 'mergeRequestCommits',
name: __('Merge request commits'),
keybindings: [MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT],
};
-export const ISSUES_SHORTCUTS_GROUP = {
+const ISSUES_SHORTCUTS_GROUP = {
id: 'issues',
name: __('Issues'),
keybindings: [ISSUE_NEXT_DESIGN, ISSUE_PREVIOUS_DESIGN, ISSUE_CLOSE_DESIGN],
};
-export const WEB_IDE_SHORTCUTS_GROUP = {
+const WEB_IDE_SHORTCUTS_GROUP = {
id: 'webIDE',
- name: __('Web IDE'),
+ name: __('Legacy Web IDE'),
keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT],
};
-export const METRICS_SHORTCUTS_GROUP = {
+const METRICS_SHORTCUTS_GROUP = {
id: 'metrics',
name: __('Metrics'),
keybindings: [
METRICS_EXPAND_PANEL,
- METRICS_VIEW_LOGS,
METRICS_DOWNLOAD_CSV,
METRICS_COPY_LINK_TO_CHART,
METRICS_SHOW_ALERTS,
@@ -681,15 +701,18 @@ export const keybindingGroups = [
MR_COMMITS_SHORTCUTS_GROUP,
ISSUES_SHORTCUTS_GROUP,
WEB_IDE_SHORTCUTS_GROUP,
- METRICS_SHORTCUTS_GROUP,
+ ...(gon.features?.removeMonitorMetrics ? [] : [METRICS_SHORTCUTS_GROUP]),
MISC_SHORTCUTS_GROUP,
];
/**
* Gets keyboard shortcuts associated with a command
*
- * @param {string} command The command object. All command
- * objects are available as imports from this file.
+ * @param {Object} command The command object. All command objects are
+ * available as imports from this file.
+ * @param {string} command.id
+ * @param {string[]} command.defaultKeys
+ * @param {boolean} [command.customizable]
*
* @returns {string[]} An array of keyboard shortcut strings bound to the command
*
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 7a1577e97d5..9514ad853b0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { flatten } from 'lodash';
-import Mousetrap from 'mousetrap';
import Vue from 'vue';
+import { Mousetrap, addStopCallback } from '~/lib/mousetrap';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import findAndFollowLink from '~/lib/utils/navigation_utility';
@@ -28,20 +28,11 @@ import {
} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
-const defaultStopCallback = Mousetrap.prototype.stopCallback;
-Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
- if (keysFor(TOGGLE_MARKDOWN_PREVIEW).indexOf(combo) !== -1) {
- return false;
- }
-
- return defaultStopCallback.call(this, e, element, combo);
-};
-
/**
* The key used to save and fetch the local Mousetrap instance
* attached to a `<textarea>` element using `jQuery.data`
*/
-const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
+export const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
/**
* Gets a mapping of toolbar button => keyboard shortcuts
@@ -76,62 +67,75 @@ export default class Shortcuts {
this.helpModalElement = null;
this.helpModalVueInstance = null;
- Mousetrap.bind(keysFor(TOGGLE_KEYBOARD_SHORTCUTS_DIALOG), this.onToggleHelp);
- Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch);
- Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this));
- Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
- Mousetrap.bind(keysFor(HIDE_APPEARING_CONTENT), Shortcuts.hideAppearingContent);
- Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary);
-
- const findFileURL = document.body.dataset.findFile;
-
- Mousetrap.bind(keysFor(GO_TO_YOUR_TODO_LIST), () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind(keysFor(GO_TO_ACTIVITY_FEED), () =>
- findAndFollowLink('.dashboard-shortcuts-activity'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_ISSUES), () =>
- findAndFollowLink('.dashboard-shortcuts-issues'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
- findAndFollowLink('.dashboard-shortcuts-merge_requests'),
+ this.bindCommands([
+ [TOGGLE_KEYBOARD_SHORTCUTS_DIALOG, this.onToggleHelp],
+ [START_SEARCH, Shortcuts.focusSearch],
+ [FOCUS_FILTER_BAR, this.focusFilter.bind(this)],
+ [TOGGLE_PERFORMANCE_BAR, Shortcuts.onTogglePerfBar],
+ [HIDE_APPEARING_CONTENT, Shortcuts.hideAppearingContent],
+ [TOGGLE_CANARY, Shortcuts.onToggleCanary],
+
+ [GO_TO_YOUR_TODO_LIST, () => findAndFollowLink('.shortcuts-todos')],
+ [GO_TO_ACTIVITY_FEED, () => findAndFollowLink('.dashboard-shortcuts-activity')],
+ [GO_TO_YOUR_ISSUES, () => findAndFollowLink('.dashboard-shortcuts-issues')],
+ [GO_TO_YOUR_MERGE_REQUESTS, () => findAndFollowLink('.dashboard-shortcuts-merge_requests')],
+ [GO_TO_YOUR_REVIEW_REQUESTS, () => findAndFollowLink('.dashboard-shortcuts-review_requests')],
+ [GO_TO_YOUR_PROJECTS, () => findAndFollowLink('.dashboard-shortcuts-projects')],
+ [GO_TO_YOUR_GROUPS, () => findAndFollowLink('.dashboard-shortcuts-groups')],
+ [GO_TO_MILESTONE_LIST, () => findAndFollowLink('.dashboard-shortcuts-milestones')],
+ [GO_TO_YOUR_SNIPPETS, () => findAndFollowLink('.dashboard-shortcuts-snippets')],
+
+ [TOGGLE_MARKDOWN_PREVIEW, Shortcuts.toggleMarkdownPreview],
+ ]);
+
+ addStopCallback((e, element, combo) =>
+ keysFor(TOGGLE_MARKDOWN_PREVIEW).includes(combo) ? false : undefined,
);
- Mousetrap.bind(keysFor(GO_TO_YOUR_REVIEW_REQUESTS), () =>
- findAndFollowLink('.dashboard-shortcuts-review_requests'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
- findAndFollowLink('.dashboard-shortcuts-projects'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_GROUPS), () =>
- findAndFollowLink('.dashboard-shortcuts-groups'),
- );
- Mousetrap.bind(keysFor(GO_TO_MILESTONE_LIST), () =>
- findAndFollowLink('.dashboard-shortcuts-milestones'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () =>
- findAndFollowLink('.dashboard-shortcuts-snippets'),
- );
-
- Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview);
+ const findFileURL = document.body.dataset.findFile;
if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
- Mousetrap.bind(keysFor(GO_TO_PROJECT_FIND_FILE), () => {
+ this.bindCommand(GO_TO_PROJECT_FIND_FILE, () => {
visitUrl(findFileURL);
});
}
- $(document).on('click.more_help', '.js-more-help-button', function clickMoreHelp(e) {
- $(this).remove();
- e.preventDefault();
- });
-
+ const shortcutsModalTriggerEvent = 'click.shortcutsModalTrigger';
// eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-shortcuts-modal-trigger').off('click').on('click', this.onToggleHelp);
+ $(document)
+ .off(shortcutsModalTriggerEvent)
+ .on(shortcutsModalTriggerEvent, '.js-shortcuts-modal-trigger', this.onToggleHelp);
if (shouldDisableShortcuts()) {
disableShortcuts();
}
}
+ /**
+ * Bind the keyboard shortcut(s) defined by the given command to the given
+ * callback.
+ *
+ * @param {Object} command A command object.
+ * @param {Function} callback The callback to call when the command's key
+ * combo has been pressed.
+ * @returns {void}
+ */
+ // eslint-disable-next-line class-methods-use-this
+ bindCommand(command, callback) {
+ Mousetrap.bind(keysFor(command), callback);
+ }
+
+ /**
+ * Bind the keyboard shortcut(s) defined by the given commands to the given
+ * callbacks.
+ *
+ * @param {Array<[Object, Function]>} commandsAndCallbacks An array of
+ * command/callback pairs.
+ * @returns {void}
+ */
+ bindCommands(commandsAndCallbacks) {
+ commandsAndCallbacks.forEach((commandAndCallback) => this.bindCommand(...commandAndCallback));
+ }
+
onToggleHelp(e) {
if (e?.preventDefault) {
e.preventDefault();
@@ -182,13 +186,6 @@ export default class Shortcuts {
}
static toggleMarkdownPreview(e) {
- // Check if short-cut was triggered while in Write Mode
- const $target = $(e.target);
- const $form = $target.closest('form');
-
- if ($target.hasClass('js-note-text')) {
- $('.js-md-preview-button', $form).focus();
- }
$(document).triggerHandler('markdown-preview:toggle', [e]);
}
@@ -201,7 +198,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/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index ab7fcbb35f1..65ae67d156f 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,5 +1,4 @@
-import Mousetrap from 'mousetrap';
-import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
+import { PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import {
getLocationHash,
updateHistory,
@@ -11,7 +10,6 @@ import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts';
const defaults = {
- skipResetBindings: false,
fileBlobPermalinkUrl: null,
fileBlobPermalinkUrlElement: null,
};
@@ -24,12 +22,12 @@ function eventHasModifierKeys(event) {
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = { ...defaults, ...opts };
- super(options.skipResetBindings);
+ super();
this.options = options;
this.shortcircuitPermalinkButton();
- Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.moveToFilePermalink.bind(this));
+ this.bindCommand(PROJECT_FILES_GO_TO_PERMALINK, this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
index 992e571e596..f26878cf161 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
@@ -1,4 +1,3 @@
-import Mousetrap from 'mousetrap';
import {
keysFor,
PROJECT_FILES_MOVE_SELECTION_UP,
@@ -6,15 +5,14 @@ import {
PROJECT_FILES_OPEN_SELECTION,
PROJECT_FILES_GO_BACK,
} from '~/behaviors/shortcuts/keybindings';
+import { addStopCallback } from '~/lib/mousetrap';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsFindFile extends ShortcutsNavigation {
constructor(projectFindFile) {
super();
- const oldStopCallback = Mousetrap.prototype.stopCallback;
-
- Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
+ addStopCallback((e, element, combo) => {
if (
element === projectFindFile.inputElement[0] &&
(keysFor(PROJECT_FILES_MOVE_SELECTION_UP).includes(combo) ||
@@ -27,12 +25,14 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
return false;
}
- return oldStopCallback.call(this, e, element, combo);
- };
+ return undefined;
+ });
- Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_UP), projectFindFile.selectRowUp);
- Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN), projectFindFile.selectRowDown);
- Mousetrap.bind(keysFor(PROJECT_FILES_GO_BACK), projectFindFile.goToTree);
- Mousetrap.bind(keysFor(PROJECT_FILES_OPEN_SELECTION), projectFindFile.goToBlob);
+ this.bindCommands([
+ [PROJECT_FILES_MOVE_SELECTION_UP, projectFindFile.selectRowUp],
+ [PROJECT_FILES_MOVE_SELECTION_DOWN, projectFindFile.selectRowDown],
+ [PROJECT_FILES_GO_BACK, projectFindFile.goToTree],
+ [PROJECT_FILES_OPEN_SELECTION, projectFindFile.goToBlob],
+ ]);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 64297da39cd..0c882ff9ea2 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import ClipboardJS from 'clipboard';
-import Mousetrap from 'mousetrap';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { DEBOUNCE_DROPDOWN_DELAY } from '~/sidebar/components/labels/labels_select_widget/constants';
@@ -9,7 +8,6 @@ import { s__ } from '~/locale';
import Sidebar from '~/right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
- keysFor,
ISSUE_MR_CHANGE_ASSIGNEE,
ISSUE_MR_CHANGE_MILESTONE,
ISSUABLE_CHANGE_LABEL,
@@ -32,18 +30,14 @@ export default class ShortcutsIssuable extends Shortcuts {
toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
});
- Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
- ShortcutsIssuable.openSidebarDropdown('assignee'),
- );
- Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_MILESTONE), () =>
- ShortcutsIssuable.openSidebarDropdown('milestone'),
- );
- Mousetrap.bind(keysFor(ISSUABLE_CHANGE_LABEL), () =>
- ShortcutsIssuable.openSidebarDropdown('labels'),
- );
- Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
- Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
- Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), () => this.copyBranchName());
+ this.bindCommands([
+ [ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')],
+ [ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')],
+ [ISSUABLE_CHANGE_LABEL, () => ShortcutsIssuable.openSidebarDropdown('labels')],
+ [ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText],
+ [ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue],
+ [MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()],
+ ]);
/**
* We're attaching a global focus event listener on document for
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index 7bb6bc7e9bc..9e6c9c2e08e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,13 +1,12 @@
-import Mousetrap from 'mousetrap';
import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility';
import findAndFollowLink from '~/lib/utils/navigation_utility';
import {
- keysFor,
GO_TO_PROJECT_OVERVIEW,
GO_TO_PROJECT_ACTIVITY_FEED,
GO_TO_PROJECT_RELEASES,
GO_TO_PROJECT_FILES,
GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_PIPELINES,
GO_TO_PROJECT_JOBS,
GO_TO_PROJECT_REPO_GRAPH,
GO_TO_PROJECT_REPO_CHARTS,
@@ -28,40 +27,27 @@ export default class ShortcutsNavigation extends Shortcuts {
constructor() {
super();
- Mousetrap.bind(keysFor(GO_TO_PROJECT_OVERVIEW), () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ACTIVITY_FEED), () =>
- findAndFollowLink('.shortcuts-project-activity'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_RELEASES), () =>
- findAndFollowLink('.shortcuts-project-releases'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_FILES), () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_COMMITS), () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_JOBS), () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_GRAPH), () =>
- findAndFollowLink('.shortcuts-network'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_CHARTS), () =>
- findAndFollowLink('.shortcuts-repository-charts'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUES), () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUE_BOARDS), () =>
- findAndFollowLink('.shortcuts-issue-boards'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_MERGE_REQUESTS), () =>
- findAndFollowLink('.shortcuts-merge_requests'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_WIKI), () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_SNIPPETS), () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_KUBERNETES), () =>
- findAndFollowLink('.shortcuts-kubernetes'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ENVIRONMENTS), () =>
- findAndFollowLink('.shortcuts-environments'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_WEBIDE), ShortcutsNavigation.navigateToWebIDE);
- Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
+ this.bindCommands([
+ [GO_TO_PROJECT_OVERVIEW, () => findAndFollowLink('.shortcuts-project')],
+ [GO_TO_PROJECT_ACTIVITY_FEED, () => findAndFollowLink('.shortcuts-project-activity')],
+ [GO_TO_PROJECT_RELEASES, () => findAndFollowLink('.shortcuts-deployments-releases')],
+ [GO_TO_PROJECT_FILES, () => findAndFollowLink('.shortcuts-tree')],
+ [GO_TO_PROJECT_COMMITS, () => findAndFollowLink('.shortcuts-commits')],
+ [GO_TO_PROJECT_PIPELINES, () => findAndFollowLink('.shortcuts-pipelines')],
+ [GO_TO_PROJECT_JOBS, () => findAndFollowLink('.shortcuts-builds')],
+ [GO_TO_PROJECT_REPO_GRAPH, () => findAndFollowLink('.shortcuts-network')],
+ [GO_TO_PROJECT_REPO_CHARTS, () => findAndFollowLink('.shortcuts-repository-charts')],
+ [GO_TO_PROJECT_ISSUES, () => findAndFollowLink('.shortcuts-issues')],
+ [GO_TO_PROJECT_ISSUE_BOARDS, () => findAndFollowLink('.shortcuts-issue-boards')],
+ [GO_TO_PROJECT_MERGE_REQUESTS, () => findAndFollowLink('.shortcuts-merge_requests')],
+ [GO_TO_PROJECT_WIKI, () => findAndFollowLink('.shortcuts-wiki')],
+ [GO_TO_PROJECT_SNIPPETS, () => findAndFollowLink('.shortcuts-snippets')],
+ [GO_TO_PROJECT_KUBERNETES, () => findAndFollowLink('.shortcuts-kubernetes')],
+ [GO_TO_PROJECT_ENVIRONMENTS, () => findAndFollowLink('.shortcuts-environments')],
+ [GO_TO_PROJECT_METRICS, () => findAndFollowLink('.shortcuts-metrics')],
+ [GO_TO_PROJECT_WEBIDE, ShortcutsNavigation.navigateToWebIDE],
+ [NEW_ISSUE, () => findAndFollowLink('.shortcuts-new-issue')],
+ ]);
}
static navigateToWebIDE() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
index c33c092b009..02c6af53fc2 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
@@ -1,6 +1,4 @@
-import Mousetrap from 'mousetrap';
import {
- keysFor,
REPO_GRAPH_SCROLL_BOTTOM,
REPO_GRAPH_SCROLL_DOWN,
REPO_GRAPH_SCROLL_LEFT,
@@ -14,11 +12,13 @@ export default class ShortcutsNetwork extends ShortcutsNavigation {
constructor(graph) {
super();
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_LEFT), graph.scrollLeft);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_RIGHT), graph.scrollRight);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_UP), graph.scrollUp);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_DOWN), graph.scrollDown);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_TOP), graph.scrollTop);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_BOTTOM), graph.scrollBottom);
+ this.bindCommands([
+ [REPO_GRAPH_SCROLL_LEFT, graph.scrollLeft],
+ [REPO_GRAPH_SCROLL_RIGHT, graph.scrollRight],
+ [REPO_GRAPH_SCROLL_UP, graph.scrollUp],
+ [REPO_GRAPH_SCROLL_DOWN, graph.scrollDown],
+ [REPO_GRAPH_SCROLL_TOP, graph.scrollTop],
+ [REPO_GRAPH_SCROLL_BOTTOM, graph.scrollBottom],
+ ]);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
index 66aa1b752ae..3f3e0c51de5 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
@@ -1,4 +1,4 @@
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
const shorcutsDisabledKey = 'shortcutsDisabled';
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index b2801f9118d..62d612cfa6d 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -1,12 +1,12 @@
-import Mousetrap from 'mousetrap';
import findAndFollowLink from '~/lib/utils/navigation_utility';
-import { keysFor, EDIT_WIKI_PAGE } from './keybindings';
+import { EDIT_WIKI_PAGE } from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind(keysFor(EDIT_WIKI_PAGE), ShortcutsWiki.editWiki);
+
+ this.bindCommand(EDIT_WIKI_PAGE, ShortcutsWiki.editWiki);
}
static editWiki() {
diff --git a/app/assets/javascripts/blame/blame_redirect.js b/app/assets/javascripts/blame/blame_redirect.js
index 155e2a3a2cd..f528fdb1f69 100644
--- a/app/assets/javascripts/blame/blame_redirect.js
+++ b/app/assets/javascripts/blame/blame_redirect.js
@@ -1,5 +1,5 @@
import { setUrlParams } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
export default function redirectToCorrectBlamePage() {
diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js
new file mode 100644
index 00000000000..935343cca2e
--- /dev/null
+++ b/app/assets/javascripts/blame/streaming/index.js
@@ -0,0 +1,56 @@
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+import { toPolyfillReadable } from '~/streaming/polyfills';
+
+export async function renderBlamePageStreams(firstStreamPromise) {
+ const element = document.querySelector('#blame-stream-container');
+
+ if (!element || !firstStreamPromise) return;
+
+ const stopAnchorObserver = handleStreamedAnchorLink(element);
+ const { dataset } = document.querySelector('#blob-content-holder');
+ const totalExtraPages = parseInt(dataset.totalExtraPages, 10);
+ const { pagesUrl } = dataset;
+
+ const remainingStreams = rateLimitStreamRequests({
+ factory: (index) => {
+ const url = new URL(pagesUrl);
+ // page numbers start with 1
+ // the first page is already rendered in the document
+ // the second page is passed with the 'firstStreamPromise'
+ url.searchParams.set('page', index + 3);
+ return fetch(url).then((response) => toPolyfillReadable(response.body));
+ },
+ // we don't want to overload gitaly with concurrent requests
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/391842#note_1281695095
+ // using 5 as a good starting point
+ maxConcurrentRequests: 5,
+ total: totalExtraPages,
+ });
+
+ try {
+ await renderHtmlStreams(
+ [firstStreamPromise.then(toPolyfillReadable), ...remainingStreams],
+ element,
+ );
+ } catch (error) {
+ createAlert({
+ message: __('Blame could not be loaded as a single page.'),
+ primaryButton: {
+ text: __('View blame as separate pages'),
+ clickHandler() {
+ const newUrl = new URL(window.location);
+ newUrl.searchParams.delete('streaming');
+ window.location.href = newUrl;
+ },
+ },
+ });
+ throw error;
+ } finally {
+ stopAnchorObserver();
+ document.querySelector('#blame-stream-loading').remove();
+ }
+}
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index 5715635fd13..8fd3f03ff71 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -35,9 +35,7 @@ export default {
<div class="gl-display-flex gl-align-items-center gl-w-full">
<gl-form-input
v-model="name"
- :placeholder="
- s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
- "
+ :placeholder="s__('Snippets|File name (e.g. test.rb)')"
name="snippet_file_name"
class="form-control js-snippet-file-name"
type="text"
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index fb99392ff48..95b88937c32 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -59,7 +59,7 @@ export default {
:gfm="gfmCopyText"
:title="__('Copy file path')"
category="tertiary"
- css-class="btn-clipboard btn-transparent lh-100 position-static"
+ css-class="gl-mr-2"
/>
<small class="gl-mr-3">{{ blobSize }}</small>
diff --git a/app/assets/javascripts/blob/csv/index.js b/app/assets/javascripts/blob/csv/index.js
index 4cf6c169c68..ed8e1ffa318 100644
--- a/app/assets/javascripts/blob/csv/index.js
+++ b/app/assets/javascripts/blob/csv/index.js
@@ -10,6 +10,7 @@ export default () => {
return createElement(CsvViewer, {
props: {
csv: el.dataset.data,
+ remoteFile: true,
},
});
},
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 2ea3c93625d..7ccb66f18a9 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import Api from '~/api';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index ade92f2562b..acbd231c94a 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -83,4 +83,9 @@ export default {
.output img {
min-width: 0; /* https://www.w3.org/TR/css-flexbox-1/#min-size-auto */
}
+
+.output .markdown {
+ display: block;
+ width: 100%;
+}
</style>
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index d81aa05c44e..94ae281cada 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -1,12 +1,21 @@
import { setAttributes } from '~/lib/utils/dom_utils';
import axios from '~/lib/utils/axios_utils';
-import { getBaseURL, relativePathToAbsolute, joinPaths } from '~/lib/utils/url_utility';
+import {
+ getBaseURL,
+ relativePathToAbsolute,
+ joinPaths,
+ setUrlParams,
+} from '~/lib/utils/url_utility';
const SANDBOX_FRAME_PATH = '/-/sandbox/swagger';
const getSandboxFrameSrc = () => {
const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH);
- return relativePathToAbsolute(path, getBaseURL());
+ const absoluteUrl = relativePathToAbsolute(path, getBaseURL());
+ if (window.gon?.relative_url_root) {
+ return setUrlParams({ relativeRootPath: window.gon.relative_url_root }, absoluteUrl);
+ }
+ return absoluteUrl;
};
const createSandbox = () => {
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/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 5e85e4cea38..bdaefe8383c 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import {
REPO_BLOB_LOAD_VIEWER_START,
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 509d399273d..7e667409556 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,8 +1,6 @@
-/* eslint-disable no-new */
-
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
import NewCommitForm from '../new_commit_form';
@@ -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/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index a3d11d90ed2..f021553ae98 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -4,7 +4,7 @@ import { SourceEditorExtension } from '~/editor/extensions/source_editor_extensi
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
import SourceEditor from '~/editor/source_editor';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import { insertFinalNewline } from '~/lib/utils/text_utility';
@@ -68,9 +68,9 @@ export default class EditBlob {
blobContent: editorEl.innerText,
});
this.editor.use([
+ { definition: ToolbarExtension },
{ definition: SourceEditorExtension },
{ definition: FileTemplateExtension },
- { definition: ToolbarExtension },
]);
fileNameEl.addEventListener('change', () => {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index c5411ec313a..90f7059da86 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -1,13 +1,24 @@
<script>
-import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import {
+ GlTooltipDirective as GlTooltip,
+ GlButton,
+ GlCollapsibleListbox,
+ GlIcon,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ value: __('Value'),
+ noResults: __('No matching results'),
+ },
components: {
BoardAddNewColumnForm,
- GlFormRadio,
- GlFormRadioGroup,
+ GlButton,
+ GlCollapsibleListbox,
+ GlIcon,
},
directives: {
GlTooltip,
@@ -17,6 +28,7 @@ export default {
return {
selectedId: null,
selectedLabel: null,
+ selectedIdValid: true,
};
},
computed: {
@@ -25,6 +37,15 @@ export default {
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
+ items() {
+ return (
+ this.labels.map((i) => ({
+ ...i,
+ text: i.title,
+ value: i.id,
+ })) || []
+ );
+ },
},
created() {
this.filterItems();
@@ -33,6 +54,7 @@ export default {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
addList() {
if (!this.selectedLabel) {
+ this.selectedIdValid = false;
return;
}
@@ -61,53 +83,67 @@ export default {
this.selectedLabel = { ...label };
}
},
+ onHide() {
+ this.searchValue = '';
+ this.$emit('filter-items', '');
+ this.$emit('hide');
+ },
},
};
</script>
<template>
<board-add-new-column-form
- :loading="labelsLoading"
- :none-selected="__('Select a label')"
- :search-placeholder="__('Search labels')"
- :selected-id="selectedId"
+ :selected-id-valid="selectedIdValid"
@filter-items="filterItems"
@add-list="addList"
>
- <template #selected>
- <template v-if="selectedLabel">
- <span
- class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
- :style="{
- backgroundColor: selectedLabel.color,
- }"
- ></span>
- <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
- </template>
- </template>
-
- <template #items>
- <gl-form-radio-group
- v-if="labels.length > 0"
- class="gl-overflow-y-auto gl-px-5 gl-pt-3"
- :checked="selectedId"
- @change="setSelectedItem"
+ <template #dropdown>
+ <gl-collapsible-listbox
+ class="gl-mb-3 gl-max-w-full"
+ :items="items"
+ searchable
+ :search-placeholder="__('Search labels')"
+ :searching="labelsLoading"
+ :selected="selectedId"
+ :no-results-text="$options.i18n.noResults"
+ @select="setSelectedItem"
+ @search="filterItems"
+ @hidden="onHide"
>
- <label
- v-for="label in labels"
- :key="label.id"
- class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word"
- >
- <gl-form-radio :value="label.id" />
- <span
- class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
- :style="{
- backgroundColor: label.color,
- }"
- ></span>
- <span>{{ label.title }}</span>
- </label>
- </gl-form-radio-group>
+ <template #toggle>
+ <gl-button
+ class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-truncate"
+ :class="{ 'gl-inset-border-1-red-400!': !selectedIdValid }"
+ button-text-classes="gl-display-flex"
+ >
+ <template v-if="selectedLabel">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: selectedLabel.color,
+ }"
+ ></span>
+ <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
+ </template>
+
+ <template v-else>{{ __('Select a label') }}</template>
+ <gl-icon class="dropdown-chevron gl-ml-2" name="chevron-down" />
+ </gl-button>
+ </template>
+
+ <template #list-item="{ item }">
+ <label class="gl-display-flex gl-font-weight-normal gl-overflow-break-word gl-mb-0">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: item.color,
+ }"
+ ></span>
+ <span>{{ item.title }}</span>
+ </label>
+ </template>
+ </gl-collapsible-listbox>
</template>
</board-add-new-column-form>
</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index 1899d42fa4d..259423df07f 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -1,12 +1,5 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlFormGroup,
- GlIcon,
- GlSearchBoxByType,
- GlSkeletonLoader,
-} from '@gitlab/ui';
+import { GlButton, GlFormGroup } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
@@ -15,81 +8,34 @@ export default {
add: __('Add to board'),
cancel: __('Cancel'),
newList: __('New list'),
- noResults: __('No matching results'),
scope: __('Scope'),
scopeDescription: __('Issues must match this scope to appear in this list.'),
- selected: __('Selected'),
requiredFieldFeedback: __('This field is required.'),
},
components: {
GlButton,
- GlDropdown,
GlFormGroup,
- GlIcon,
- GlSearchBoxByType,
- GlSkeletonLoader,
},
props: {
- loading: {
- type: Boolean,
- required: true,
- },
searchLabel: {
type: String,
required: false,
default: null,
},
- noneSelected: {
- type: String,
- required: true,
- },
- searchPlaceholder: {
- type: String,
+ selectedIdValid: {
+ type: Boolean,
required: true,
},
- selectedId: {
- type: [Number, String],
- required: false,
- default: null,
- },
},
data() {
return {
searchValue: '',
- selectedIdValid: true,
};
},
- computed: {
- toggleClassList() {
- return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${
- this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!'
- }`;
- },
- },
- watch: {
- selectedId(val) {
- if (val) {
- this.$refs.dropdown.hide(true);
- this.selectedIdValid = true;
- }
- },
- },
methods: {
...mapActions(['setAddColumnFormVisibility']),
- setFocus() {
- this.$refs.searchBox.focusInput();
- },
- onHide() {
- this.searchValue = '';
- this.$emit('filter-items', '');
- this.$emit('hide');
- },
onSubmit() {
- if (!this.selectedId) {
- this.selectedIdValid = false;
- } else {
- this.$emit('add-list');
- }
+ this.$emit('add-list');
},
},
};
@@ -126,44 +72,7 @@ export default {
:state="selectedIdValid"
:invalid-feedback="$options.i18n.requiredFieldFeedback"
>
- <gl-dropdown
- ref="dropdown"
- class="gl-mb-3 gl-max-w-full"
- :toggle-class="toggleClassList"
- boundary="viewport"
- @shown="setFocus"
- @hide="onHide"
- >
- <template #button-content>
- <slot name="selected">
- <div>{{ noneSelected }}</div>
- </slot>
- <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" />
- </template>
-
- <template #header>
- <gl-search-box-by-type
- ref="searchBox"
- v-model="searchValue"
- debounce="250"
- class="gl-mt-0!"
- :placeholder="searchPlaceholder"
- @input="$emit('filter-items', $event)"
- />
- </template>
-
- <div v-if="loading" class="gl-px-5">
- <gl-skeleton-loader :width="400" :height="172">
- <rect width="380" height="20" x="10" y="15" rx="4" />
- <rect width="280" height="20" x="10" y="50" rx="4" />
- <rect width="330" height="20" x="10" y="85" rx="4" />
- </gl-skeleton-loader>
- </div>
-
- <slot v-else name="items">
- <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
- </slot>
- </gl-dropdown>
+ <slot name="dropdown"></slot>
</gl-form-group>
</div>
<div class="gl-display-flex gl-mb-4">
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index d41fc1e9300..3a247819850 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -1,24 +1,106 @@
<script>
import { mapGetters } from 'vuex';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
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 { listsQuery } from 'ee_else_ce/boards/constants';
+import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export default {
+ i18n: {
+ fetchError: s__(
+ 'Boards|An error occurred while fetching the board lists. Please reload the page.',
+ ),
+ },
components: {
BoardContent,
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['initialBoardId'],
+ inject: [
+ 'fullPath',
+ 'initialBoardId',
+ 'initialFilterParams',
+ 'isIssueBoard',
+ 'isGroupBoard',
+ 'issuableType',
+ 'boardType',
+ 'isApolloBoard',
+ ],
data() {
return {
+ activeListId: '',
boardId: this.initialBoardId,
+ filterParams: { ...this.initialFilterParams },
+ isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
+ apolloError: null,
};
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ result({ data: { activeBoardItem } }) {
+ if (activeBoardItem) {
+ this.setActiveId('');
+ }
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ boardListsApollo: {
+ query() {
+ return listsQuery[this.issuableType].query;
+ },
+ variables() {
+ return this.listQueryVariables;
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ update(data) {
+ const { lists } = data[this.boardType].board;
+ return formatBoardLists(lists);
+ },
+ error() {
+ this.apolloError = this.$options.i18n.fetchError;
+ },
+ },
+ },
+
computed: {
...mapGetters(['isSidebarOpen']),
+ listQueryVariables() {
+ return {
+ ...(this.isIssueBoard && {
+ isGroup: this.isGroupBoard,
+ isProject: !this.isGroupBoard,
+ }),
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ filters: this.filterParams,
+ };
+ },
+ isSwimlanesOn() {
+ return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false;
+ },
+ isAnySidebarOpen() {
+ if (this.isApolloBoard) {
+ return this.activeBoardItem?.id || this.activeListId;
+ }
+ return this.isSidebarOpen;
+ },
+ activeList() {
+ return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined;
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -27,17 +109,47 @@ export default {
window.removeEventListener('popstate', refreshCurrentPage);
},
methods: {
+ setActiveId(id) {
+ this.activeListId = id;
+ },
switchBoard(id) {
this.boardId = id;
+ this.setActiveId('');
+ },
+ setFilters(filters) {
+ const filterParams = { ...filters };
+ if (filterParams.groupBy) delete filterParams.groupBy;
+ this.filterParams = filterParams;
},
},
};
</script>
<template>
- <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
- <board-top-bar :board-id="boardId" @switchBoard="switchBoard" />
- <board-content :board-id="boardId" />
- <board-settings-sidebar />
+ <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }">
+ <board-top-bar
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ @switchBoard="switchBoard"
+ @setFilters="setFilters"
+ @toggleSwimlanes="isShowingEpicsSwimlanes = $event"
+ />
+ <board-content
+ v-if="!isApolloBoard || boardListsApollo"
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ :filter-params="filterParams"
+ :board-lists-apollo="boardListsApollo"
+ :apollo-error="apolloError"
+ @setActiveList="setActiveId"
+ />
+ <board-settings-sidebar
+ v-if="!isApolloBoard || activeList"
+ :list="activeList"
+ :list-id="activeListId"
+ :board-id="boardId"
+ :query-variables="listQueryVariables"
+ @unsetActiveId="setActiveId('')"
+ />
</div>
</template>
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_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 708e1539c6e..b2054d76e95 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -20,6 +20,10 @@ export default {
type: String,
required: true,
},
+ filters: {
+ type: Object,
+ required: true,
+ },
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
@@ -33,11 +37,14 @@ export default {
isListDraggable() {
return isListDraggable(this.list);
},
+ filtersToUse() {
+ return this.isApolloBoard ? this.filters : this.filterParams;
+ },
},
watch: {
filterParams: {
handler() {
- if (this.list.id && !this.list.collapsed) {
+ if (!this.isApolloBoard && this.list.id && !this.list.collapsed) {
this.fetchItemsForList({ listId: this.list.id });
}
},
@@ -46,7 +53,7 @@ export default {
},
'list.id': {
handler(id) {
- if (id) {
+ if (!this.isApolloBoard && id) {
this.fetchItemsForList({ listId: this.list.id });
}
},
@@ -83,13 +90,18 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
:class="{ 'board-column-highlighted': highlighted }"
>
- <board-list-header :list="list" />
+ <board-list-header
+ :list="list"
+ :filter-params="filtersToUse"
+ :board-id="boardId"
+ @setActiveList="$emit('setActiveList', $event)"
+ />
<board-list
ref="board-list"
:board-id="boardId"
:board-items="listItems"
:list="list"
- :filter-params="filterParams"
+ :filter-params="filtersToUse"
/>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 8a37719eae8..8304dfef527 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,23 +1,15 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { breakpoints } from '@gitlab/ui/dist/utils';
-import { sortBy, throttle } from 'lodash';
+import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
-import { mapState, mapGetters, mapActions } from 'vuex';
-import { contentTop } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
-import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
+import { mapState, mapActions } from 'vuex';
+import eventHub from '~/boards/eventhub';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
-import { DraggableItemTypes, listsQuery } from 'ee_else_ce/boards/constants';
+import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import BoardColumn from './board_column.vue';
export default {
- i18n: {
- fetchError: s__(
- 'Boards|An error occurred while fetching the board lists. Please reload the page.',
- ),
- },
draggableItemTypes: DraggableItemTypes,
components: {
BoardAddNewColumn,
@@ -28,73 +20,41 @@ export default {
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
- inject: [
- 'canAdminList',
- 'boardType',
- 'fullPath',
- 'issuableType',
- 'isIssueBoard',
- 'isEpicBoard',
- 'isGroupBoard',
- 'disabled',
- 'isApolloBoard',
- ],
+ inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'],
props: {
boardId: {
type: String,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
+ isSwimlanesOn: {
+ type: Boolean,
+ required: true,
+ },
+ boardListsApollo: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ apolloError: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
boardHeight: null,
- boardListsApollo: {},
- apolloError: null,
- updatedBoardId: this.boardId,
};
},
- apollo: {
- boardListsApollo: {
- query() {
- return listsQuery[this.issuableType].query;
- },
- variables() {
- return this.queryVariables;
- },
- skip() {
- return !this.isApolloBoard;
- },
- update(data) {
- const { lists } = data[this.boardType].board;
- return formatBoardLists(lists);
- },
- result() {
- // this allows us to delay fetching lists when we switch a board to fetch the actual board lists
- // instead of fetching lists for the "previous" board
- this.updatedBoardId = this.boardId;
- },
- error() {
- this.apolloError = this.$options.i18n.fetchError;
- },
- },
- },
computed: {
...mapState(['boardLists', 'error', 'addColumnForm']),
- ...mapGetters(['isSwimlanesOn']),
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
- queryVariables() {
- return {
- ...(this.isIssueBoard && {
- isGroup: this.isGroupBoard,
- isProject: !this.isGroupBoard,
- }),
- fullPath: this.fullPath,
- boardId: this.boardId,
- filterParams: this.filterParams,
- };
- },
boardListsToUse() {
const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists;
return sortBy([...Object.values(lists)], 'position');
@@ -126,18 +86,11 @@ export default {
return this.isApolloBoard ? this.apolloError : this.error;
},
},
- mounted() {
- this.setBoardHeight();
-
- this.resizeObserver = new ResizeObserver(
- throttle(() => {
- this.setBoardHeight();
- }, 150),
- );
- this.resizeObserver.observe(document.body);
+ created() {
+ eventHub.$on('updateBoard', this.refetchLists);
},
- unmounted() {
- this.resizeObserver.disconnect();
+ beforeDestroy() {
+ eventHub.$off('updateBoard', this.refetchLists);
},
methods: {
...mapActions(['moveList', 'unsetError']),
@@ -145,19 +98,19 @@ export default {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
},
- setBoardHeight() {
- if (window.innerWidth < breakpoints.md) {
- this.boardHeight = `${window.innerHeight - contentTop()}px`;
- } else {
- this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`;
- }
+ refetchLists() {
+ this.$apollo.queries.boardListsApollo.refetch();
},
},
};
</script>
<template>
- <div v-cloak data-qa-selector="boards_list">
+ <div
+ v-cloak
+ data-qa-selector="boards_list"
+ class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0"
+ >
<gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ errorToDisplay }}
</gl-alert>
@@ -166,8 +119,7 @@ export default {
v-if="!isSwimlanesOn"
ref="list"
v-bind="draggableOptions"
- class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll"
- :style="{ height: boardHeight }"
+ class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto"
@end="moveList"
>
<board-column
@@ -176,8 +128,10 @@ export default {
ref="board"
:board-id="boardId"
:list="list"
+ :filters="filterParams"
:data-draggable-item-type="$options.draggableItemTypes.list"
:class="{ 'gl-xs-display-none!': addColumnFormVisible }"
+ @setActiveList="$emit('setActiveList', $event)"
/>
<transition name="slide" @after-enter="afterFormEnters">
@@ -188,9 +142,11 @@ export default {
<epics-swimlanes
v-else-if="boardListsToUse.length"
ref="swimlanes"
+ :board-id="boardId"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
- :style="{ height: boardHeight }"
+ :filters="filterParams"
+ @setActiveList="$emit('setActiveList', $event)"
/>
<board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" />
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 6227f185eda..1b97214ff8b 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,12 +3,14 @@ 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 { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants';
+import { INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
@@ -16,8 +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 { LabelType } from '~/sidebar/components/labels/labels_select_widget/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -40,7 +40,6 @@ export default {
SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
multipleAssigneesFeatureAvailable: {
default: false,
@@ -72,33 +71,48 @@ 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 ? BoardType.group : BoardType.project;
+ return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
createLabelTitle() {
return sprintf(__('Create %{workspace} label'), {
@@ -114,13 +128,21 @@ export default {
return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue;
},
labelType() {
- return this.isGroupBoard ? LabelType.group : LabelType.project;
+ return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
labelsFilterPath() {
return this.isGroupBoard
? 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([
@@ -132,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({
@@ -144,7 +178,7 @@ export default {
},
handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
- iid: this.activeBoardItem.iid,
+ iid: this.activeBoardIssuable.iid,
projectPath: this.projectPathForActiveIssue,
removeLabelIds: [removeLabelId],
});
@@ -157,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"
@@ -168,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"
@@ -195,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"
@@ -204,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"
@@ -214,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"
@@ -233,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 1bc5d910561..b5d3613ca27 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,7 +1,7 @@
<script>
import { pickBy, isEmpty, mapValues } from 'lodash';
import { mapActions } from 'vuex';
-import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -22,7 +22,8 @@ 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';
export default {
@@ -30,8 +31,13 @@ export default {
search: __('Search'),
},
components: { FilteredSearch },
- inject: ['initialFilterParams'],
+ inject: ['initialFilterParams', 'isApolloBoard'],
props: {
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tokens: {
type: Array,
required: true,
@@ -320,6 +326,7 @@ export default {
release_tag: releaseTag,
confidential,
health_status: healthStatus,
+ group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined,
},
(value) => {
if (value || value === false) {
@@ -334,11 +341,23 @@ export default {
},
);
},
+ formattedFilterParams() {
+ const filtersCopy = { ...this.filterParams };
+ if (this.filterParams?.iterationId) {
+ filtersCopy.iterationId = convertToGraphQLId(
+ TYPENAME_ITERATION,
+ this.filterParams.iterationId,
+ );
+ }
+
+ return filtersCopy;
+ },
},
created() {
eventHub.$on('updateTokens', this.updateTokens);
if (!isEmpty(this.eeFilters)) {
this.filterParams = this.eeFilters;
+ this.$emit('setFilters', this.formattedFilterParams);
}
},
beforeDestroy() {
@@ -349,6 +368,7 @@ export default {
updateTokens() {
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
this.filterParams = convertObjectPropsToCamelCase(rawFilterParams, {});
+ this.$emit('setFilters', this.formattedFilterParams);
this.filteredSearchKey += 1;
},
handleFilter(filters) {
@@ -360,7 +380,11 @@ export default {
replace: true,
});
- this.performSearch();
+ if (this.isApolloBoard) {
+ this.$emit('setFilters', this.formattedFilterParams);
+ } else {
+ this.performSearch();
+ }
},
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
@@ -373,7 +397,6 @@ export default {
generateParams(filters = []) {
const filterParams = {};
const labels = [];
- const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
@@ -415,7 +438,9 @@ export default {
filterParams.confidential = filter.value.data;
break;
case FILTERED_SEARCH_TERM:
- if (filter.value.data) plainText.push(filter.value.data);
+ if (filter.value.data) {
+ filterParams.search = filter.value.data;
+ }
break;
case TOKEN_TYPE_HEALTH:
filterParams.healthStatus = filter.value.data;
@@ -429,10 +454,6 @@ export default {
filterParams.labelName = labels;
}
- if (plainText.length) {
- filterParams.search = plainText.join(' ');
- }
-
return filterParams;
},
},
@@ -444,6 +465,7 @@ export default {
:key="filteredSearchKey"
class="gl-w-full"
namespace=""
+ terms-as-tokens
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="getFilteredSearchValue"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index a71bde54a8f..604e71f5993 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+import eventHub from '~/boards/eventhub';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@@ -57,6 +58,9 @@ export default {
isProjectBoard: {
default: false,
},
+ isApolloBoard: {
+ default: false,
+ },
},
props: {
canAdminBoard: {
@@ -124,14 +128,12 @@ export default {
primaryProps() {
return {
text: this.buttonText,
- attributes: [
- {
- variant: this.buttonKind,
- disabled: this.submitDisabled,
- loading: this.isLoading,
- 'data-qa-selector': 'save_changes_button',
- },
- ],
+ attributes: {
+ variant: this.buttonKind,
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ 'data-qa-selector': 'save_changes_button',
+ },
};
},
cancelProps() {
@@ -213,7 +215,15 @@ export default {
} else {
try {
const board = await this.createOrUpdateBoard();
- this.setBoard(board);
+ if (this.isApolloBoard) {
+ if (this.board.id) {
+ eventHub.$emit('updateBoard', board);
+ } else {
+ this.$emit('addBoard', board);
+ }
+ } else {
+ this.setBoard(board);
+ }
this.cancel();
const param = getParameterByName('group_by')
@@ -278,7 +288,7 @@ export default {
@hide.prevent
>
<gl-alert
- v-if="error"
+ v-if="!isApolloBoard && error"
class="gl-mb-3"
variant="danger"
:dismissible="true"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 6f2b35f5191..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';
@@ -59,6 +60,10 @@ export default {
type: Array,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -108,7 +113,7 @@ export default {
},
},
computed: {
- ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
+ ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']),
boardListItems() {
return this.isApolloBoard
? this.currentList?.[`${this.issuableType}s`].nodes || []
@@ -125,7 +130,7 @@ export default {
};
},
listItemsCount() {
- return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
+ return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
@@ -154,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
@@ -260,6 +265,10 @@ export default {
this.showIssueForm = !this.showIssueForm;
}
},
+ isObservableItem(index) {
+ // observe every 6 item of 10 to achieve smooth loading state
+ return index !== 0 && index % 6 === 0;
+ },
onReachingListBottom() {
if (!this.loadingMore && this.hasNextPage) {
this.showCount = true;
@@ -393,8 +402,14 @@ export default {
:list="list"
:list-items-length="boardListItems.length"
/>
+ <gl-intersection-observer
+ v-if="isObservableItem(index)"
+ data-testid="board-card-gl-io"
+ @appear="onReachingListBottom"
+ />
</board-card>
- <gl-intersection-observer @appear="onReachingListBottom">
+ <div>
+ <!-- for supporting previous structure with intersection observer -->
<li
v-if="showCount"
class="board-list-count gl-text-center gl-text-secondary gl-py-4"
@@ -404,12 +419,11 @@ 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>
</li>
- </gl-intersection-observer>
+ </div>
</component>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 749fae0c426..1b711feb686 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -1,38 +1,48 @@
<script>
import {
GlButton,
- GlButtonGroup,
GlLabel,
GlTooltip,
GlIcon,
GlSprintf,
GlTooltipDirective,
+ GlDisclosureDropdown,
} from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { n__, s__, __ } from '~/locale';
+import { n__, s__ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
+import { TYPE_ISSUE } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import AccessorUtilities from '~/lib/utils/accessor';
-import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
+import {
+ inactiveId,
+ LIST,
+ ListType,
+ toggleFormEventPrefix,
+ updateListQueries,
+ toggleCollapsedMutations,
+} from 'ee_else_ce/boards/constants';
import eventHub from '../eventhub';
import ItemCount from './item_count.vue';
export default {
i18n: {
- newIssue: __('New issue'),
- newEpic: s__('Boards|New epic'),
- listSettings: __('List settings'),
+ newIssue: s__('Boards|Create new issue'),
+ listActions: s__('Boards|List actions'),
+ newEpic: s__('Boards|Create new epic'),
+ listSettings: s__('Boards|Edit list settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
},
components: {
- GlButtonGroup,
+ GlDisclosureDropdown,
GlButton,
GlLabel,
GlTooltip,
@@ -63,6 +73,12 @@ export default {
disabled: {
default: true,
},
+ issuableType: {
+ default: TYPE_ISSUE,
+ },
+ isApolloBoard: {
+ default: false,
+ },
},
props: {
list: {
@@ -75,16 +91,26 @@ export default {
required: false,
default: false,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['activeId', 'filterParams', 'boardId']),
- ...mapGetters(['isSwimlanesOn']),
+ ...mapState(['activeId']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
listType() {
return this.list.listType;
},
+ itemsCount() {
+ return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
+ },
listAssignee() {
return this.list?.assignee?.username || '';
},
@@ -111,7 +137,10 @@ export default {
},
showListHeaderActions() {
if (this.isLoggedIn) {
- return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown;
+ return (
+ (this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown) &&
+ !this.list.collapsed
+ );
}
return false;
},
@@ -162,6 +191,50 @@ export default {
canShowTotalWeight() {
return this.weightFeatureAvailable && !this.isLoading;
},
+ actionListItems() {
+ const items = [];
+
+ if (this.isNewIssueShown) {
+ const newIssueText = this.$options.i18n.newIssue;
+ items.push({
+ text: newIssueText,
+ action: this.showNewIssueForm,
+ extraAttrs: {
+ 'data-testid': 'newIssueBtn',
+ title: newIssueText,
+ 'aria-label': newIssueText,
+ },
+ });
+ }
+
+ if (this.isNewEpicShown) {
+ const newEpicText = this.$options.i18n.newEpic;
+ items.push({
+ text: newEpicText,
+ action: this.showNewEpicForm,
+ extraAttrs: {
+ 'data-testid': 'newEpicBtn',
+ title: newEpicText,
+ 'aria-label': newEpicText,
+ },
+ });
+ }
+
+ if (this.isSettingsShown) {
+ const listSettingsText = this.$options.i18n.listSettings;
+ items.push({
+ text: listSettingsText,
+ action: this.openSidebarSettings,
+ extraAttrs: {
+ 'data-testid': 'settingsBtn',
+ title: listSettingsText,
+ 'aria-label': listSettingsText,
+ },
+ });
+ }
+
+ return items;
+ },
},
apollo: {
boardList: {
@@ -175,34 +248,43 @@ export default {
context: {
isSingleRequest: true,
},
- skip() {
- return this.isEpicBoard;
- },
},
},
created() {
const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) {
- this.toggleListCollapsed({ listId: this.list.id, collapsed: true });
+ this.updateLocalCollapsedStatus(true);
}
},
methods: {
...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
+ closeListActions() {
+ this.$refs.headerListActions?.close();
+ },
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
}
- this.setActiveId({ id: this.list.id, sidebarType: LIST });
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: { boardItem: null },
+ });
+ this.$emit('setActiveList', this.list.id);
+ } else {
+ this.setActiveId({ id: this.list.id, sidebarType: LIST });
+ }
this.track('click_button', { label: 'list_settings' });
+
+ this.closeListActions();
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
-
showNewIssueForm() {
- if (this.isSwimlanesOn) {
+ if (this.isSwimlanesHeader) {
eventHub.$emit('open-unassigned-lane');
this.$nextTick(() => {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
@@ -210,18 +292,22 @@ export default {
} else {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
}
+
+ this.closeListActions();
},
showNewEpicForm() {
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
+
+ this.closeListActions();
},
toggleExpanded() {
const collapsed = !this.list.collapsed;
- this.toggleListCollapsed({ listId: this.list.id, collapsed });
+ this.updateLocalCollapsedStatus(collapsed);
if (!this.isLoggedIn) {
- this.addToLocalStorage();
+ this.addToLocalStorage(collapsed);
} else {
- this.updateListFunction();
+ this.updateListFunction(collapsed);
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
@@ -233,13 +319,37 @@ export default {
property: collapsed ? 'closed' : 'open',
});
},
- addToLocalStorage() {
+ addToLocalStorage(collapsed) {
if (AccessorUtilities.canUseLocalStorage()) {
- localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
+ localStorage.setItem(`${this.uniqueKey}.collapsed`, collapsed);
}
},
- updateListFunction() {
- this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
+ async updateListFunction(collapsed) {
+ if (this.isApolloBoard) {
+ try {
+ await this.$apollo.mutate({
+ mutation: updateListQueries[this.issuableType].mutation,
+ variables: {
+ listId: this.list.id,
+ collapsed,
+ },
+ optimisticResponse: {
+ updateBoardList: {
+ __typename: 'UpdateBoardListPayload',
+ errors: [],
+ list: {
+ ...this.list,
+ collapsed,
+ },
+ },
+ },
+ });
+ } catch {
+ this.$emit('error');
+ }
+ } else {
+ this.updateList({ listId: this.list.id, collapsed });
+ }
},
/**
* TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
@@ -251,6 +361,19 @@ export default {
const due = formatDate(dueDate, 'mmm d, yyyy', true);
return `${start} - ${due}`;
},
+ updateLocalCollapsedStatus(collapsed) {
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: toggleCollapsedMutations[this.issuableType].mutation,
+ variables: {
+ list: this.list,
+ collapsed,
+ },
+ });
+ } else {
+ this.toggleListCollapsed({ listId: this.list.id, collapsed });
+ }
+ },
},
};
</script>
@@ -364,7 +487,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>
@@ -392,7 +515,7 @@ export default {
<gl-icon class="gl-mr-2" :name="countIcon" :size="14" />
<item-count
v-if="!isLoading"
- :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount"
+ :items-size="itemsCount"
:max-issue-count="list.maxIssueCount"
/>
</span>
@@ -407,44 +530,24 @@ export default {
<!-- EE end -->
</span>
</div>
- <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2">
- <gl-button
- v-if="isNewIssueShown"
- v-show="!list.collapsed"
- ref="newIssueBtn"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.newIssue"
- :title="$options.i18n.newIssue"
- class="no-drag"
- size="small"
- icon="plus"
- @click="showNewIssueForm"
- />
-
- <gl-button
- v-if="isNewEpicShown"
- v-show="!list.collapsed"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.newEpic"
- :title="$options.i18n.newEpic"
- class="no-drag"
- size="small"
- icon="plus"
- @click="showNewEpicForm"
- />
-
- <gl-button
- v-if="isSettingsShown"
- ref="settingsBtn"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.listSettings"
- class="no-drag"
- size="small"
- :title="$options.i18n.listSettings"
- icon="settings"
- @click="openSidebarSettings"
- />
- </gl-button-group>
+ <gl-disclosure-dropdown
+ v-if="showListHeaderActions"
+ ref="headerListActions"
+ v-gl-tooltip.hover.top="{
+ title: $options.i18n.listActions,
+ boundary: 'viewport',
+ }"
+ data-testid="header-list-actions"
+ class="gl-py-2 gl-ml-3"
+ :aria-label="$options.i18n.listActions"
+ :title="$options.i18n.listActions"
+ category="tertiary"
+ icon="ellipsis_v"
+ :text-sr-only="true"
+ :items="actionListItems"
+ no-caret
+ placement="right"
+ />
</h3>
</header>
</template>
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index c0c2699b63d..23e0f2510a7 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,8 +1,15 @@
<script>
+import produce from 'immer';
import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
+import {
+ LIST,
+ ListType,
+ ListTypeTitles,
+ listsQuery,
+ deleteListQueries,
+} from 'ee_else_ce/boards/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
@@ -31,8 +38,34 @@ export default {
GlModal: GlModalDirective,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['canAdminList', 'scopedLabelsAvailable', 'isIssueBoard'],
+ inject: [
+ 'boardType',
+ 'canAdminList',
+ 'issuableType',
+ 'scopedLabelsAvailable',
+ 'isIssueBoard',
+ 'isApolloBoard',
+ ],
inheritAttrs: false,
+ props: {
+ listId: {
+ type: String,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
ListType,
@@ -45,8 +78,11 @@ export default {
isWipLimitsOn() {
return this.glFeatures.wipLimits && this.isIssueBoard;
},
+ activeListId() {
+ return this.isApolloBoard ? this.listId : this.activeId;
+ },
activeList() {
- return this.boardLists[this.activeId] || {};
+ return (this.isApolloBoard ? this.list : this.boardLists[this.activeId]) || {};
},
activeListLabel() {
return this.activeList.label;
@@ -58,27 +94,60 @@ export default {
return ListTypeTitles[ListType.label];
},
showSidebar() {
+ if (this.isApolloBoard) {
+ return Boolean(this.listId);
+ }
return this.sidebarType === LIST && this.isSidebarOpen;
},
},
created() {
- eventHub.$on('sidebar.closeAll', this.unsetActiveId);
+ eventHub.$on('sidebar.closeAll', this.unsetActiveListId);
},
beforeDestroy() {
- eventHub.$off('sidebar.closeAll', this.unsetActiveId);
+ eventHub.$off('sidebar.closeAll', this.unsetActiveListId);
},
methods: {
...mapActions(['unsetActiveId', 'removeList']),
handleModalPrimary() {
- this.deleteBoard();
+ this.deleteBoardList();
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
- deleteBoard() {
+ async deleteBoardList() {
this.track('click_button', { label: 'remove_list' });
- this.removeList(this.activeId);
- this.unsetActiveId();
+ if (this.isApolloBoard) {
+ await this.deleteList(this.activeListId);
+ } else {
+ this.removeList(this.activeId);
+ }
+ this.unsetActiveListId();
+ },
+ unsetActiveListId() {
+ if (this.isApolloBoard) {
+ this.$emit('unsetActiveId');
+ } else {
+ this.unsetActiveId();
+ }
+ },
+ async deleteList(listId) {
+ await this.$apollo.mutate({
+ mutation: deleteListQueries[this.issuableType].mutation,
+ variables: {
+ listId,
+ },
+ update: (store) => {
+ store.updateQuery(
+ { query: listsQuery[this.issuableType].query, variables: this.queryVariables },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ draftData[this.boardType].board.lists.nodes = draftData[
+ this.boardType
+ ].board.lists.nodes.filter((list) => list.id !== listId);
+ }),
+ );
+ },
+ });
},
},
};
@@ -91,7 +160,7 @@ export default {
class="js-board-settings-sidebar gl-absolute"
:open="showSidebar"
variant="sidebar"
- @close="unsetActiveId"
+ @close="unsetActiveListId"
>
<template #title>
<h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
@@ -109,7 +178,7 @@ export default {
</gl-button>
</div>
</template>
- <template v-if="isSidebarOpen">
+ <template v-if="showSidebar">
<div v-if="boardListType === ListType.label">
<label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
<gl-label
@@ -127,6 +196,7 @@ export default {
<board-settings-sidebar-wip-limit
v-if="isWipLimitsOn"
:max-issue-count="activeList.maxIssueCount"
+ :active-list-id="activeListId"
/>
</template>
</gl-drawer>
@@ -136,11 +206,11 @@ export default {
size="sm"
:action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: $options.i18n.modalAction,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
:action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: $options.i18n.modalCancel,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index 2e20ed70bb0..c186346b2ac 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -35,6 +35,10 @@ export default {
type: String,
required: true,
},
+ isSwimlanesOn: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -56,10 +60,28 @@ export default {
return !this.isApolloBoard;
},
update(data) {
- return data.workspace.board;
+ const { board } = data.workspace;
+ return {
+ ...board,
+ labels: board.labels?.nodes,
+ };
},
},
},
+ computed: {
+ hasScope() {
+ if (this.board.labels?.length > 0) {
+ return true;
+ }
+ let hasScope = false;
+ ['assignee', 'iterationCadence', 'iteration', 'milestone', 'weight'].forEach((attr) => {
+ if (this.board[attr] !== null && this.board[attr] !== undefined) {
+ hasScope = true;
+ }
+ });
+ return hasScope;
+ },
+ },
};
</script>
@@ -73,15 +95,28 @@ export default {
>
<boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" />
<new-board-button />
- <issue-board-filtered-search v-if="isIssueBoard" />
- <epic-board-filtered-search v-else />
+ <issue-board-filtered-search
+ v-if="isIssueBoard"
+ :board="board"
+ :is-swimlanes-on="isSwimlanesOn"
+ @setFilters="$emit('setFilters', $event)"
+ />
+ <epic-board-filtered-search
+ v-else
+ :board="board"
+ @setFilters="$emit('setFilters', $event)"
+ />
</div>
<div
class="filter-dropdown-container gl-md-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start"
>
<toggle-labels />
- <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" />
- <config-toggle />
+ <toggle-epics-swimlanes
+ v-if="swimlanesFeatureAvailable && isSignedIn"
+ :is-swimlanes-on="isSwimlanesOn"
+ @toggleSwimlanes="$emit('toggleSwimlanes', $event)"
+ />
+ <config-toggle :board-has-scope="hasScope" />
<board-add-new-column-trigger v-if="canAdminList" />
<toggle-focus />
</div>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index a1a49386b37..fddb58c45fe 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -8,6 +8,7 @@ import {
GlDropdownItem,
GlModalDirective,
} from '@gitlab/ui';
+import { produce } from 'immer';
import { throttle } from 'lodash';
import { mapActions, mapState } from 'vuex';
@@ -89,6 +90,9 @@ export default {
parentType() {
return this.boardType;
},
+ boardQuery() {
+ return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
+ },
loading() {
return this.loadingRecentBoards || this.loadingBoards;
},
@@ -140,6 +144,9 @@ export default {
},
methods: {
...mapActions(['setError', 'fetchBoard', 'unsetActiveId']),
+ fullBoardId(boardId) {
+ return fullBoardId(boardId);
+ },
showPage(page) {
this.currentPage = page;
},
@@ -155,9 +162,6 @@ export default {
name: node.name,
}));
},
- boardQuery() {
- return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
- },
recentBoardsQuery() {
return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
},
@@ -191,6 +195,29 @@ export default {
},
});
},
+ addBoard(board) {
+ const { defaultClient: store } = this.$apollo.provider.clients;
+
+ const sourceData = store.readQuery({
+ query: this.boardQuery,
+ variables: { fullPath: this.fullPath },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ draftState[this.parentType].boards.edges = [
+ ...draftState[this.parentType].boards.edges,
+ { node: board },
+ ];
+ });
+
+ store.writeQuery({
+ query: this.boardQuery,
+ variables: { fullPath: this.fullPath },
+ data: newData,
+ });
+
+ this.$emit('switchBoard', board.id);
+ },
isScrolledUp() {
const { content } = this.$refs;
@@ -226,14 +253,13 @@ export default {
boardType: this.boardType,
});
},
- fullBoardId(boardId) {
- return fullBoardId(boardId);
- },
async switchBoard(boardId, e) {
if (isMetaKey(e)) {
window.open(`${this.boardBaseUrl}/${boardId}`, '_blank');
} else if (this.isApolloBoard) {
+ // Epic board ID is supported in EE version of this file
this.$emit('switchBoard', this.fullBoardId(boardId));
+ updateHistory({ url: `${this.boardBaseUrl}/${boardId}` });
} else {
this.unsetActiveId();
this.fetchCurrentBoard(boardId);
@@ -357,6 +383,7 @@ export default {
:weights="weights"
:current-board="boardToUse"
:current-page="currentPage"
+ @addBoard="addBoard"
@cancel="cancel"
/>
</span>
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 7002fd44294..dd3b9472879 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -16,6 +16,13 @@ export default {
},
mixins: [Tracking.mixin()],
inject: ['canAdminList'],
+ props: {
+ boardHasScope: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
...mapGetters(['hasScope']),
buttonText() {
@@ -40,7 +47,7 @@ export default {
v-gl-modal-directive="'board-config-modal'"
v-gl-tooltip
:title="tooltipTitle"
- :class="{ 'dot-highlight': hasScope }"
+ :class="{ 'dot-highlight': hasScope || boardHasScope }"
data-qa-selector="boards_config_button"
@click.prevent="showPage"
>
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 7749391ec6f..3c056f296e1 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -1,12 +1,11 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { mapActions } from 'vuex';
import { orderBy } from 'lodash';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import issueBoardFilters from '~/boards/issue_board_filters';
+import issueBoardFilters from 'ee_else_ce/boards/issue_board_filters';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
@@ -47,11 +46,23 @@ export default {
},
components: { BoardFilteredSearch },
inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'],
+ props: {
+ board: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
tokensCE() {
const { issue, incident } = this.$options.i18n;
const { types } = this.$options;
- const { fetchUsers, fetchLabels } = issueBoardFilters(
+ const { fetchUsers, fetchLabels, fetchMilestones } = issueBoardFilters(
this.$apollo,
this.fullPath,
this.isGroupBoard,
@@ -135,7 +146,7 @@ export default {
token: MilestoneToken,
unique: true,
shouldSkipSort: true,
- fetchMilestones: this.fetchMilestones,
+ fetchMilestones,
},
{
icon: 'issues',
@@ -176,7 +187,6 @@ export default {
},
},
methods: {
- ...mapActions(['fetchMilestones']),
preloadedUsers() {
return gon?.current_user_id
? [
@@ -194,5 +204,11 @@ export default {
</script>
<template>
- <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" />
+ <board-filtered-search
+ 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 712e3e1ac4a..7fe89ffbb52 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,25 +1,18 @@
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { s__, __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
+import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql';
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
-/* eslint-disable-next-line @gitlab/require-i18n-strings */
-export const AssigneeIdParamValues = ['Any', 'None'];
-
-export const issuableTypes = {
- issue: 'issue',
- epic: 'epic',
-};
-
export const BoardType = {
project: 'project',
group: 'group',
@@ -64,10 +57,10 @@ export const INCIDENT = 'INCIDENT';
export const flashAnimationDuration = 2000;
export const boardQuery = {
- [BoardType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupBoardQuery,
},
- [BoardType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectBoardQuery,
},
};
@@ -84,6 +77,12 @@ export const updateListQueries = {
},
};
+export const toggleCollapsedMutations = {
+ [TYPE_ISSUE]: {
+ mutation: toggleListCollapsedMutation,
+ },
+};
+
export const deleteListQueries = {
[TYPE_ISSUE]: {
mutation: destroyBoardListMutation,
@@ -94,7 +93,7 @@ export const titleQueries = {
[TYPE_ISSUE]: {
mutation: issueSetTitleMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicTitleMutation,
},
};
@@ -103,7 +102,7 @@ export const subscriptionQueries = {
[TYPE_ISSUE]: {
mutation: issueSetSubscriptionMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicSubscriptionMutation,
},
};
@@ -143,6 +142,7 @@ export const MilestoneFilterType = {
started: 'Started',
upcoming: 'Upcoming',
};
+/* eslint-enable @gitlab/require-i18n-strings */
export const DraggableItemTypes = {
card: 'card',
@@ -155,7 +155,6 @@ export const MilestoneIDs = {
};
export default {
- BoardType,
ListType,
};
@@ -178,3 +177,5 @@ export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [
action: () => {},
},
];
+
+export const GroupByParamType = {};
diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
index 06e8c8783de..e987ee08df2 100644
--- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
@@ -3,6 +3,7 @@
query BoardLists(
$fullPath: ID!
$boardId: BoardID!
+ $listId: ListID
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
@@ -12,7 +13,7 @@ query BoardLists(
board(id: $boardId) {
id
hideBacklogList
- lists(issueFilters: $filters) {
+ lists(issueFilters: $filters, id: $listId) {
nodes {
...BoardListFragment
}
@@ -24,7 +25,7 @@ query BoardLists(
board(id: $boardId) {
id
hideBacklogList
- lists(issueFilters: $filters) {
+ lists(issueFilters: $filters, id: $listId) {
nodes {
...BoardListFragment
}
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/board_toggle_collapsed.mutation.graphql b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql
new file mode 100644
index 00000000000..890152989eb
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql
@@ -0,0 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
+
+mutation toggleListCollapsed($list: BoardList!, $collapsed: Boolean!) {
+ clientToggleListCollapsed(list: $list, collapsed: $collapsed) @client {
+ list {
+ ...BoardListFragment
+ }
+ }
+}
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/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
index 9e6c26063e9..14811b435e1 100644
--- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
@@ -1,5 +1,5 @@
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
- group(fullPath: $fullPath) {
+ workspace: group(fullPath: $fullPath) {
id
milestones(
includeAncestors: true
diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
index 02aa08f90ef..9af92a6ff2d 100644
--- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
@@ -1,5 +1,5 @@
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
id
milestones(
searchTitle: $searchTerm
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 4c6f341828c..67388284d31 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -3,9 +3,8 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
-import { BoardType } from '~/boards/constants';
import store from '~/boards/stores';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import {
NavigationType,
isLoggedIn,
@@ -68,8 +67,8 @@ function mountBoardApp(el) {
initialFilterParams,
boardBaseUrl: el.dataset.boardBaseUrl,
boardType,
- isGroupBoard: boardType === BoardType.group,
- isProjectBoard: boardType === BoardType.project,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
currentUserId: gon.current_user_id || null,
boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
labelsManagePath: el.dataset.labelsManagePath,
diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js
index 7e9b68778d5..27efb3f775c 100644
--- a/app/assets/javascripts/boards/issue_board_filters.js
+++ b/app/assets/javascripts/boards/issue_board_filters.js
@@ -1,5 +1,7 @@
import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql';
import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql';
+import groupBoardMilestonesQuery from './graphql/group_board_milestones.query.graphql';
+import projectBoardMilestonesQuery from './graphql/project_board_milestones.query.graphql';
import boardLabels from './graphql/board_labels.query.graphql';
export default function issueBoardFilters(apollo, fullPath, isGroupBoard) {
@@ -37,8 +39,27 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) {
.then(transformLabels);
};
+ const fetchMilestones = (searchTerm) => {
+ const variables = {
+ fullPath,
+ searchTerm,
+ };
+
+ const query = isGroupBoard ? groupBoardMilestonesQuery : projectBoardMilestonesQuery;
+
+ return apollo
+ .query({
+ query,
+ variables,
+ })
+ .then(({ data }) => {
+ return data.workspace?.milestones.nodes;
+ });
+ };
+
return {
fetchLabels,
fetchUsers,
+ fetchMilestones,
};
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 1b4e6334723..a144054d680 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/browser';
import { sortBy } from 'lodash';
import {
- BoardType,
ListType,
inactiveId,
flashAnimationDuration,
@@ -34,7 +33,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe
import { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -61,7 +60,7 @@ export default {
return gqlClient
.query({
- query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery,
+ query: boardType === WORKSPACE_GROUP ? groupBoardQuery : projectBoardQuery,
variables,
})
.then(({ data }) => {
@@ -139,8 +138,8 @@ export default {
boardId: fullBoardId,
filters: filterParams,
...(issuableType === TYPE_ISSUE && {
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
}),
};
@@ -234,8 +233,8 @@ export default {
const variables = {
fullPath,
searchTerm,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
};
commit(types.RECEIVE_LABELS_REQUEST);
@@ -268,10 +267,10 @@ export default {
};
let query;
- if (boardType === BoardType.project) {
+ if (boardType === WORKSPACE_PROJECT) {
query = projectBoardMilestonesQuery;
}
- if (boardType === BoardType.group) {
+ if (boardType === WORKSPACE_GROUP) {
query = groupBoardMilestonesQuery;
}
@@ -286,8 +285,8 @@ export default {
variables,
})
.then(({ data }) => {
- const errors = data[boardType]?.errors;
- const milestones = data[boardType]?.milestones.nodes;
+ const errors = data.workspace?.errors;
+ const milestones = data.workspace?.milestones.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
@@ -431,8 +430,8 @@ export default {
boardId: fullBoardId,
id: listId,
filters: filterParams,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
@@ -710,7 +709,7 @@ export default {
) => {
const input = formatIssueInput(issueInput, boardConfig);
- if (boardType === BoardType.project) {
+ if (boardType === WORKSPACE_PROJECT) {
input.projectPath = fullPath;
}
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index fef5862f319..505c011b034 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -1,18 +1,19 @@
import { cloneDeep, pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_EPIC } from '~/issues/constants';
import { s__, __ } from '~/locale';
import { formatIssue } from '../boards_util';
-import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
- if (state.issuableType === issuableTypes.epic) {
- Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
- } else {
- Vue.set(state.boardLists, listId, { ...list });
+ if (state.issuableType === TYPE_EPIC) {
+ const listItem = cloneDeep(state.boardLists[listId]);
+ listItem.metadataepicsCount += value;
+ Vue.set(state.boardLists[listId], listId, listItem);
}
+ Vue.set(state.boardLists, listId, { ...list });
};
export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => {
diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
index 70974f2e725..d9d8f1d742d 100644
--- a/app/assets/javascripts/branches/components/delete_merged_branches.vue
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -1,11 +1,10 @@
<script>
-import { GlButton, GlFormInput, GlModal, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { sprintf, s__, __ } from '~/locale';
export const i18n = {
deleteButtonText: s__('Branches|Delete merged branches'),
- buttonTooltipText: s__("Branches|Delete all branches that are merged into '%{defaultBranch}'"),
modalTitle: s__('Branches|Delete all merged branches?'),
modalMessage: s__(
'Branches|You are about to %{strongStart}delete all branches%{strongEnd} that were merged into %{codeStart}%{defaultBranch}%{codeEnd}.',
@@ -28,14 +27,12 @@ export const i18n = {
export default {
csrf,
components: {
- GlModal,
+ GlDisclosureDropdown,
GlButton,
+ GlModal,
GlFormInput,
GlSprintf,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
formPath: {
type: String,
@@ -53,9 +50,6 @@ export default {
};
},
computed: {
- buttonTooltipText() {
- return sprintf(this.$options.i18n.buttonTooltipText, { defaultBranch: this.defaultBranch });
- },
modalMessage() {
return sprintf(this.$options.i18n.modalMessage, {
defaultBranch: this.defaultBranch,
@@ -67,6 +61,20 @@ export default {
isDeleteButtonDisabled() {
return !this.isDeletingConfirmed;
},
+ dropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.deleteButtonText,
+ action: () => {
+ this.openModal();
+ },
+ extraAttrs: {
+ 'data-qa-selector': 'delete_merged_branches_button',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
},
methods: {
openModal() {
@@ -87,15 +95,16 @@ export default {
<template>
<div>
- <gl-button
- v-gl-tooltip="buttonTooltipText"
- class="gl-mr-3"
- data-qa-selector="delete_merged_branches_button"
- category="secondary"
- variant="danger"
- @click="openModal"
- >{{ $options.i18n.deleteButtonText }}
- </gl-button>
+ <gl-disclosure-dropdown
+ :toggle-text="$options.i18n.actionsToggleText"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ placement="right"
+ data-qa-selector="delete_merged_branches_dropdown_button"
+ :items="dropdownItems"
+ />
<gl-modal
ref="modal"
size="sm"
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index d05b53f1a50..54abc9c45a7 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import DivergenceGraph from './components/divergence_graph.vue';
diff --git a/app/assets/javascripts/branches/init_new_branch_ref_selector.js b/app/assets/javascripts/branches/init_new_branch_ref_selector.js
index aad3fbb9982..532242ac12d 100644
--- a/app/assets/javascripts/branches/init_new_branch_ref_selector.js
+++ b/app/assets/javascripts/branches/init_new_branch_ref_selector.js
@@ -17,6 +17,7 @@ export default function initNewBranchRefSelector() {
props: {
value: defaultBranchName,
name: hiddenInputName,
+ queryParams: { sort: 'updated_desc' },
projectId,
},
});
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..c89df2610eb 100644
--- a/app/assets/javascripts/artifacts/components/app.vue
+++ b/app/assets/javascripts/ci/artifacts/components/app.vue
@@ -18,12 +18,8 @@ export default {
variables() {
return { projectPath: this.projectPath };
},
- update({
- project: {
- statistics: { buildArtifactsSize },
- },
- }) {
- return buildArtifactsSize;
+ update({ project: { statistics } }) {
+ return statistics?.buildArtifactsSize ?? null;
},
},
},
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 fffdfce60a7..5b1c322f07a 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
@@ -1,7 +1,21 @@
<script>
-import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
+import {
+ GlButtonGroup,
+ GlButton,
+ GlBadge,
+ GlFriendlyWrap,
+ GlFormCheckbox,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ I18N_EXPIRED,
+ I18N_DOWNLOAD,
+ I18N_DELETE,
+ BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_MAX_SELECTED,
+} from '../constants';
export default {
name: 'ArtifactRow',
@@ -10,17 +24,30 @@ export default {
GlButton,
GlBadge,
GlFriendlyWrap,
+ GlFormCheckbox,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
inject: ['canDestroyArtifacts'],
props: {
artifact: {
type: Object,
required: true,
},
+ isSelected: {
+ type: Boolean,
+ required: true,
+ },
isLastRow: {
type: Boolean,
required: true,
},
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
isExpired() {
@@ -29,9 +56,25 @@ export default {
}
return Date.now() > new Date(this.artifact.expireAt).getTime();
},
+ isCheckboxDisabled() {
+ return this.isSelectedArtifactsLimitReached && !this.isSelected;
+ },
+ checkboxTooltip() {
+ return this.isCheckboxDisabled ? I18N_BULK_DELETE_MAX_SELECTED : '';
+ },
artifactSize() {
return numberToHumanSize(this.artifact.size);
},
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked === this.isSelected) return;
+
+ this.$emit('selectArtifact', this.artifact, checked);
+ },
},
i18n: {
expired: I18N_EXPIRED,
@@ -46,6 +89,15 @@ export default {
:class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }"
>
<div class="gl-display-inline-flex gl-align-items-center gl-w-full">
+ <span v-if="canBulkDestroyArtifacts" class="gl-pl-5">
+ <gl-form-checkbox
+ v-gl-tooltip.right
+ :title="checkboxTooltip"
+ :checked="isSelected"
+ :disabled="isCheckboxDisabled"
+ @input="handleInput"
+ />
+ </span>
<span
class="gl-w-half gl-pl-8 gl-display-flex gl-align-items-center"
data-testid="job-artifact-row-name"
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..c176532482f
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlButton, GlSprintf, GlAlert } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+ I18N_BULK_DELETE_MAX_SELECTED,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlSprintf,
+ GlAlert,
+ },
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ maxSelected: I18N_BULK_DELETE_MAX_SELECTED,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="isSelectedArtifactsLimitReached" variant="warning" :dismissible="false">
+ {{ $options.i18n.maxSelected }}
+ </gl-alert>
+
+ <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>
+ </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 4a826d0d462..e893040edd8 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import destroyArtifactMutation from '../graphql/mutations/destroy_artifact.mutation.graphql';
@@ -25,10 +25,18 @@ export default {
type: Object,
required: true,
},
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
},
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -52,6 +60,9 @@ export default {
isLastRow(index) {
return index === this.artifacts.nodes.length - 1;
},
+ isSelected(item) {
+ return this.selectedArtifacts.includes(item.id);
+ },
showModal(item) {
this.deletingArtifactId = item.id;
this.deletingArtifactName = item.name;
@@ -92,13 +103,16 @@ export default {
};
</script>
<template>
- <div :style="scrollContainerStyle">
+ <div :style="scrollContainerStyle" class="gl-overflow-auto">
<dynamic-scroller :items="artifacts.nodes" :min-item-size="$options.ARTIFACT_ROW_HEIGHT">
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<artifact-row
:artifact="item"
+ :is-selected="isSelected(item)"
:is-last-row="isLastRow(index)"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ v-on="$listeners"
@delete="showModal(item)"
/>
</dynamic-scroller-item>
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 5743ff3ec9e..3f6ea56382f 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
@@ -8,13 +8,18 @@ import {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
+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,
@@ -33,7 +38,15 @@ import {
INITIAL_NEXT_PAGE_CURSOR,
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,
+ SELECTED_ARTIFACTS_MAX_COUNT,
} 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';
@@ -56,21 +69,25 @@ export default {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
CiIcon,
TimeAgo,
+ JobCheckbox,
+ ArtifactsBulkDelete,
+ BulkDeleteModal,
ArtifactsTableRowDetails,
FeedbackBanner,
},
- inject: ['projectPath', 'canDestroyArtifacts'],
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectId', 'projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
query: getJobArtifactsQuery,
variables() {
return this.queryVariables;
},
- update({ project: { jobs: { nodes = [], pageInfo = {}, count = 0 } = {} } }) {
+ update({ project: { jobs: { nodes = [], pageInfo = {} } = {} } }) {
this.pageInfo = pageInfo;
- this.count = count;
return nodes
.map(mapArchivesToJobNodes)
.map(mapBooleansToJobNodes)
@@ -93,10 +110,13 @@ export default {
data() {
return {
jobArtifacts: [],
- count: 0,
pageInfo: {},
expandedJobs: [],
+ selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
+ isBulkDeleteModalVisible: false,
+ jobArtifactsToDelete: [],
+ isBulkDeleting: false,
};
},
computed: {
@@ -110,7 +130,9 @@ export default {
};
},
showPagination() {
- return this.count > JOBS_PER_PAGE;
+ const { hasNextPage, hasPreviousPage } = this.pageInfo;
+
+ return hasNextPage || hasPreviousPage;
},
prevPage() {
return Number(this.pageInfo.hasPreviousPage);
@@ -118,6 +140,30 @@ export default {
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
+ fields() {
+ return [
+ this.canBulkDestroyArtifacts && {
+ key: 'checkbox',
+ label: '',
+ },
+ ...this.$options.fields,
+ ];
+ },
+ anyArtifactsSelected() {
+ return Boolean(this.selectedArtifacts.length);
+ },
+ isSelectedArtifactsLimitReached() {
+ return this.selectedArtifacts.length >= SELECTED_ARTIFACTS_MAX_COUNT;
+ },
+ 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() {
@@ -158,6 +204,79 @@ export default {
this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
}
},
+ selectArtifact(artifactNode, checked) {
+ if (checked) {
+ if (!this.isSelectedArtifactsLimitReached) {
+ this.selectedArtifacts.push(artifactNode.id);
+ }
+ } else {
+ 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 = [];
+ },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -167,6 +286,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: [
{
@@ -217,9 +343,23 @@ export default {
<template>
<div>
<feedback-banner />
+ <artifacts-bulk-delete
+ v-if="canBulkDestroyArtifacts"
+ :selected-artifacts="selectedArtifacts"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ @clearSelectedArtifacts="clearSelectedArtifacts"
+ @showBulkDeleteModal="handleBulkDeleteModalShow"
+ />
+ <bulk-delete-modal
+ :visible="isBulkDeleteModalVisible"
+ :artifacts-to-delete="artifactsToDelete"
+ :is-deleting="isBulkDeleting"
+ @primary="onConfirmBulkDelete"
+ @hidden="handleBulkDeleteModalHidden"
+ />
<gl-table
:items="jobArtifacts"
- :fields="$options.fields"
+ :fields="fields"
:busy="$apollo.queries.jobArtifacts.loading"
stacked="sm"
details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto"
@@ -227,6 +367,30 @@ export default {
<template #table-busy>
<gl-loading-icon size="lg" />
</template>
+ <template v-if="canBulkDestroyArtifacts" #head(checkbox)>
+ <gl-form-checkbox
+ :disabled="!anyArtifactsSelected"
+ :checked="anyArtifactsSelected"
+ :indeterminate="anyArtifactsSelected"
+ @change="clearSelectedArtifacts"
+ />
+ </template>
+ <template
+ v-if="canBulkDestroyArtifacts"
+ #cell(checkbox)="{ item: { hasArtifacts, artifacts } }"
+ >
+ <job-checkbox
+ :has-artifacts="hasArtifacts"
+ :selected-artifacts="
+ artifacts.nodes.filter((node) => selectedArtifacts.includes(node.id))
+ "
+ :unselected-artifacts="
+ artifacts.nodes.filter((node) => !selectedArtifacts.includes(node.id))
+ "
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ @selectArtifact="selectArtifact"
+ />
+ </template>
<template
#cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
>
@@ -313,18 +477,22 @@ 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>
<template #row-details="{ item: { artifacts } }">
<artifacts-table-row-details
:artifacts="artifacts"
+ :selected-artifacts="selectedArtifacts"
:query-variables="queryVariables"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
@refetch="refetchArtifacts"
+ @selectArtifact="selectArtifact"
/>
</template>
</gl-table>
diff --git a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
new file mode 100644
index 00000000000..91296bd507e
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+
+export default {
+ name: 'JobCheckbox',
+ components: {
+ GlFormCheckbox,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ hasArtifacts: {
+ type: Boolean,
+ required: true,
+ },
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ unselectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return (
+ !this.hasArtifacts ||
+ (this.isSelectedArtifactsLimitReached && !(this.checked || this.indeterminate))
+ );
+ },
+ checked() {
+ return this.hasArtifacts && this.unselectedArtifacts.length === 0;
+ },
+ indeterminate() {
+ return this.selectedArtifacts.length > 0 && this.unselectedArtifacts.length > 0;
+ },
+ tooltipText() {
+ return this.isSelectedArtifactsLimitReached && this.disabled
+ ? I18N_BULK_DELETE_MAX_SELECTED
+ : '';
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked) {
+ this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true));
+ } else {
+ this.selectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, false));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-checkbox
+ v-gl-tooltip.right
+ :title="tooltipText"
+ :disabled="disabled"
+ :checked="checked"
+ :indeterminate="indeterminate"
+ @input="handleInput"
+ />
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js
index da562b03bf8..7ba65e0f98f 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/ci/artifacts/constants.js
@@ -54,6 +54,49 @@ export const I18N_FEEDBACK_BANNER_BODY = s__(
export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
+export const BULK_DELETE_FEATURE_FLAG = 'ciJobArtifactBulkDestroy';
+export const SELECTED_ARTIFACTS_MAX_COUNT = 50;
+export const I18N_BULK_DELETE_MAX_SELECTED = s__(
+ 'Artifacts|Maximum selected artifacts limit reached',
+);
+export const I18N_BULK_DELETE_BANNER = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifact selected',
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifacts selected',
+ count,
+ ),
+ {
+ count,
+ },
+ );
+export const I18N_BULK_DELETE_CLEAR_SELECTION = s__('Artifacts|Clear selection');
+export const I18N_BULK_DELETE_DELETE_SELECTED = s__('Artifacts|Delete selected');
+
+export const BULK_DELETE_MODAL_ID = 'artifacts-bulk-delete-modal';
+export const I18N_BULK_DELETE_MODAL_TITLE = (count) =>
+ n__('Artifacts|Delete %d artifact?', 'Artifacts|Delete %d artifacts?', count);
+export const I18N_BULK_DELETE_BODY = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|The selected artifact will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ 'Artifacts|The selected artifacts will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ count,
+ ),
+ { count },
+ );
+export const I18N_BULK_DELETE_ACTION = (count) =>
+ n__('Artifacts|Delete %d artifact', 'Artifacts|Delete %d artifacts', count);
+
+export const I18N_BULK_DELETE_PARTIAL_ERROR = s__(
+ 'Artifacts|An error occurred while deleting. Some artifacts may not have been deleted.',
+);
+export const I18N_BULK_DELETE_ERROR = s__(
+ 'Artifacts|Something went wrong while deleting. Please refresh the page to try again.',
+);
+export const I18N_BULK_DELETE_CONFIRMATION_TOAST = (count) =>
+ n__('Artifacts|%d selected artifact deleted', 'Artifacts|%d selected artifacts deleted', count);
+
export const INITIAL_CURRENT_PAGE = 1;
export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
export const INITIAL_NEXT_PAGE_CURSOR = '';
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/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
new file mode 100644
index 00000000000..421b9258ca0
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
@@ -0,0 +1,7 @@
+mutation bulkDestroyJobArtifacts($projectId: ProjectID!, $ids: [CiJobArtifactID!]!) {
+ bulkDestroyJobArtifacts(input: { projectId: $projectId, ids: $ids }) {
+ destroyedCount
+ destroyedIds
+ errors
+ }
+}
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 a62b3daa961..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/ci/artifacts/index.js
@@ -1,3 +1,4 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
@@ -5,6 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -17,13 +19,19 @@ export const initArtifactsTable = () => {
return false;
}
- const { projectPath, canDestroyArtifacts, artifactsManagementFeedbackImagePath } = el.dataset;
+ const {
+ projectPath,
+ projectId,
+ canDestroyArtifacts,
+ artifactsManagementFeedbackImagePath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ projectId,
canDestroyArtifacts: parseBoolean(canDestroyArtifacts),
artifactsManagementFeedbackImagePath,
},
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 3c6114b38ce..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,
@@ -38,6 +42,10 @@ export default {
required: false,
default: 0,
},
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
variables: {
type: Array,
required: true,
@@ -87,11 +95,16 @@ export default {
:entity="entity"
:is-loading="isLoading"
:max-variable-limit="maxVariableLimit"
+ :page-info="pageInfo"
:variables="variables"
+ @handle-prev-page="$emit('handle-prev-page')"
+ @handle-next-page="$emit('handle-next-page')"
@set-selected-variable="setSelectedVariable"
+ @sort-changed="(val) => $emit('sort-changed', val)"
/>
<ci-variable-modal
v-if="showModal"
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
:hide-environment-scope="hideEnvironmentScope"
@@ -102,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 6e39bda0b07..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
@@ -1,11 +1,15 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
+ ENVIRONMENT_QUERY_LIMIT,
+ SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
+ mapMutationActionToToast,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
@@ -16,6 +20,7 @@ export default {
components: {
CiVariableSettings,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['endpoint'],
props: {
areScopedVariablesAvailable: {
@@ -97,6 +102,7 @@ export default {
loadingCounter: 0,
maxVariableLimit: 0,
pageInfo: {},
+ sortDirection: SORT_DIRECTIONS.ASC,
};
},
apollo: {
@@ -107,6 +113,8 @@ export default {
variables() {
return {
fullPath: this.fullPath || undefined,
+ first: this.pageSize,
+ sort: this.sortDirection,
};
},
update(data) {
@@ -116,21 +124,23 @@ export default {
this.maxVariableLimit = this.queryData.ciVariables.lookup(data)?.limit || 0;
this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo;
- this.hasNextPage = this.pageInfo?.hasNextPage || false;
- // Because graphQL has a limit of 100 items,
- // we batch load all the variables by making successive queries
- // to keep the same UX. As a safeguard, we make sure that we cannot go over
- // 20 consecutive API calls, which means 2000 variables loaded maximum.
- if (!this.hasNextPage) {
- this.isLoadingMoreItems = false;
- } else if (this.loadingCounter < 20) {
- this.hasNextPage = false;
- this.fetchMoreVariables();
- this.loadingCounter += 1;
- } else {
- createAlert({ message: this.$options.tooManyCallsError });
- reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {});
+ if (!this.glFeatures?.ciVariablesPages) {
+ this.hasNextPage = this.pageInfo?.hasNextPage || false;
+ // Because graphQL has a limit of 100 items,
+ // we batch load all the variables by making successive queries
+ // to keep the same UX. As a safeguard, we make sure that we cannot go over
+ // 20 consecutive API calls, which means 2000 variables loaded maximum.
+ if (!this.hasNextPage) {
+ this.isLoadingMoreItems = false;
+ } else if (this.loadingCounter < 20) {
+ this.hasNextPage = false;
+ this.fetchMoreVariables();
+ this.loadingCounter += 1;
+ } else {
+ createAlert({ message: this.$options.tooManyCallsError });
+ reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {});
+ }
}
},
error() {
@@ -154,6 +164,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.environmentQueryVariables,
};
},
update(data) {
@@ -165,13 +176,32 @@ 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
);
},
+ pageSize() {
+ return this.glFeatures?.ciVariablesPages ? 20 : 100;
+ },
},
methods: {
addVariable(variable) {
@@ -189,9 +219,39 @@ export default {
},
});
},
+ handlePrevPage() {
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ before: this.pageInfo.startCursor,
+ first: null,
+ last: this.pageSize,
+ },
+ });
+ },
+ handleNextPage() {
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ first: this.pageSize,
+ last: null,
+ },
+ });
+ },
+ async handleSortChanged({ sortDesc }) {
+ this.sortDirection = sortDesc ? SORT_DIRECTIONS.DESC : SORT_DIRECTIONS.ASC;
+
+ // Wait for the new sort direction to be updated and then refetch
+ await this.$nextTick();
+ this.$apollo.queries.ciVariables.refetch();
+ },
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];
@@ -209,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 });
@@ -228,15 +292,21 @@ export default {
<template>
<ci-variable-settings
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:entity="entity"
+ :environments="environments"
:hide-environment-scope="hideEnvironmentScope"
:is-loading="isLoading"
- :variables="ciVariables"
:max-variable-limit="maxVariableLimit"
- :environments="environments"
+ :page-info="pageInfo"
+ :variables="ciVariables"
@add-variable="addVariable"
@delete-variable="deleteVariable"
+ @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 345a8def49d..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
@@ -4,6 +4,7 @@ import {
GlButton,
GlLoadingIcon,
GlModalDirective,
+ GlKeysetPagination,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -56,6 +57,7 @@ export default {
components: {
GlAlert,
GlButton,
+ GlKeysetPagination,
GlLoadingIcon,
GlTable,
},
@@ -78,6 +80,10 @@ export default {
type: Number,
required: true,
},
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
variables: {
type: Array,
required: true,
@@ -165,6 +171,23 @@ export default {
>
{{ exceedsVariableLimitText }}
</gl-alert>
+ <div
+ v-if="glFeatures.ciVariablesPages"
+ class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
+ >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ class="gl-mx-3"
+ data-qa-selector="add_ci_variable_button"
+ variant="confirm"
+ category="primary"
+ :aria-label="__('Add')"
+ :disabled="exceedsVariableLimit"
+ @click="setSelectedVariable()"
+ >{{ __('Add variable') }}</gl-button
+ >
+ </div>
<gl-table
v-if="!isLoading"
:fields="fields"
@@ -174,11 +197,13 @@ export default {
sort-by="key"
sort-direction="asc"
stacked="lg"
- table-class="text-secondary"
+ table-class="gl-border-t"
fixed
show-empty
sort-icon-left
no-sort-reset
+ no-local-sorting
+ @sort-changed="(val) => $emit('sort-changed', val)"
>
<template #table-colgroup="scope">
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
@@ -275,7 +300,7 @@ export default {
>
{{ exceedsVariableLimitText }}
</gl-alert>
- <div class="ci-variable-actions gl-display-flex gl-mt-5">
+ <div v-if="!glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-mt-5">
<gl-button
v-gl-modal-directive="$options.modalId"
class="gl-mr-3"
@@ -287,12 +312,16 @@ 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
+ v-bind="pageInfo"
+ :prev-text="__('Previous')"
+ :next-text="__('Next')"
+ @prev="$emit('handle-prev-page')"
+ @next="$emit('handle-next-page')"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index 627ace1b28e..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,12 @@
-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',
+ DESC: 'KEY_DESC',
+};
// This const will be deprecated once we remove VueX from the section
export const displayText = {
@@ -92,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/group_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
index 538502fdd3b..4a64a24573e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
@@ -1,10 +1,17 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) {
+query getGroupVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $fullPath: ID!
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
group(fullPath: $fullPath) {
id
- ciVariables(after: $after, first: $first) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
limit
pageInfo {
...PageInfo
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/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
index af0cd2d0b2c..03a7142080b 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
@@ -1,10 +1,17 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) {
+query getProjectVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $fullPath: ID!
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
project(fullPath: $fullPath) {
id
- ciVariables(after: $after, first: $first) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
limit
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
index b8dd6f5f562..adf539a44ae 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
@@ -1,8 +1,14 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getVariables($after: String, $first: Int = 100) {
- ciVariables(after: $after, first: $first) {
+query getVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
pageInfo {
...PageInfo
}
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
index cafe3df35d0..7ed0418d5f4 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
@@ -205,33 +205,40 @@ export const mergeVariables = (existing, incoming, { args }) => {
return result;
};
-export const cacheConfig = {
- cacheConfig: {
- typePolicies: {
- Query: {
- fields: {
- ciVariables: {
- keyArgs: false,
- merge: mergeVariables,
+export const mergeOnlyIncomings = (_, incoming) => {
+ return incoming;
+};
+
+export const generateCacheConfig = (isVariablePagesEnabled = false) => {
+ const merge = isVariablePagesEnabled ? mergeOnlyIncomings : mergeVariables;
+ return {
+ cacheConfig: {
+ typePolicies: {
+ Query: {
+ fields: {
+ ciVariables: {
+ keyArgs: false,
+ merge,
+ },
},
},
- },
- Project: {
- fields: {
- ciVariables: {
- keyArgs: ['fullPath', 'endpoint', 'id'],
- merge: mergeVariables,
+ Project: {
+ fields: {
+ ciVariables: {
+ keyArgs: ['fullPath'],
+ merge,
+ },
},
},
- },
- Group: {
- fields: {
- ciVariables: {
- keyArgs: ['fullPath'],
- merge: mergeVariables,
+ Group: {
+ fields: {
+ ciVariables: {
+ keyArgs: ['fullPath'],
+ merge,
+ },
},
},
},
},
- },
+ };
};
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 4270c3c67fc..033cdbe864e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import CiAdminVariables from './components/ci_admin_variables.vue';
import CiGroupVariables from './components/ci_group_variables.vue';
import CiProjectVariables from './components/ci_project_variables.vue';
-import { cacheConfig, resolvers } from './graphql/settings';
+import { generateCacheConfig, resolvers } from './graphql/settings';
const mountCiVariableListApp = (containerEl) => {
const {
@@ -40,10 +41,16 @@ const mountCiVariableListApp = (containerEl) => {
component = CiProjectVariables;
}
+ Vue.use(GlToast);
Vue.use(VueApollo);
+ // If the feature flag `ci_variables_pages` is enabled,
+ // we are using the default cache config with pages.
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, cacheConfig),
+ defaultClient: createDefaultClient(
+ resolvers,
+ generateCacheConfig(window.gon?.features?.ciVariablesPages),
+ ),
});
return new Vue({
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/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
index 9cbf60b1c8f..b7616c02601 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
@@ -1,11 +1,13 @@
<script>
import { __, s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import {
COMMIT_ACTION_CREATE,
COMMIT_ACTION_UPDATE,
COMMIT_FAILURE,
COMMIT_SUCCESS,
COMMIT_SUCCESS_WITH_REDIRECT,
+ pipelineEditorTrackingOptions,
} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
@@ -26,6 +28,7 @@ export default {
components: {
CommitForm,
},
+ mixins: [Tracking.mixin()],
inject: ['projectFullPath', 'ciConfigPath'],
props: {
ciFileContent: {
@@ -78,6 +81,8 @@ export default {
async onCommitSubmit({ message, sourceBranch, openMergeRequest }) {
this.isSaving = true;
+ this.trackCommitEvent();
+
try {
const {
data: {
@@ -131,6 +136,10 @@ export default {
this.isSaving = false;
}
},
+ trackCommitEvent() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.commitCiConfig, { label, property: this.action });
+ },
updateCurrentBranch(currentBranch) {
this.$apollo.mutate({
mutation: updateCurrentBranchMutation,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
index 42e2d34fa3a..9179fe9d075 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -6,7 +6,7 @@ import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
i18n: {
- viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
+ viewOnlyMessage: s__('Pipelines|Full configuration is view only'),
},
components: {
SourceEditor,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
index b78224e93b0..eabf4749e9c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -10,12 +10,14 @@ export default {
browseTemplates: __('Browse templates'),
help: __('Help'),
jobAssistant: s__('JobAssistant|Job assistant'),
+ aiAssistant: s__('PipelinesAiAssistant|Ai assistant'),
},
TEMPLATE_REPOSITORY_URL,
components: {
GlButton,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['aiChatAvailable'],
props: {
showDrawer: {
type: Boolean,
@@ -25,6 +27,15 @@ export default {
type: Boolean,
required: true,
},
+ showAiAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isAiConfigChatAvailable() {
+ return this.glFeatures.aiCiConfigGenerator && this.aiChatAvailable;
+ },
},
methods: {
toggleDrawer() {
@@ -40,6 +51,11 @@ export default {
this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer',
);
},
+ toggleAiAssistantDrawer() {
+ this.$emit(
+ this.showAiAssistantDrawer ? 'close-ai-assistant-drawer' : 'open-ai-assistant-drawer',
+ );
+ },
trackHelpDrawerClick() {
const { label, actions } = pipelineEditorTrackingOptions;
this.track(actions.openHelpDrawer, { label });
@@ -85,5 +101,15 @@ export default {
>
{{ $options.i18n.jobAssistant }}
</gl-button>
+ <gl-button
+ v-if="isAiConfigChatAvailable"
+ icon="bulb"
+ size="small"
+ data-testid="ai-assistant-drawer-toggle"
+ data-qa-selector="ai_assistant_drawer_toggle"
+ @click="toggleAiAssistantDrawer"
+ >
+ {{ $options.i18n.aiAssistant }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
index 891c40482d3..1192f0bf418 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
@@ -2,6 +2,7 @@
import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
@@ -16,6 +17,12 @@ export default {
},
inject: ['ciConfigPath'],
inheritAttrs: false,
+ created() {
+ eventHub.$on(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
+ beforeDestroy() {
+ eventHub.$off(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
methods: {
onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
@@ -24,6 +31,10 @@ export default {
instance.use({ definition: CiSchemaExtension });
instance.registerCiSchema();
},
+ scrollEditorToBottom() {
+ const editor = this.$refs.editor.getEditor();
+ editor.setScrollTop(editor.getScrollHeight());
+ },
},
readyEvent: EDITOR_READY_EVENT,
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
index 84c0eef441f..8553256f13a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
@@ -1,8 +1,7 @@
<script>
-import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
@@ -11,15 +10,20 @@ import {
} from '../../constants';
export const i18n = {
- empty: __(
- "We'll continuously validate your pipeline configuration. The validation results will appear here.",
+ empty: s__(
+ "Pipelines|We'll continuously validate your pipeline configuration. The validation results will appear here.",
),
- learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
- invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
- invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
- unavailableValidation: s__('Pipelines|Configuration validation currently not available.'),
- valid: s__('Pipelines|Pipeline syntax is correct.'),
+ invalid: s__(
+ 'Pipelines|This GitLab CI configuration is invalid. %{linkStart}Learn more%{linkEnd}',
+ ),
+ invalidWithReason: s__(
+ 'Pipelines|This GitLab CI configuration is invalid: %{reason}. %{linkStart}Learn more%{linkEnd}',
+ ),
+ unavailableValidation: s__(
+ 'Pipelines|Unable to validate CI/CD configuration. See the %{linkStart}GitLab CI/CD troubleshooting guide%{linkEnd} for more details.',
+ ),
+ valid: s__('Pipelines|Pipeline syntax is correct. %{linkStart}Learn more%{linkEnd}'),
};
export default {
@@ -28,10 +32,10 @@ export default {
GlIcon,
GlLink,
GlLoadingIcon,
- TooltipOnTruncate,
+ GlSprintf,
},
inject: {
- lintUnavailableHelpPagePath: {
+ ciTroubleshootingPath: {
default: '',
},
ymlHelpPagePath: {
@@ -54,49 +58,48 @@ export default {
},
},
computed: {
- helpPath() {
- return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath;
+ APP_STATUS_CONFIG() {
+ return {
+ [EDITOR_APP_STATUS_EMPTY]: {
+ icon: 'check',
+ message: this.$options.i18n.empty,
+ },
+ [EDITOR_APP_STATUS_LINT_UNAVAILABLE]: {
+ icon: 'time-out',
+ link: this.ciTroubleshootingPath,
+ message: this.$options.i18n.unavailableValidation,
+ },
+ [EDITOR_APP_STATUS_VALID]: {
+ icon: 'check',
+ message: this.$options.i18n.valid,
+ },
+ };
},
- isEmpty() {
- return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ currentAppStatusConfig() {
+ return this.APP_STATUS_CONFIG[this.appStatus] || {};
},
- isLintUnavailable() {
- return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE;
+ hasLink() {
+ return this.appStatus !== EDITOR_APP_STATUS_EMPTY;
+ },
+ helpPath() {
+ return this.currentAppStatusConfig.link || this.ymlHelpPagePath;
},
isLoading() {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
- isValid() {
- return this.appStatus === EDITOR_APP_STATUS_VALID;
- },
icon() {
- switch (this.appStatus) {
- case EDITOR_APP_STATUS_EMPTY:
- return 'check';
- case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
- return 'time-out';
- case EDITOR_APP_STATUS_VALID:
- return 'check';
- default:
- return 'warning-solid';
- }
+ return this.currentAppStatusConfig.icon || 'warning-solid';
},
message() {
const [reason] = this.ciConfig?.errors || [];
- switch (this.appStatus) {
- case EDITOR_APP_STATUS_EMPTY:
- return this.$options.i18n.empty;
- case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
- return this.$options.i18n.unavailableValidation;
- case EDITOR_APP_STATUS_VALID:
- return this.$options.i18n.valid;
- default:
- // Only display first error as a reason
- return this.ciConfig?.errors?.length > 0
- ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
- : this.$options.i18n.invalid;
- }
+ return (
+ this.currentAppStatusConfig.message ||
+ // Only display first error as a reason
+ (reason
+ ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
+ : this.$options.i18n.invalid)
+ );
},
},
};
@@ -108,18 +111,14 @@ export default {
<gl-loading-icon size="sm" inline />
{{ $options.i18n.loading }}
</template>
-
- <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full">
- <tooltip-on-truncate :title="message" class="gl-text-truncate">
+ <span v-else data-testid="validation-segment">
+ <span class="gl-max-w-full" data-qa-selector="validation_message_content">
<gl-icon :name="icon" />
- <span data-qa-selector="validation_message_content" data-testid="validationMsg">
- {{ message }}
- </span>
- </tooltip-on-truncate>
- <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
- <gl-link data-testid="learnMoreLink" :href="helpPath">
- {{ $options.i18n.learnMore }}
- </gl-link>
+ <gl-sprintf :message="message">
+ <template v-if="hasLink" #link="{ content }">
+ <gl-link :href="helpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</span>
</span>
</div>
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
new file mode 100644
index 00000000000..b4b468987d8
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormGroup, GlAccordionItem, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ imageEntryPoint() {
+ return this.job.image.entrypoint.join('\n');
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.IMAGE">
+ <gl-form-group :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
+ :label="$options.i18n.IMAGE_ENTRYPOINT"
+ :description="$options.i18n.ARRAY_FIELD_DESCRIPTION"
+ class="gl-mb-0"
+ >
+ <gl-form-textarea
+ :no-resize="false"
+ :placeholder="$options.placeholderText"
+ data-testid="image-entrypoint-input"
+ :value="imageEntryPoint"
+ @input="$emit('update-job', 'image.entrypoint', $event.split('\n'))"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
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
new file mode 100644
index 00000000000..511003d3ad4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
@@ -0,0 +1,90 @@
+<script>
+import {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlTokenSelector,
+ GlFormCombobox,
+} from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormCombobox,
+ GlTokenSelector,
+ },
+ props: {
+ tagOptions: {
+ type: Array,
+ required: true,
+ },
+ job: {
+ type: Object,
+ required: true,
+ },
+ isNameValid: {
+ type: Boolean,
+ required: true,
+ },
+ isScriptValid: {
+ type: Boolean,
+ required: true,
+ },
+ availableStages: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.JOB_SETUP" visible>
+ <gl-form-group
+ :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED"
+ :state="isNameValid"
+ :label="$options.i18n.JOB_NAME"
+ >
+ <gl-form-input
+ :value="job.name"
+ :state="isNameValid"
+ data-testid="job-name-input"
+ @input="$emit('update-job', 'name', $event)"
+ />
+ </gl-form-group>
+ <gl-form-combobox
+ :value="job.stage"
+ :token-list="availableStages"
+ :label-text="$options.i18n.STAGE"
+ data-testid="job-stage-input"
+ @input="$emit('update-job', 'stage', $event)"
+ />
+ <gl-form-group
+ :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED"
+ :state="isScriptValid"
+ :label="$options.i18n.SCRIPT"
+ >
+ <gl-form-textarea
+ :value="job.script"
+ :state="isScriptValid"
+ :no-resize="false"
+ data-testid="job-script-input"
+ @input="$emit('update-job', 'script', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.TAGS">
+ <gl-token-selector
+ :dropdown-items="tagOptions"
+ :selected-tokens="job.tags"
+ data-testid="job-tags-input"
+ @input="$emit('update-job', 'tags', $event)"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
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/accordion_items/services_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
new file mode 100644
index 00000000000..9bada3ef110
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlAccordionItem, GlFormInput, GlButton, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
+ components: {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlButton,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ canDeleteServices() {
+ return this.job.services.length > 1;
+ },
+ },
+ methods: {
+ deleteService(index) {
+ if (!this.canDeleteServices) {
+ return;
+ }
+ this.$emit('update-job', `services[${index}]`);
+ },
+ addService() {
+ this.$emit('update-job', `services[${this.job.services.length}]`, {
+ name: '',
+ entrypoint: [''],
+ });
+ },
+ serviceEntryPoint(service) {
+ const { entrypoint = [''] } = service;
+ return entrypoint.join('\n');
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.SERVICE">
+ <div
+ v-for="(service, index) in job.services"
+ :key="index"
+ class="gl-relative gl-bg-gray-10 gl-mb-5 gl-p-5"
+ >
+ <gl-button
+ v-if="canDeleteServices"
+ class="gl-absolute gl-right-3 gl-top-3"
+ category="tertiary"
+ icon="remove"
+ :data-testid="`delete-job-service-button-${index}`"
+ @click="deleteService(index)"
+ />
+ <gl-form-group :label="$options.i18n.SERVICE_NAME">
+ <gl-form-input
+ :data-testid="`service-name-input-${index}`"
+ :value="service.name"
+ @input="$emit('update-job', `services[${index}].name`, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.SERVICE_ENTRYPOINT"
+ :description="$options.i18n.ARRAY_FIELD_DESCRIPTION"
+ class="gl-mb-0"
+ >
+ <gl-form-textarea
+ :no-resize="false"
+ :placeholder="$options.placeholderText"
+ :data-testid="`service-entrypoint-input-${index}`"
+ :value="serviceEntryPoint(service)"
+ @input="$emit('update-job', `services[${index}].entrypoint`, $event.split('\n'))"
+ />
+ </gl-form-group>
+ </div>
+ <gl-button
+ category="secondary"
+ variant="confirm"
+ data-testid="add-job-service-button"
+ @click="addService"
+ >{{ $options.i18n.ADD_SERVICE }}</gl-button
+ >
+ </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 1c122fd5e38..e93a9e84302 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
@@ -1,7 +1,118 @@
-import { s__ } from '~/locale';
+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: '',
+ script: '',
+ tags: [],
+ image: {
+ name: '',
+ entrypoint: [''],
+ },
+ services: [
+ {
+ name: '',
+ entrypoint: [''],
+ },
+ ],
+ artifacts: {
+ paths: [''],
+ exclude: [''],
+ },
+ cache: {
+ paths: [''],
+ key: '',
+ },
+ rules: [
+ {
+ allow_failure: false,
+ when: 'on_success',
+ start_in: '',
+ },
+ ],
+};
+
export const i18n = {
+ ARRAY_FIELD_DESCRIPTION: s__('JobAssistant|Please separate array type fields with new lines'),
+ INPUT_FORMAT: s__('JobAssistant|Input format'),
ADD_JOB: s__('JobAssistant|Add job'),
+ SCRIPT: s__('JobAssistant|Script'),
+ JOB_NAME: s__('JobAssistant|Job name'),
+ JOB_SETUP: s__('JobAssistant|Job Setup'),
+ STAGE: s__('JobAssistant|Stage (optional)'),
+ TAGS: s__('JobAssistant|Tags (optional)'),
+ IMAGE: s__('JobAssistant|Image'),
+ 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'),
+ ADD_SERVICE: s__('JobAssistant|Add service'),
+ SERVICE: s__('JobAssistant|Services'),
+ SERVICE_NAME: s__('JobAssistant|Service name (optional)'),
+ SERVICE_ENTRYPOINT: s__('JobAssistant|Service entrypoint (optional)'),
+ ENTRYPOINT_PLACEHOLDER_TEXT: s__('JobAssistant|Please enter the parameters.'),
};
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 65c87df21cb..30746065732 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,13 +1,29 @@
<script>
-import { GlDrawer, GlButton } from '@gitlab/ui';
+import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
+import { stringify, parse } from 'yaml';
+import { get, omit, toPath } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
-import { DRAWER_CONTAINER_CLASS, i18n } from './constants';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+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 ServicesItem from './accordion_items/services_item.vue';
+import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue';
+import RulesItem from './accordion_items/rules_item.vue';
export default {
i18n,
components: {
GlDrawer,
+ GlAccordion,
GlButton,
+ JobSetupItem,
+ ImageItem,
+ ServicesItem,
+ ArtifactsAndCacheItem,
+ RulesItem,
},
props: {
isVisible: {
@@ -20,16 +36,136 @@ 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: getRunnerTags,
+ update(data) {
+ return data?.runners?.nodes || [];
+ },
+ },
},
computed: {
+ availableStages() {
+ if (this.ciConfigData?.mergedYaml) {
+ return parse(this.ciConfigData.mergedYaml).stages;
+ }
+ return [];
+ },
+ tagOptions() {
+ const options = [];
+ this.runners?.forEach((runner) => options.push(...runner.tagList));
+ return [...new Set(options)].map((tag) => {
+ return {
+ id: tag,
+ name: tag,
+ };
+ });
+ },
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: {
closeDrawer() {
+ this.clearJob();
this.$emit('close-job-assistant-drawer');
},
+ addCiConfig() {
+ this.validateJob();
+
+ if (!this.isJobValid) {
+ return;
+ }
+
+ const newJobString = this.generateYmlString();
+ this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`);
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ this.closeDrawer();
+ },
+ generateYmlString() {
+ let job = JSON.parse(JSON.stringify(this.job));
+ const jobName = 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.$nextTick(() => {
+ this.isNameValid = true;
+ this.isScriptValid = true;
+ this.isStartValid = true;
+ });
+ },
+ updateJob(key, value) {
+ 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);
+ }
+ },
+ 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);
+ },
},
};
</script>
@@ -44,6 +180,20 @@ export default {
<template #title>
<h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2>
</template>
+ <gl-accordion :header-level="3">
+ <job-setup-item
+ :tag-options="tagOptions"
+ :job="job"
+ :is-name-valid="isNameValid"
+ :is-script-valid="isScriptValid"
+ :available-stages="availableStages"
+ @update-job="updateJob"
+ />
+ <image-item :job="job" @update-job="updateJob" />
+ <services-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">
<gl-button
@@ -51,11 +201,15 @@ export default {
class="gl-mr-3"
data-testid="cancel-button"
@click="closeDrawer"
- >{{ __('Cancel') }}</gl-button
- >
- <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{
- __('Add')
- }}</gl-button>
+ >{{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="confirm-button"
+ @click="addCiConfig"
+ >{{ __('Add') }}
+ </gl-button>
</div>
</template>
</gl-drawer>
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
new file mode 100644
index 00000000000..a604d79259d
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
@@ -0,0 +1,53 @@
+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);
+
+export const removeEmptyObj = (obj) => {
+ if (isArray(obj)) {
+ return reject(map(obj, removeEmptyObj), isEmptyValue);
+ } else if (isObject(obj)) {
+ return omitBy(mapValues(obj, removeEmptyObj), isEmptyValue);
+ }
+ return obj;
+};
+
+export const trimFields = (data) => {
+ if (isArray(data)) {
+ return data.map(trimFields);
+ } else if (isObject(data)) {
+ return mapValues(data, trimFields);
+ }
+ 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/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
index fd6547468d9..403793a255a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -31,7 +31,7 @@ export default {
tabEdit: s__('Pipelines|Edit'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
- tabMergedYaml: s__('Pipelines|View merged YAML'),
+ tabMergedYaml: s__('Pipelines|Full configuration'),
tabValidate: s__('Pipelines|Validate'),
empty: {
visualization: s__(
@@ -41,12 +41,12 @@ export default {
'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
),
merge: s__(
- 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ 'PipelineEditor|The full configuration view is displayed when the CI/CD configuration file has valid syntax.',
),
},
},
errorTexts: {
- loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
+ loadMergedYaml: s__('Pipelines|Could not load full configuration content'),
},
query: {
TAB_QUERY_PARAM,
@@ -99,6 +99,10 @@ export default {
type: Boolean,
required: true,
},
+ showAiAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
appStatus: {
@@ -194,6 +198,7 @@ export default {
<ci-editor-header
:show-drawer="showDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
+ :show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
/>
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
index 83fcab4b343..ba33888e2fb 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
@@ -2,7 +2,7 @@
import {
GlAlert,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlIcon,
GlLoadingIcon,
GlLink,
@@ -61,7 +61,7 @@ export default {
CiLintResults,
GlAlert,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlIcon,
GlLoadingIcon,
GlLink,
@@ -195,11 +195,11 @@ export default {
<div class="gl-display-flex gl-justify-content-space-between gl-mt-3">
<div>
<label>{{ $options.i18n.pipelineSource }}</label>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-gl-tooltip.hover
class="gl-ml-3"
:title="$options.i18n.pipelineSourceTooltip"
- :text="$options.i18n.pipelineSourceDefault"
+ :toggle-text="$options.i18n.pipelineSourceDefault"
disabled
data-testid="pipeline-source"
/>
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
index dd25c4d433b..912e0fcbff9 100644
--- a/app/assets/javascripts/ci/pipeline_editor/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -67,6 +67,7 @@ export const pipelineEditorTrackingOptions = {
actions: {
browseTemplates: 'browse_templates',
closeHelpDrawer: 'close_help_drawer',
+ commitCiConfig: 'commit_ci_config',
helpDrawerLinks: {
[CI_EXAMPLES_LINK]: 'visit_help_drawer_link_ci_examples',
[CI_HELP_LINK]: 'visit_help_drawer_link_ci_help',
@@ -86,25 +87,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/event_hub.js b/app/assets/javascripts/ci/pipeline_editor/event_hub.js
new file mode 100644
index 00000000000..c64eaf5ef5c
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/event_hub.js
@@ -0,0 +1,5 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
+
+export const SCROLL_EDITOR_TO_BOTTOM = Symbol('scrollEditorToBottom');
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 6d91c339833..b8d6c27435d 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -29,12 +29,12 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
includesHelpPagePath,
lintHelpPagePath,
- lintUnavailableHelpPagePath,
needsHelpPagePath,
newMergeRequestPath,
pipelinePagePath,
@@ -46,6 +46,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
usesExternalConfig,
validateTabIllustrationPath,
ymlHelpPagePath,
+ aiChatAvailable,
} = el.dataset;
const configurationPaths = Object.fromEntries(
@@ -115,10 +116,12 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
el,
apolloProvider,
provide: {
+ aiChatAvailable: parseBoolean(aiChatAvailable),
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
configurationPaths,
dataMethod: 'graphql',
defaultBranch,
@@ -126,7 +129,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
helpPaths,
includesHelpPagePath,
lintHelpPagePath,
- lintUnavailableHelpPagePath,
needsHelpPagePath,
newMergeRequestPath,
pipelinePagePath,
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 ff848a973e3..de8e5a1a284 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
-import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
+import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -325,7 +325,7 @@ export default {
},
this.newMergeRequestPath,
);
- redirectTo(url);
+ redirectTo(url); // eslint-disable-line import/no-deprecated
},
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
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..647e33333ce 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -1,6 +1,7 @@
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue';
@@ -10,6 +11,9 @@ import PipelineEditorHeader from './components/header/pipeline_editor_header.vue
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants';
+const AiAssistantDrawer = () =>
+ import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue');
+
export default {
commitSectionRef: 'commitSectionRef',
modal: {
@@ -30,11 +34,13 @@ export default {
GlModal,
PipelineEditorDrawer,
JobAssistantDrawer,
+ AiAssistantDrawer,
PipelineEditorFileNav,
PipelineEditorFileTree,
PipelineEditorHeader,
PipelineEditorTabs,
},
+ mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -66,8 +72,10 @@ export default {
shouldLoadNewBranch: false,
showDrawer: false,
showJobAssistantDrawer: false,
+ showAiAssistantDrawer: false,
drawerIndex: 200,
jobAssistantIndex: 200,
+ aiAssistantIndex: 200,
showFileTree: false,
showSwitchBranchModal: false,
};
@@ -93,6 +101,13 @@ export default {
closeJobAssistantDrawer() {
this.showJobAssistantDrawer = false;
},
+ closeAiAssistantDrawer() {
+ this.showAiAssistantDrawer = false;
+ },
+ openAiAssistantDrawer() {
+ this.showAiAssistantDrawer = true;
+ this.aiAssistantIndex = this.drawerIndex + 1;
+ },
handleConfirmSwitchBranch() {
this.showSwitchBranchModal = true;
},
@@ -167,11 +182,14 @@ export default {
:is-new-ci-config-file="isNewCiConfigFile"
:show-drawer="showDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
+ :show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
@open-drawer="openDrawer"
@close-drawer="closeDrawer"
@open-job-assistant-drawer="openJobAssistantDrawer"
@close-job-assistant-drawer="closeJobAssistantDrawer"
+ @open-ai-assistant-drawer="openAiAssistantDrawer"
+ @close-ai-assistant-drawer="closeAiAssistantDrawer"
@set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
@@ -195,10 +213,18 @@ 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"
@close-job-assistant-drawer="closeJobAssistantDrawer"
/>
+ <ai-assistant-drawer
+ v-if="glFeatures.aiCiConfigGenerator"
+ :is-visible="showAiAssistantDrawer"
+ :z-index="aiAssistantIndex"
+ @close-ai-assistant-drawer="closeAiAssistantDrawer"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
index 8837b7a1917..6fd5c8130ad 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
@@ -18,7 +18,7 @@ import { uniqueId } from 'lodash';
import Vue from 'vue';
import { fetchPolicies } from '~/lib/graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__, __, n__ } from '~/locale';
import {
CC_VALIDATION_REQUIRED_ERROR,
@@ -43,6 +43,7 @@ const i18n = {
defaultError: __('Something went wrong on our end. Please try again.'),
refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ configButtonTitle: s__('Pipelines|Go to the pipeline editor'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
removeVariableLabel: s__('CiVariables|Remove variable'),
@@ -81,6 +82,14 @@ export default {
type: String,
required: true,
},
+ pipelinesEditorPath: {
+ type: String,
+ required: true,
+ },
+ canViewPipelineEditor: {
+ type: Boolean,
+ required: true,
+ },
defaultBranch: {
type: String,
required: true,
@@ -330,7 +339,7 @@ export default {
const { id, errors, totalWarnings, warnings } = data.createPipeline;
if (id) {
- redirectTo(`${this.pipelinesPath}/${id}`);
+ redirectTo(`${this.pipelinesPath}/${id}`); // eslint-disable-line import/no-deprecated
return;
}
@@ -373,9 +382,18 @@ export default {
:dismissible="false"
variant="danger"
class="gl-mb-4"
- data-testid="run-pipeline-error-alert"
>
- <span v-safe-html="error"></span>
+ <span v-safe-html="error" data-testid="run-pipeline-error-alert" class="block"></span>
+ <gl-button
+ v-if="canViewPipelineEditor"
+ class="gl-my-3"
+ data-testid="ci-cd-pipeline-configuration"
+ variant="confirm"
+ :aria-label="$options.i18n.configButtonTitle"
+ :href="pipelinesEditorPath"
+ >
+ {{ $options.i18n.configButtonTitle }}
+ </gl-button>
</gl-alert>
<gl-alert
v-if="shouldShowWarning"
@@ -406,7 +424,11 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
- <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
+ <refs-dropdown
+ v-model="refValue"
+ :project-id="projectId"
+ @loadingError="onRefsLoadingError"
+ />
</gl-form-group>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
index 060527f2662..429f8e78dbe 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
@@ -1,86 +1,57 @@
<script>
-import { GlCollapsibleListbox } from '@gitlab/ui';
-import { debounce } from 'lodash';
-import axios from '~/lib/utils/axios_utils';
-import { DEBOUNCE_REFS_SEARCH_MS } from '../constants';
-import { formatListBoxItems, searchByFullNameInListboxOptions } from '../utils/format_refs';
+import { __ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
+import { formatToShortName } from '../utils/format_refs';
export default {
+ ENABLED_TYPE_REFS: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
+ i18n: {
+ /**
+ * In order to hide ListBox header
+ * we need to explicitly provide
+ * empty string for translations
+ */
+ dropdownHeader: '',
+ searchPlaceholder: __('Search refs'),
+ },
components: {
- GlCollapsibleListbox,
+ RefSelector,
},
- inject: ['projectRefsEndpoint'],
props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
value: {
type: Object,
required: false,
default: () => ({}),
},
},
- data() {
- return {
- isLoading: false,
- searchTerm: '',
- listBoxItems: [],
- };
- },
computed: {
- lowerCasedSearchTerm() {
- return this.searchTerm.toLowerCase();
- },
refShortName() {
return this.value.shortName;
},
},
methods: {
- loadRefs() {
- this.isLoading = true;
-
- axios
- .get(this.projectRefsEndpoint, {
- params: {
- search: this.lowerCasedSearchTerm,
- },
- })
- .then(({ data }) => {
- // Note: These keys are uppercase in API
- const { Branches = [], Tags = [] } = data;
-
- this.listBoxItems = formatListBoxItems(Branches, Tags);
- })
- .catch((e) => {
- this.$emit('loadingError', e);
- })
- .finally(() => {
- this.isLoading = false;
- });
- },
- debouncedLoadRefs: debounce(function debouncedLoadRefs() {
- this.loadRefs();
- }, DEBOUNCE_REFS_SEARCH_MS),
- setRefSelected(refFullName) {
- const ref = searchByFullNameInListboxOptions(refFullName, this.listBoxItems);
- this.$emit('input', ref);
- },
- setSearchTerm(searchQuery) {
- this.searchTerm = searchQuery?.trim();
- this.debouncedLoadRefs();
+ setRefSelected(fullName) {
+ this.$emit('input', {
+ shortName: formatToShortName(fullName),
+ fullName,
+ });
},
},
};
</script>
<template>
- <gl-collapsible-listbox
- class="gl-w-full gl-font-monospace"
- :items="listBoxItems"
- :searchable="true"
- :searching="isLoading"
- :search-placeholder="__('Search refs')"
- :selected="value.fullName"
- toggle-class="gl-flex-direction-column gl-align-items-stretch!"
- :toggle-text="refShortName"
- @search="setSearchTerm"
- @select="setRefSelected"
- @shown.once="loadRefs"
+ <ref-selector
+ :value="refShortName"
+ :enabled-ref-types="$options.ENABLED_TYPE_REFS"
+ :project-id="projectId"
+ :translations="$options.i18n"
+ :use-symbolic-ref-names="true"
+ toggle-button-class="gl-w-auto! gl-mb-0!"
+ @input="setRefSelected"
/>
</template>
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_new/index.js b/app/assets/javascripts/ci/pipeline_new/index.js
index 71c76aeab36..a466313a6cd 100644
--- a/app/assets/javascripts/ci/pipeline_new/index.js
+++ b/app/assets/javascripts/ci/pipeline_new/index.js
@@ -14,6 +14,8 @@ const mountPipelineNewForm = (el) => {
fileParam,
maxWarnings,
pipelinesPath,
+ pipelinesEditorPath,
+ canViewPipelineEditor,
projectId,
projectPath,
refParam,
@@ -43,6 +45,8 @@ const mountPipelineNewForm = (el) => {
fileParams,
maxWarnings: Number(maxWarnings),
pipelinesPath,
+ pipelinesEditorPath,
+ canViewPipelineEditor,
projectId,
projectPath,
refParam,
diff --git a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
index e6d26b32d47..228702fbc71 100644
--- a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
+++ b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
@@ -5,6 +5,10 @@ function convertToListBoxItems(items) {
return items.map(({ shortName, fullName }) => ({ text: shortName, value: fullName }));
}
+export function formatToShortName(ref) {
+ return ref.replace(/^refs\/(tags|heads)\//, '');
+}
+
export function formatRefs(refs, type) {
let fullName;
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
index 16bfc7f3abe..92c824fb5a1 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
@@ -10,11 +10,11 @@ export default {
),
actionPrimary: {
text: s__('PipelineSchedules|Delete pipeline schedule'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
actionCancel: {
text: __('Cancel'),
- attributes: [],
+ attributes: {},
},
},
components: {
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
index d03de91ea07..6695c6179cf 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -70,10 +70,12 @@ export default {
},
update(data) {
const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {};
+ const currentUser = data.currentUser || {};
return {
list,
count,
+ currentUser,
};
},
error() {
@@ -279,6 +281,7 @@ export default {
<pipeline-schedules-table
v-else
:schedules="schedules.list"
+ :current-user="schedules.currentUser"
@showTakeOwnershipModal="setTakeOwnershipModal"
@showDeleteModal="setDeleteModal"
@playPipelineSchedule="playPipelineSchedule"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
index 45b4f618e17..5bd58bfd95d 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
@@ -23,13 +23,20 @@ export default {
type: Object,
required: true,
},
+ currentUser: {
+ type: Object,
+ required: true,
+ },
},
computed: {
canPlay() {
return this.schedule.userPermissions.playPipelineSchedule;
},
+ isCurrentUserOwner() {
+ return this.schedule.owner.username === this.currentUser.username;
+ },
canTakeOwnership() {
- return this.schedule.userPermissions.takeOwnershipPipelineSchedule;
+ return !this.isCurrentUserOwner && this.schedule.userPermissions.adminPipelineSchedule;
},
canUpdate() {
return this.schedule.userPermissions.updatePipelineSchedule;
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 e8cfc5b29f3..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
@@ -59,12 +59,21 @@ export default {
type: Array,
required: true,
},
+ currentUser: {
+ type: Object,
+ required: true,
+ },
},
};
</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>
@@ -94,6 +103,7 @@ export default {
<template #cell(actions)="{ item }">
<pipeline-schedule-actions
:schedule="item"
+ :current-user="currentUser"
@showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)"
@showDeleteModal="$emit('showDeleteModal', $event)"
@playPipelineSchedule="$emit('playPipelineSchedule', $event)"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
index 3ac52d4735d..7863b0e3ef0 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
@@ -27,12 +27,10 @@ export default {
actionPrimary() {
return {
text: this.$options.i18n.takeOwnership,
- attributes: [
- {
- variant: 'confirm',
- category: 'primary',
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ },
};
},
},
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
index 7ded3945a32..b4d84309c5f 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
@@ -27,14 +27,12 @@ export default {
actionPrimary() {
return {
text: this.$options.i18n.takeOwnership,
- attributes: [
- {
- variant: 'confirm',
- category: 'primary',
- href: this.ownershipUrl,
- 'data-method': 'post',
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ href: this.ownershipUrl,
+ 'data-method': 'post',
+ },
};
},
},
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
index 9f6cb429cca..6167c7dc577 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
@@ -1,4 +1,8 @@
query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) {
+ currentUser {
+ id
+ username
+ }
project(fullPath: $projectPath) {
id
pipelineSchedules(status: $status) {
@@ -25,13 +29,13 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat
realNextRun
owner {
id
+ username
avatarUrl
name
webPath
}
userPermissions {
playPipelineSchedule
- takeOwnershipPipelineSchedule
updatePipelineSchedule
adminPipelineSchedule
}
diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js
index e1486649dbb..d8af2a11e0d 100644
--- a/app/assets/javascripts/ci/reports/codequality_report/constants.js
+++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js
@@ -1,6 +1,6 @@
export const SEVERITY_CLASSES = {
info: 'gl-text-blue-400',
- minor: 'gl-text-orange-200',
+ minor: 'gl-text-orange-300',
major: 'gl-text-orange-400',
critical: 'gl-text-red-600',
blocker: 'gl-text-red-800',
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
index 97d4ac7bf6f..ca1f3301691 100644
--- a/app/assets/javascripts/ci/reports/components/report_item.vue
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -53,7 +53,7 @@ export default {
};
</script>
<template>
- <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
+ <li class="report-block-list-issue gl-p-3!" data-qa-selector="report_item_row">
<component
:is="iconComponent"
v-if="showReportSectionStatusIcon"
diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js
index bad6fa1e7b9..1137236d355 100644
--- a/app/assets/javascripts/ci/reports/constants.js
+++ b/app/assets/javascripts/ci/reports/constants.js
@@ -1,10 +1,3 @@
-export const fieldTypes = {
- codeBlock: 'codeBlock',
- link: 'link',
- seconds: 'seconds',
- text: 'text',
-};
-
export const LOADING = 'LOADING';
export const ERROR = 'ERROR';
export const SUCCESS = 'SUCCESS';
@@ -15,10 +8,6 @@ export const STATUS_NEUTRAL = 'neutral';
export const STATUS_NOT_FOUND = 'not_found';
export const ICON_WARNING = 'warning';
-export const ICON_SUCCESS = 'success';
-export const ICON_NOTFOUND = 'notfound';
-export const ICON_PENDING = 'pending';
-export const ICON_FAILED = 'failed';
export const status = {
LOADING,
@@ -26,9 +15,6 @@ export const status = {
SUCCESS,
};
-export const ACCESSIBILITY_ISSUE_ERROR = 'error';
-export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
-
/**
* Slot names for the ReportSection component, corresponding to the success,
* loading and error statuses.
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 5401c7c1c28..e4d47fba464 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
@@ -1,67 +1,61 @@
<script>
-import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
-import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
-import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
-import { DEFAULT_PLATFORM, DEFAULT_ACCESS_LEVEL } from '../constants';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
name: 'AdminNewRunnerApp',
components: {
- GlLink,
- GlSprintf,
- RunnerInstructionsModal,
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
RunnerPlatformsRadioGroup,
- RunnerFormFields,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- props: {
- legacyRegistrationToken: {
- type: String,
- required: true,
- },
+ RunnerCreateForm,
},
data() {
return {
platform: DEFAULT_PLATFORM,
- runner: {
- description: '',
- maintenanceNote: '',
- paused: false,
- accessLevel: DEFAULT_ACCESS_LEVEL,
- runUntagged: false,
- tagList: '',
- maximumTimeout: ' ',
- },
};
},
- modalId: 'runners-legacy-registration-instructions-modal',
+ 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); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ INSTANCE_TYPE,
};
</script>
<template>
<div>
+ <registration-feedback-banner />
+
<h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="$options.INSTANCE_TYPE" />
+
<p>
- <gl-sprintf
- :message="
- s__(
- 'Runners|Create an instance 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>
+ {{
+ s__(
+ 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
</p>
<hr aria-hidden="true" />
@@ -73,6 +67,6 @@ export default {
<hr aria-hidden="true" />
- <runner-form-fields v-model="runner" />
+ <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
index 502d9d33b4d..434c1197f71 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/index.js
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
@@ -12,8 +12,6 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
return null;
}
- const { legacyRegistrationToken } = el.dataset;
-
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
@@ -22,11 +20,7 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
el,
apolloProvider,
render(h) {
- return h(AdminNewRunnerApp, {
- props: {
- legacyRegistrationToken,
- },
- });
+ return h(AdminNewRunnerApp);
},
});
};
diff --git a/app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue b/app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue
new file mode 100644
index 00000000000..cd38dc07157
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_register_runner/admin_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: 'AdminRegisterRunnerApp',
+ 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|Admin 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/admin_register_runner/index.js b/app/assets/javascripts/ci/runner/admin_register_runner/index.js
new file mode 100644
index 00000000000..bd43a5e8ce9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_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 AdminRegisterRunnerApp from './admin_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminRegisterRunner = (selector = '#js-admin-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(AdminRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 8d4303778af..668a55d2437 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,8 +1,8 @@
<script>
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -71,7 +71,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath);
+ redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
},
},
};
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index ce2c511ddd4..4d88feebe53 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -54,7 +54,6 @@ export default {
RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
- inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
newRunnerPath: {
type: String,
@@ -128,8 +127,8 @@ export default {
return isSearchFiltered(this.search);
},
shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow feature flag
- return this.glFeatures.createRunnerWorkflow;
+ // create_runner_workflow_for_admin feature flag
+ return this.glFeatures.createRunnerWorkflowForAdmin;
},
},
watch: {
@@ -193,16 +192,17 @@ export default {
nav-class="gl-border-none!"
/>
- <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
- {{ s__('Runners|New instance runner') }}
- </gl-button>
- <registration-dropdown
- v-else
- class="gl-w-full gl-sm-w-auto gl-mr-auto"
- :registration-token="registrationToken"
- :type="$options.INSTANCE_TYPE"
- right
- />
+ <div class="gl-w-full gl-md-w-auto gl-display-flex">
+ <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
+ {{ s__('Runners|New instance runner') }}
+ </gl-button>
+ <registration-dropdown
+ class="gl-ml-3"
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ right
+ />
+ </div>
</div>
<runner-filtered-search-bar
@@ -219,8 +219,6 @@ export default {
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
:new-runner-path="newRunnerPath"
- :svg-path="emptyStateSvgPath"
- :filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-list
diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js
index 881dc3613e9..54eb37f8c90 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runners/index.js
@@ -35,8 +35,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
registrationToken,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@@ -53,8 +51,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
},
render(h) {
return h(AdminRunnersApp, {
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..f24fb5575ae 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,13 +64,14 @@ export default {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
},
};
</script>
<template>
<div>
- <div>
+ <div class="gl-mb-3">
<slot :runner="runner" name="runner-name">
<runner-name :runner="runner" />
</slot>
@@ -69,22 +84,23 @@ export default {
<runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" />
</div>
- <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
- <div class="gl-flex-shrink-0">
- <runner-upgrade-status-icon :runner="runner" />
- <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
- <template #version>{{ runner.version }}</template>
- </gl-sprintf>
- </div>
- <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ <div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full">
+ <template v-if="runner.version">
+ <div class="gl-flex-shrink-0">
+ <runner-upgrade-status-icon :runner="runner" />
+ <gl-sprintf :message="$options.i18n.I18N_VERSION_LABEL">
+ <template #version>{{ runner.version }}</template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ </template>
<tooltip-on-truncate
- v-if="runner.description"
class="gl-text-truncate gl-display-block"
+ :class="{ 'gl-text-secondary': !runner.description }"
:title="runner.description"
>
- {{ runner.description }}
+ {{ runner.description || $options.i18n.I18N_NO_DESCRIPTION }}
</tooltip-on-truncate>
- <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span>
</div>
<div>
@@ -106,14 +122,33 @@ 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>
- <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
+ <runner-tags class="gl-display-block" :tag-list="runner.tagList" size="sm" />
</div>
</template>
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..742259ee491 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-mb-3 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/cli_command.vue b/app/assets/javascripts/ci/runner/components/registration/cli_command.vue
new file mode 100644
index 00000000000..95b135c83a7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/cli_command.vue
@@ -0,0 +1,42 @@
+<script>
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ ClipboardButton,
+ },
+ props: {
+ prompt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ command: {
+ type: [Array, String],
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ lines() {
+ if (typeof this.command === 'string') {
+ return [this.command];
+ }
+ return this.command;
+ },
+ clipboard() {
+ return this.lines.join('');
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-gap-3 gl-align-items-flex-start">
+ <!-- eslint-disable vue/require-v-for-key-->
+ <pre
+ class="gl-w-full"
+ ><span v-if="prompt" class="gl-user-select-none">{{ prompt }} </span><template v-for="line in lines">{{ line }}<br class="gl-user-select-none"/></template></pre>
+ <!-- eslint-enable vue/require-v-for-key-->
+ <clipboard-button :text="clipboard" :title="__('Copy')" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
new file mode 100644
index 00000000000..ff182c61ccf
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlDrawer, GlFormGroup, GlFormSelect, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+
+import {
+ DEFAULT_PLATFORM,
+ MACOS_PLATFORM,
+ LINUX_PLATFORM,
+ WINDOWS_PLATFORM,
+ INSTALL_HELP_URL,
+} from '../../constants';
+import { installScript, platformArchitectures } from './utils';
+
+import CliCommand from './cli_command.vue';
+
+export default {
+ components: {
+ GlDrawer,
+ GlFormGroup,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ CliCommand,
+ },
+ props: {
+ open: {
+ type: Boolean,
+ required: true,
+ },
+ platform: {
+ type: String,
+ required: false,
+ default: DEFAULT_PLATFORM,
+ },
+ },
+ data() {
+ return {
+ selectedPlatform: this.platform,
+ selectedArchitecture: null,
+ };
+ },
+ computed: {
+ drawerHeightOffset() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ architectureOptions() {
+ return platformArchitectures({ platform: this.selectedPlatform });
+ },
+ script() {
+ return installScript({
+ platform: this.selectedPlatform,
+ architecture: this.selectedArchitecture,
+ });
+ },
+ },
+ watch: {
+ selectedPlatform() {
+ this.selectedArchitecture =
+ this.architectureOptions.find((value) => value === this.selectedArchitecture) ||
+ this.architectureOptions[0];
+
+ this.$emit('selectPlatform', this.selectedPlatform);
+ },
+ },
+ created() {
+ [this.selectedArchitecture] = this.architectureOptions;
+ },
+ methods: {
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ platformOptions: [
+ /* eslint-disable @gitlab/require-i18n-strings */
+ { value: LINUX_PLATFORM, text: 'Linux' },
+ { value: MACOS_PLATFORM, text: 'macOS' },
+ { value: WINDOWS_PLATFORM, text: 'Windows' },
+ /* eslint-enable @gitlab/require-i18n-strings */
+ ],
+ INSTALL_HELP_URL,
+ DRAWER_Z_INDEX,
+};
+</script>
+<template>
+ <gl-drawer
+ :open="open"
+ :header-height="drawerHeightOffset"
+ :z-index="$options.DRAWER_Z_INDEX"
+ data-testid="runner-platforms-drawer"
+ @close="onClose"
+ >
+ <template #title>
+ <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
+ {{ s__('Runners|Install GitLab Runner') }}
+ </h2>
+ </template>
+ <div>
+ <p>{{ s__('Runners|Select platform specifications to install GitLab Runner.') }}</p>
+
+ <gl-form-group :label="s__('Runners|Environment')" label-for="runner-environment-select">
+ <gl-form-select
+ id="runner-environment-select"
+ v-model="selectedPlatform"
+ :options="$options.platformOptions"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="s__('Runners|Architecture')" label-for="runner-architecture-select">
+ <gl-form-select
+ id="runner-architecture-select"
+ v-model="selectedArchitecture"
+ :options="architectureOptions"
+ />
+ </gl-form-group>
+
+ <cli-command :command="script" />
+
+ <p>
+ <gl-sprintf
+ :message="
+ s__('Runners|See more %{linkStart}installation methods and architectures%{linkEnd}.')
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.INSTALL_HELP_URL">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue b/app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue
new file mode 100644
index 00000000000..4ebb5754e61
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import DismissibleFeedbackAlert from '~/vue_shared/components/dismissible_feedback_alert.vue';
+import { s__ } from '~/locale';
+import { CHANGELOG_URL } from '../../constants';
+
+export default {
+ name: 'RegistrationCompatibilityAlert',
+ components: {
+ GlLink,
+ GlSprintf,
+ DismissibleFeedbackAlert,
+ },
+ props: {
+ alertKey: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ alertFeatureName() {
+ return `new_runner_compatibility_${this.alertKey}`;
+ },
+ },
+ CHANGELOG_URL,
+ i18n: {
+ title: s__(
+ 'Runners|This registration process is only supported in GitLab Runner 15.10 or later',
+ ),
+ message: s__(
+ 'Runners|This registration process is not supported in GitLab Runner 15.9 or earlier and only available as an experimental feature in GitLab Runner 15.10 and 15.11. You should upgrade to %{linkStart}GitLab Runner 16.0%{linkEnd} or later to use a stable version of this registration process.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <dismissible-feedback-alert
+ :feature-name="alertFeatureName"
+ class="gl-mb-4"
+ variant="warning"
+ :title="$options.i18n.title"
+ >
+ <gl-sprintf :message="$options.i18n.message">
+ <template #link="{ content }">
+ <gl-link :href="$options.CHANGELOG_URL" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </dismissible-feedback-alert>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
index 212ad5fa5a0..2fdf8456615 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
@@ -1,8 +1,17 @@
<script>
-import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_REGISTER_INSTANCE_TYPE,
+ I18N_REGISTER_GROUP_TYPE,
+ I18N_REGISTER_PROJECT_TYPE,
+ I18N_REGISTER_RUNNER,
+} from '../../constants';
import RegistrationToken from './registration_token.vue';
import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue';
@@ -17,10 +26,12 @@ export default {
GlDropdownForm,
GlDropdownItem,
GlDropdownDivider,
+ GlIcon,
RegistrationToken,
RunnerInstructionsModal,
RegistrationTokenResetDropdownItem,
},
+ mixins: [glFeatureFlagMixin()],
props: {
registrationToken: {
type: String,
@@ -40,17 +51,49 @@ export default {
};
},
computed: {
- dropdownText() {
+ isDeprecated() {
+ // Show a compact version when used as secondary option
+ // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
+ return (
+ this.glFeatures?.createRunnerWorkflowForAdmin ||
+ this.glFeatures?.createRunnerWorkflowForNamespace
+ );
+ },
+ actionText() {
switch (this.type) {
case INSTANCE_TYPE:
- return s__('Runners|Register an instance runner');
+ return I18N_REGISTER_INSTANCE_TYPE;
case GROUP_TYPE:
- return s__('Runners|Register a group runner');
+ return I18N_REGISTER_GROUP_TYPE;
case PROJECT_TYPE:
- return s__('Runners|Register a project runner');
+ return I18N_REGISTER_PROJECT_TYPE;
default:
- return s__('Runners|Register a runner');
+ return I18N_REGISTER_RUNNER;
+ }
+ },
+ dropdownText() {
+ if (this.isDeprecated) {
+ return '';
+ }
+ return this.actionText;
+ },
+ dropdownToggleClass() {
+ if (this.isDeprecated) {
+ return ['gl-px-3!'];
+ }
+ return [];
+ },
+ dropdownCategory() {
+ if (this.isDeprecated) {
+ return 'tertiary';
}
+ return 'primary';
+ },
+ dropdownVariant() {
+ if (this.isDeprecated) {
+ return 'default';
+ }
+ return 'confirm';
},
},
methods: {
@@ -71,9 +114,26 @@ export default {
ref="runnerRegistrationDropdown"
menu-class="gl-w-auto!"
:text="dropdownText"
- variant="confirm"
+ :toggle-class="dropdownToggleClass"
+ :variant="dropdownVariant"
+ :category="dropdownCategory"
v-bind="$attrs"
>
+ <template v-if="isDeprecated" #button-content>
+ <span class="gl-sr-only">{{ actionText }}</span>
+ <gl-icon name="ellipsis_v" />
+ </template>
+ <gl-dropdown-form class="gl-p-4!">
+ <registration-token input-id="token-value" :value="currentRegistrationToken">
+ <template v-if="isDeprecated" #label-description>
+ <gl-icon name="warning" class="gl-text-orange-500" />
+ <span class="gl-text-secondary">
+ {{ s__('Runners|Support for registration tokens is deprecated') }}
+ </span>
+ </template>
+ </registration-token>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
<gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
{{ $options.i18n.showInstallationInstructions }}
<runner-instructions-modal
@@ -83,10 +143,6 @@ export default {
/>
</gl-dropdown-item>
<gl-dropdown-divider />
- <gl-dropdown-form class="gl-p-4!">
- <registration-token input-id="token-value" :value="currentRegistrationToken" />
- </gl-dropdown-form>
- <gl-dropdown-divider />
<registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
new file mode 100644
index 00000000000..fe19977f783
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
@@ -0,0 +1,41 @@
+<script>
+import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg?url';
+import { GlBanner } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+
+const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/387993';
+
+export default {
+ components: {
+ GlBanner,
+ UserCalloutDismisser,
+ },
+ i18n: {
+ title: s__("Runners|We've made some changes and want your feedback"),
+ body: s__(
+ "Runners|We've been making improvements to how you register runners so that it's more secure and efficient. Tell us how we're doing.",
+ ),
+ button: s__('Runners|Add your feedback to this issue'),
+ },
+ ILLUSTRATION_URL,
+ FEEDBACK_ISSUE_URL,
+};
+</script>
+<template>
+ <user-callout-dismisser feature-name="create_runner_workflow_banner">
+ <template #default="{ dismiss, shouldShowCallout }">
+ <gl-banner
+ v-if="shouldShowCallout"
+ class="gl-my-6"
+ :title="$options.i18n.title"
+ :svg-path="$options.ILLUSTRATION_URL"
+ :button-text="$options.i18n.button"
+ :button-link="$options.FEEDBACK_ISSUE_URL"
+ @close="dismiss"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ </gl-banner>
+ </template>
+ </user-callout-dismisser>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
new file mode 100644
index 00000000000..69021dde0e9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -0,0 +1,256 @@
+<script>
+import { GlIcon, GlLink, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { createAlert } from '~/alert';
+import { s__, sprintf } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
+
+import runnerForRegistrationQuery from '../../graphql/register/runner_for_registration.query.graphql';
+import {
+ STATUS_ONLINE,
+ EXECUTORS_HELP_URL,
+ SERVICE_COMMANDS_HELP_URL,
+ RUNNER_REGISTRATION_POLLING_INTERVAL_MS,
+ I18N_FETCH_ERROR,
+ I18N_REGISTRATION_SUCCESS,
+} from '../../constants';
+import { captureException } from '../../sentry_utils';
+
+import CliCommand from './cli_command.vue';
+import { commandPrompt, registerCommand, runCommand } from './utils';
+
+export default {
+ name: 'RegistrationInstructions',
+ components: {
+ GlIcon,
+ GlLink,
+ GlSkeletonLoader,
+ GlSprintf,
+ ClipboardButton,
+ CliCommand,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ platform: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ runner: null,
+ token: null,
+ };
+ },
+ apollo: {
+ runner: {
+ query: runnerForRegistrationQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId),
+ };
+ },
+ manual: true,
+ result({ data }) {
+ if (data?.runner) {
+ const { ephemeralAuthenticationToken, ...runner } = data.runner;
+ this.runner = runner;
+
+ // The token is available in the API for a limited amount of time
+ // preserve its original value if it is missing after polling.
+ this.token = ephemeralAuthenticationToken || this.token;
+ }
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+ captureException({ error, component: this.$options.name });
+ },
+ pollInterval() {
+ if (this.isRunnerOnline) {
+ // stop polling
+ return 0;
+ }
+ return RUNNER_REGISTRATION_POLLING_INTERVAL_MS;
+ },
+ },
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.runner.loading;
+ },
+ description() {
+ return this.runner?.description;
+ },
+ heading() {
+ if (this.description) {
+ return sprintf(
+ s__('Runners|Register "%{runnerDescription}" runner'),
+ {
+ runnerDescription: this.description,
+ },
+ false,
+ );
+ }
+ return s__('Runners|Register runner');
+ },
+ tokenMessage() {
+ if (this.token) {
+ return s__(
+ 'Runners|The %{boldStart}runner token%{boldEnd} %{token} displays %{boldStart}only for a short time%{boldEnd}, and is stored in the %{codeStart}config.toml%{codeEnd} after you register the runner. It will not be visible once the runner is registered.',
+ );
+ }
+ return s__(
+ 'Runners|The %{boldStart}runner token%{boldEnd} is no longer visible, it is stored in the %{codeStart}config.toml%{codeEnd} if you have registered the runner.',
+ );
+ },
+ commandPrompt() {
+ return commandPrompt({ platform: this.platform });
+ },
+ registerCommand() {
+ return registerCommand({
+ platform: this.platform,
+ 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,
+ I18N_REGISTRATION_SUCCESS,
+};
+</script>
+<template>
+ <div>
+ <h1 class="gl-font-size-h1">{{ heading }}</h1>
+
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|GitLab Runner must be installed before you can register a runner. %{linkStart}How do I install GitLab Runner?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link data-testid="runner-install-link" @click="toggleDrawer">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 1') }}</h2>
+ <p>
+ {{
+ s__(
+ 'Runners|Copy and paste the following command into your command line to register the runner.',
+ )
+ }}
+ </p>
+ <gl-skeleton-loader v-if="loading" />
+ <template v-else>
+ <cli-command :prompt="commandPrompt" :command="registerCommand" />
+ <p>
+ <gl-icon name="information-o" class="gl-text-blue-600!" />
+ <gl-sprintf :message="tokenMessage">
+ <template #token>
+ <code data-testid="runner-token">{{ token }}</code>
+ <clipboard-button
+ :text="token"
+ :title="__('Copy')"
+ size="small"
+ category="tertiary"
+ class="gl-border-none!"
+ />
+ </template>
+ <template #bold="{ content }"
+ ><span class="gl-font-weight-bold">{{ content }}</span></template
+ >
+ <template #code="{ content }"
+ ><code>{{ content }}</code></template
+ >
+ </gl-sprintf>
+ </p>
+ </template>
+ </section>
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 2') }}</h2>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Choose an executor when prompted by the command line. Executors run builds in different environments. %{linkStart}Not sure which one to select?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.EXECUTORS_HELP_URL" target="_blank">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 3 (optional)') }}</h2>
+ <p>{{ s__('Runners|Manually verify that the runner is available to pick up jobs.') }}</p>
+ <cli-command :prompt="commandPrompt" :command="runCommand" />
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|This may not be needed if you manage your runner as a %{linkStart}system or user service%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.SERVICE_COMMANDS_HELP_URL" target="_blank">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ <section v-if="isRunnerOnline">
+ <h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2>
+
+ <p class="gl-pl-6">
+ <gl-sprintf :message="s__('Runners|To view the runner, go to %{runnerListName}.')">
+ <template #runnerListName>
+ <span class="gl-font-weight-bold"><slot name="runner-list-name"></slot></span>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index 6b4e6a929b7..b196bccf66f 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
@@ -45,5 +45,9 @@ export default {
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
:form-input-group-props="formInputGroupProps"
@copy="onCopy"
- />
+ >
+ <template v-for="slot in Object.keys($scopedSlots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
+ </input-copy-toggle-visibility>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
index ac2793654c8..6ce88fc54de 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
@@ -73,13 +73,13 @@ export default {
actionPrimary() {
return {
text: i18n.modalAction,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
actionSecondary() {
return {
text: i18n.modalCancel,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
};
},
},
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh b/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh
new file mode 100644
index 00000000000..a8ba2592128
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh
@@ -0,0 +1,12 @@
+# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner ${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh b/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh
new file mode 100644
index 00000000000..76c893bacfc
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh
@@ -0,0 +1,11 @@
+# Download the binary for your system
+sudo curl --output /usr/local/bin/gitlab-runner ${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# The rest of the commands execute as the user who will run the runner
+# Register the runner (steps below), then run
+cd ~
+gitlab-runner install
+gitlab-runner start
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1 b/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1
new file mode 100644
index 00000000000..019363fc3f7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1
@@ -0,0 +1,13 @@
+# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator
+# Create a folder somewhere on your system, for example: C:\GitLab-Runner
+New-Item -Path 'C:\GitLab-Runner' -ItemType Directory
+
+# Change to the folder
+cd 'C:\GitLab-Runner'
+
+# Download binary
+Invoke-WebRequest -Uri "${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}" -OutFile "gitlab-runner.exe"
+
+# Register the runner (steps below), then run
+.\gitlab-runner.exe install
+.\gitlab-runner.exe start
diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js
new file mode 100644
index 00000000000..c8a75506c9c
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/utils.js
@@ -0,0 +1,81 @@
+import {
+ DEFAULT_PLATFORM,
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ DOWNLOAD_LOCATIONS,
+} from '../../constants';
+import linuxInstall from './scripts/linux/install.sh?raw';
+import osxInstall from './scripts/osx/install.sh?raw';
+import windowsInstall from './scripts/windows/install.ps1?raw';
+
+const OS = {
+ [LINUX_PLATFORM]: {
+ shell: 'bash',
+ commandPrompt: '$',
+ executable: 'gitlab-runner',
+ },
+ [MACOS_PLATFORM]: {
+ shell: 'bash',
+ commandPrompt: '$',
+ executable: 'gitlab-runner',
+ },
+ [WINDOWS_PLATFORM]: {
+ shell: 'powershell',
+ commandPrompt: '>',
+ executable: '.\\gitlab-runner.exe',
+ },
+};
+
+export const commandPrompt = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt;
+};
+
+export const executable = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).executable;
+};
+
+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 (token) {
+ lines.push(` --token ${token}`);
+ }
+ return lines;
+};
+
+export const runCommand = ({ platform }) => {
+ return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => {
+ switch (platform) {
+ case LINUX_PLATFORM:
+ return linuxInstall;
+ case MACOS_PLATFORM:
+ return osxInstall;
+ case WINDOWS_PLATFORM:
+ return windowsInstall;
+ default:
+ return '';
+ }
+};
+
+export const platformArchitectures = ({ platform }) => {
+ return DOWNLOAD_LOCATIONS[platform].map(({ arch }) => arch);
+};
+
+export const installScript = ({ platform, architecture }) => {
+ const downloadLocation = DOWNLOAD_LOCATIONS[platform].find(({ arch }) => arch === architecture)
+ .url;
+
+ return importInstallScript({ platform })
+ .replace(
+ // eslint-disable-next-line no-template-curly-in-string
+ '${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}',
+ downloadLocation,
+ )
+ .trim();
+};
diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
index 8dde3ac4e19..e7b26ec6d4e 100644
--- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__, sprintf } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql';
diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
new file mode 100644
index 00000000000..6107b4dd3ea
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlForm, GlButton } from '@gitlab/ui';
+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 {
+ RUNNER_TYPES,
+ DEFAULT_ACCESS_LEVEL,
+ PROJECT_TYPE,
+ GROUP_TYPE,
+ INSTANCE_TYPE,
+} from '../constants';
+
+export default {
+ name: 'RunnerCreateForm',
+ components: {
+ GlForm,
+ GlButton,
+ RunnerFormFields,
+ },
+ props: {
+ runnerType: {
+ type: String,
+ required: true,
+ validator: (t) => RUNNER_TYPES.includes(t),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ projectId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ saving: false,
+ runner: {
+ description: '',
+ maintenanceNote: '',
+ paused: false,
+ accessLevel: DEFAULT_ACCESS_LEVEL,
+ runUntagged: false,
+ tagList: '',
+ maximumTimeout: '',
+ },
+ };
+ },
+ computed: {
+ mutationInput() {
+ const { input } = modelToUpdateMutationVariables(this.runner);
+
+ if (this.runnerType === GROUP_TYPE) {
+ return {
+ ...input,
+ runnerType: GROUP_TYPE,
+ groupId: this.groupId,
+ };
+ }
+ if (this.runnerType === PROJECT_TYPE) {
+ return {
+ ...input,
+ runnerType: PROJECT_TYPE,
+ projectId: this.projectId,
+ };
+ }
+ return {
+ ...input,
+ runnerType: INSTANCE_TYPE,
+ };
+ },
+ },
+ methods: {
+ async onSubmit() {
+ this.saving = true;
+ try {
+ const {
+ data: {
+ runnerCreate: { errors, runner },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerCreateMutation,
+ variables: {
+ input: this.mutationInput,
+ },
+ });
+
+ if (errors?.length) {
+ this.$emit('error', new Error(errors.join(' ')));
+ } else {
+ this.onSuccess(runner);
+ }
+ } catch (error) {
+ captureException({ error, component: this.$options.name });
+ this.$emit('error', error);
+ } finally {
+ this.saving = false;
+ }
+ },
+ onSuccess(runner) {
+ this.$emit('saved', runner);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form @submit.prevent="onSubmit">
+ <runner-form-fields v-model="runner" />
+
+ <div class="gl-display-flex">
+ <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving">
+ {{ __('Submit') }}
+ </gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index f02e6bce5c3..020487fc727 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf, s__ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs.vue b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
index 9003eba3636..62e09346c2c 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
@@ -1,12 +1,13 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import runnerJobsQuery from '../graphql/show/runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
import { captureException } from '../sentry_utils';
import { getPaginationVariables } from '../utils';
import RunnerJobsTable from './runner_jobs_table.vue';
import RunnerPagination from './runner_pagination.vue';
+import RunnerJobsEmptyState from './runner_jobs_empty_state.vue';
export default {
name: 'RunnerJobs',
@@ -14,7 +15,9 @@ export default {
GlSkeletonLoader,
RunnerJobsTable,
RunnerPagination,
+ RunnerJobsEmptyState,
},
+
props: {
runner: {
type: Object,
@@ -75,7 +78,7 @@ export default {
<gl-skeleton-loader />
</div>
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
- <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
+ <runner-jobs-empty-state v-else />
<runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" />
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
new file mode 100644
index 00000000000..c30a824120d
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
@@ -0,0 +1,27 @@
+<script>
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+
+import { GlEmptyState } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('Runners|This runner has not run any jobs'),
+ description: s__(
+ 'Runners|Make sure the runner is online and available to run jobs (not paused). Jobs display here when the runner picks them up.',
+ ),
+ },
+ components: {
+ GlEmptyState,
+ },
+ EMPTY_STATE_SVG_URL,
+};
+</script>
+
+<template>
+ <gl-empty-state :svg-path="$options.EMPTY_STATE_SVG_URL" :title="$options.i18n.title">
+ <template #description>
+ <p>{{ $options.i18n.description }}</p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
index ebcda4f0ac3..5d8e9dcdee2 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
@@ -2,7 +2,7 @@
import { GlTableLite } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -50,11 +50,11 @@ export default {
},
duration(job) {
const { duration } = job;
- return duration ? durationTimeFormatted(duration) : '';
+ return duration ? formatTime(duration * 1000) : '';
},
queued(job) {
const { queuedDuration } = job;
- return queuedDuration ? durationTimeFormatted(queuedDuration) : '';
+ return queuedDuration ? formatTime(queuedDuration * 1000) : '';
},
},
fields: [
diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index d2f7912fabb..8606c22db34 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
@@ -1,4 +1,7 @@
<script>
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url';
+
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
@@ -20,16 +23,6 @@ export default {
required: false,
default: false,
},
- svgPath: {
- type: String,
- required: false,
- default: '',
- },
- filteredSvgPath: {
- type: String,
- required: false,
- default: '',
- },
registrationToken: {
type: String,
required: false,
@@ -43,12 +36,18 @@ export default {
},
computed: {
shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow feature flag
- return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow;
+ // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
+ return (
+ this.newRunnerPath &&
+ (this.glFeatures?.createRunnerWorkflowForAdmin ||
+ this.glFeatures?.createRunnerWorkflowForNamespace)
+ );
},
},
modalId: 'runners-empty-state-instructions-modal',
svgHeight: 145,
+ EMPTY_STATE_SVG_URL,
+ FILTERED_SVG_URL,
};
</script>
@@ -56,14 +55,14 @@ export default {
<gl-empty-state
v-if="isSearchFiltered"
:title="s__('Runners|No results found')"
- :svg-path="filteredSvgPath"
+ :svg-path="$options.FILTERED_SVG_URL"
:svg-height="$options.svgHeight"
:description="s__('Runners|Edit your search and try again')"
/>
<gl-empty-state
v-else
:title="s__('Runners|Get started with runners')"
- :svg-path="svgPath"
+ :svg-path="$options.EMPTY_STATE_SVG_URL"
:svg-height="$options.svgHeight"
>
<template v-if="registrationToken" #description>
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
index 2c80518e772..a27af232e97 100644
--- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { captureException } from '~/ci/runner/sentry_utils';
import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
index d70c51e83f9..48c5d2f9721 100644
--- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
@@ -30,6 +30,9 @@ export default {
isChecked() {
return this.value && this.value === this.checked;
},
+ imgClass() {
+ return 'gl-h-6 gl-mt-n2 gl-mr-2';
+ },
},
methods: {
onInput($event) {
@@ -47,23 +50,22 @@ export default {
<template>
<div
- class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6"
+ class="runner-platforms-radio gl-border gl-rounded-base gl-px-5 gl-pt-6 gl-pb-5"
:class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }"
@click="onInput(value)"
>
<gl-form-radio
v-if="value"
- class="gl-min-h-5"
:checked="checked"
:value="value"
@input="onInput($event)"
@change="onChange($event)"
>
- <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <img v-if="image" :src="image" :class="imgClass" aria-hidden="true" />
<span class="gl-font-weight-bold"><slot></slot></span>
</gl-form-radio>
- <div v-else class="gl-h-5">
- <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <div v-else class="gl-mb-3">
+ <img v-if="image" :src="image" :class="imgClass" aria-hidden="true" />
<span class="gl-font-weight-bold"><slot></slot></span>
</div>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
index 273226141d2..a49641194a7 100644
--- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
@@ -1,6 +1,6 @@
<script>
-import AWS_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/aws.svg?url';
import DOCKER_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/ci_cd-template-logos/docker.png';
+import LINUX_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/linux.svg?url';
import KUBERNETES_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/kubernetes.svg?url';
import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui';
@@ -8,7 +8,6 @@ import {
LINUX_PLATFORM,
MACOS_PLATFORM,
WINDOWS_PLATFORM,
- AWS_PLATFORM,
DOCKER_HELP_URL,
KUBERNETES_HELP_URL,
} from '../constants';
@@ -40,11 +39,10 @@ export default {
},
},
LINUX_PLATFORM,
+ LINUX_LOGO_URL,
MACOS_PLATFORM,
WINDOWS_PLATFORM,
- AWS_PLATFORM,
- AWS_LOGO_URL,
DOCKER_HELP_URL,
DOCKER_LOGO_URL,
KUBERNETES_HELP_URL,
@@ -59,7 +57,11 @@ export default {
<div class="gl-display-flex gl-flex-wrap gl-gap-5">
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM">
+ <runner-platforms-radio
+ v-model="model"
+ :image="$options.LINUX_LOGO_URL"
+ :value="$options.LINUX_PLATFORM"
+ >
Linux
</runner-platforms-radio>
<runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM">
@@ -72,20 +74,6 @@ export default {
</div>
<div class="gl-mt-3 gl-mb-6">
- <label>{{ s__('Runners|Cloud templates') }}</label>
- <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <div class="gl-display-flex gl-flex-wrap gl-gap-5">
- <runner-platforms-radio
- v-model="model"
- :image="$options.AWS_LOGO_URL"
- :value="$options.AWS_PLATFORM"
- >
- AWS
- </runner-platforms-radio>
- </div>
- </div>
-
- <div class="gl-mt-3 gl-mb-6">
<label>{{ s__('Runners|Containers') }}</label>
<div class="gl-display-flex gl-flex-wrap gl-gap-5">
diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue
index 4a6e90b44a9..4cfc57340f5 100644
--- a/app/assets/javascripts/ci/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue
@@ -1,7 +1,7 @@
<script>
import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
index a9790d06ca7..2d34c551d6d 100644
--- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
@@ -13,8 +13,8 @@ import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee_else_ce/ci/runner/runner_update_form_utils';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
@@ -101,7 +101,7 @@ export default {
},
onSuccess() {
saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS });
- redirectTo(this.runnerPath);
+ redirectTo(this.runnerPath); // eslint-disable-line import/no-deprecated
},
onError(message) {
this.saving = false;
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
index 6e7c41885f8..1de7775090a 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 318eb7e74bd..4e36a410a66 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -71,6 +71,12 @@ export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__(
'Runners|Runner is stale; it has never contacted this instance',
);
+// Registration dropdown
+export const I18N_REGISTER_INSTANCE_TYPE = s__('Runners|Register an instance runner');
+export const I18N_REGISTER_GROUP_TYPE = s__('Runners|Register a group runner');
+export const I18N_REGISTER_PROJECT_TYPE = s__('Runners|Register a project runner');
+export const I18N_REGISTER_RUNNER = s__('Runners|Register a runner');
+
// Actions
export const I18N_EDIT = __('Edit');
@@ -93,6 +99,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');
@@ -105,10 +112,15 @@ export const I18N_JOBS = s__('Runners|Jobs');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects');
export const I18N_CLEAR_FILTER_PROJECTS = __('Clear');
-export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
export const I18N_NO_PROJECTS_FOUND = __('No projects found');
+// Runner registration
+
+export const I18N_REGISTRATION_SUCCESS = s__("Runners|You've created a new runner!");
+
+export const RUNNER_REGISTRATION_POLLING_INTERVAL_MS = 2000;
+
// Styles
export const RUNNER_TAG_BADGE_VARIANT = 'info';
@@ -129,11 +141,14 @@ export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_AFTER = 'after';
export const PARAM_KEY_BEFORE = 'before';
+export const PARAM_KEY_PLATFORM = 'platform';
+
// CiRunnerType
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
@@ -180,11 +195,64 @@ export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
export const LINUX_PLATFORM = 'linux';
export const MACOS_PLATFORM = 'osx';
export const WINDOWS_PLATFORM = 'windows';
-export const AWS_PLATFORM = 'aws';
+
+export const DOWNLOAD_LOCATIONS = {
+ [LINUX_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64',
+ },
+ {
+ arch: '386',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386',
+ },
+ {
+ arch: 'arm',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm',
+ },
+ {
+ arch: 'arm64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64',
+ },
+ ],
+ [MACOS_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64',
+ },
+ {
+ arch: 'arm64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-arm64',
+ },
+ ],
+ [WINDOWS_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe',
+ },
+ {
+ arch: '386',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe',
+ },
+ ],
+};
export const DEFAULT_PLATFORM = LINUX_PLATFORM;
// Runner docs are in a separate repository and are not shipped with GitLab
// they are rendered as external URLs.
+export const INSTALL_HELP_URL = 'https://docs.gitlab.com/runner/install';
+export const EXECUTORS_HELP_URL = 'https://docs.gitlab.com/runner/executors/';
+export const SERVICE_COMMANDS_HELP_URL =
+ 'https://docs.gitlab.com/runner/commands/#service-related-commands';
+export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md';
export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
index 29abddf84f5..d18b80511fb 100644
--- a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
@@ -10,5 +10,5 @@ fragment RunnerFieldsShared on CiRunner {
maximumTimeout
tagList
createdAt
- status(legacyMode: null)
+ status
}
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..4eebcd01be6 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,8 +12,11 @@ fragment ListItemShared on CiRunner {
jobCount
tagList
createdAt
+ createdBy {
+ ...User
+ }
contactedAt
- status(legacyMode: null)
+ status
jobExecutionStatus
userPermissions {
updateRunner
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
new file mode 100644
index 00000000000..07236808dca
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
@@ -0,0 +1,9 @@
+mutation runnerCreate($input: RunnerCreateInput!) {
+ runnerCreate(input: $input) {
+ runner {
+ id
+ ephemeralRegisterUrl
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql b/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql
new file mode 100644
index 00000000000..f6cee807620
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerForRegistration($id: CiRunnerID!) {
+ runner(id: $id) {
+ id
+ description
+ ephemeralAuthenticationToken
+ status
+ }
+}
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
index b5689ff7687..bd53fb29bd0 100644
--- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -15,7 +15,7 @@ fragment RunnerDetailsShared on CiRunner {
jobCount
tagList
createdAt
- status(legacyMode: null)
+ status
contactedAt
tokenExpiresAt
version
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..67d29daf66f
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
@@ -0,0 +1,83 @@
+<script>
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.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: {
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ props: {
+ groupId: {
+ 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); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ GROUP_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <registration-feedback-banner />
+
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="groupId" />
+
+ <p>
+ {{
+ s__(
+ 'Runners|Create a group runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
+ </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..9e056081e03
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js
@@ -0,0 +1,32 @@
+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 { groupId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupNewRunnerApp, {
+ props: {
+ groupId,
+ },
+ });
+ },
+ });
+};
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_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index 273a9aa823c..1318bf5a2e6 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
@@ -1,8 +1,8 @@
<script>
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -76,7 +76,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath);
+ redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
},
},
};
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 e66a1c7b1aa..74523bc335f 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,6 +1,6 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { GlButton, GlLink } from '@gitlab/ui';
+import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config';
@@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlButton,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -56,8 +57,12 @@ export default {
RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
- inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
+ newRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
registrationToken: {
type: String,
required: false,
@@ -150,6 +155,10 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow_for_namespace feature flag
+ return this.glFeatures.createRunnerWorkflowForNamespace;
+ },
},
watch: {
search: {
@@ -207,7 +216,9 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-align-items-center">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
+ >
<runner-type-tabs
ref="runner-type-tabs"
v-model="search"
@@ -219,15 +230,23 @@ export default {
nav-class="gl-border-none!"
/>
- <registration-dropdown
- v-if="registrationToken"
- class="gl-ml-auto"
- :registration-token="registrationToken"
- :type="$options.GROUP_TYPE"
- right
- />
+ <div class="gl-w-full gl-md-w-auto gl-display-flex">
+ <gl-button
+ v-if="shouldShowCreateRunnerWorkflow && newRunnerPath"
+ :href="newRunnerPath"
+ variant="confirm"
+ >
+ {{ s__('Runners|New group runner') }}
+ </gl-button>
+ <registration-dropdown
+ v-if="registrationToken"
+ class="gl-ml-3"
+ :registration-token="registrationToken"
+ :type="$options.GROUP_TYPE"
+ right
+ />
+ </div>
</div>
-
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
:class="$options.FILTER_CSS_CLASSES"
@@ -250,8 +269,7 @@ export default {
v-if="noRunnersFound"
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
- :svg-path="emptyStateSvgPath"
- :filtered-svg-path="emptyStateFilteredSvgPath"
+ :new-runner-path="newRunnerPath"
/>
<template v-else>
<runner-list
diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 46514d5afe8..a5e2521ede5 100644
--- a/app/assets/javascripts/ci/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
@@ -18,12 +18,11 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
const {
registrationToken,
runnerInstallHelpPage,
+ newRunnerPath,
groupId,
groupFullPath,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@@ -41,14 +40,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupId,
onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
},
render(h) {
return h(GroupRunnersApp, {
props: {
registrationToken,
groupFullPath,
+ newRunnerPath,
},
});
},
diff --git a/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
index d768a06494a..bad3ca6024e 100644
--- a/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
+++ b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
@@ -7,7 +7,7 @@ export const showAlertFromLocalStorage = async () => {
if (alertOptions) {
try {
- const { createAlert } = await import('~/flash');
+ const { createAlert } = await import('~/alert');
createAlert(JSON.parse(alertOptions));
} catch {
// ignore when the alert data cannot be parsed
diff --git a/app/assets/javascripts/ci/runner/project_new_runner/index.js b/app/assets/javascripts/ci/runner/project_new_runner/index.js
new file mode 100644
index 00000000000..44f1a0ffdab
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_new_runner/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import ProjectNewRunnerApp from './project_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initProjectNewRunner = (selector = '#js-project-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { projectId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(ProjectNewRunnerApp, {
+ props: {
+ projectId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
new file mode 100644
index 00000000000..f0ae54c0232
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
@@ -0,0 +1,83 @@
+<script>
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.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, PROJECT_TYPE } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'ProjectNewRunnerApp',
+ components: {
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ props: {
+ projectId: {
+ 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); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ PROJECT_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <registration-feedback-banner />
+
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New project runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="projectId" />
+
+ <p>
+ {{
+ s__(
+ 'Runners|Create a project runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
+ </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.PROJECT_TYPE"
+ :project-id="projectId"
+ @saved="onSaved"
+ @error="onError"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/project_register_runner/index.js b/app/assets/javascripts/ci/runner/project_register_runner/index.js
new file mode 100644
index 00000000000..63e88359ee7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_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 ProjectRegisterRunnerApp from './project_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initProjectRegisterRunner = (selector = '#js-project-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(ProjectRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue b/app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue
new file mode 100644
index 00000000000..b3fad595c7e
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_register_runner/project_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: 'ProjectRegisterRunnerApp',
+ 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|Project › CI/CD Settings › 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/project_runners/register/index.js b/app/assets/javascripts/ci/runner/project_runners/register/index.js
new file mode 100644
index 00000000000..9986c93c918
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_runners/register/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue';
+import { PROJECT_TYPE } from '~/ci/runner/constants';
+
+Vue.use(VueApollo);
+
+export const initProjectRunnersRegistrationDropdown = (
+ selector = '#js-project-runner-registration-dropdown',
+) => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { registrationToken, projectId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ projectId,
+ },
+ render(h) {
+ return h(RegistrationDropdown, {
+ props: {
+ registrationToken,
+ type: PROJECT_TYPE,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
index 4593c9ae52b..843342b20df 100644
--- a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
+++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '../components/runner_header.vue';
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/agents/components/create_token_modal.vue b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
index 451e1ee1d67..d2e5e484502 100644
--- a/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
+++ b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
@@ -9,7 +9,6 @@ import {
EVENT_ACTIONS_OPEN,
EVENT_ACTIONS_CLICK,
TOKEN_NAME_LIMIT,
- TOKEN_STATUS_ACTIVE,
} from '../constants';
import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
@@ -66,7 +65,6 @@ export default {
variables: {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
},
};
diff --git a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
index f0af0da4bb4..c2c7beffae7 100644
--- a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
-import { REVOKE_TOKEN_MODAL_ID, TOKEN_STATUS_ACTIVE } from '../constants';
+import { REVOKE_TOKEN_MODAL_ID } from '../constants';
import revokeAgentToken from '../graphql/mutations/revoke_token.mutation.graphql';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import { removeTokenFromStore } from '../graphql/cache_update';
@@ -61,7 +61,6 @@ export default {
variables: {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
},
};
@@ -78,16 +77,17 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.revokeButton,
- attributes: [
- { disabled: this.loading || this.disableModalSubmit, loading: this.loading },
- { variant: 'danger' },
- ],
+ attributes: {
+ disabled: this.loading || this.disableModalSubmit,
+ loading: this.loading,
+ variant: 'danger',
+ },
};
},
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index f1bd36b4a63..d740d1c8865 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
+import { MAX_LIST_COUNT } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
@@ -31,7 +31,6 @@ export default {
return {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
};
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index e97d6500260..8eff9a152b2 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -37,7 +37,6 @@ export const EVENT_DETAILS = {
};
export const DEFAULT_ICON = 'token';
-export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
export const CREATE_TOKEN_MODAL = 'create-token';
export const EVENT_LABEL_MODAL = 'agent_token_creation_modal';
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
index d7a8e447071..7be524f92c4 100644
--- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -4,7 +4,6 @@
query getClusterAgent(
$projectPath: ID!
$agentName: String!
- $tokenStatus: AgentTokenStatus!
$first: Int
$last: Int
$afterToken: String
@@ -21,13 +20,7 @@ query getClusterAgent(
name
}
- tokens(
- status: $tokenStatus
- first: $first
- last: $last
- before: $beforeToken
- after: $afterToken
- ) {
+ tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
count
nodes {
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index a788703fd08..c94c91654fc 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,7 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
deleted file mode 100644
index c6ca895778d..00000000000
--- a/app/assets/javascripts/clusters/constants.js
+++ /dev/null
@@ -1,16 +0,0 @@
-// These need to match the enum found in app/models/clusters/cluster.rb
-export const CLUSTER_TYPE = {
- INSTANCE: 'instance_type',
- GROUP: 'group_type',
- PROJECT: 'project_type',
-};
-
-// These need to match the available providers in app/models/clusters/providers/
-export const PROVIDER_TYPE = {
- GCP: 'gcp',
-};
-
-// These are only used client-side
-
-export const LOGGING_MODE = 'logging';
-export const BLOCKING_MODE = 'blocking';
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/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index 7a028858d10..913db87f019 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -77,16 +77,17 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.modalAction,
- attributes: [
- { disabled: this.loading || this.disableModalSubmit, loading: this.loading },
- { variant: 'danger' },
- ],
+ attributes: {
+ disabled: this.loading || this.disableModalSubmit,
+ loading: this.loading,
+ variant: 'danger',
+ },
};
},
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 77d6d5eb009..1ea18dcc97d 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
diff --git a/app/assets/javascripts/code_review/signals.js b/app/assets/javascripts/code_review/signals.js
new file mode 100644
index 00000000000..101b7996bb5
--- /dev/null
+++ b/app/assets/javascripts/code_review/signals.js
@@ -0,0 +1,51 @@
+import createApolloClient from '../lib/graphql';
+
+import { getDerivedMergeRequestInformation } from '../diffs/utils/merge_request';
+import { EVT_MR_PREPARED } from '../diffs/constants';
+
+import getMr from '../graphql_shared/queries/merge_request.query.graphql';
+import mrPreparation from '../graphql_shared/subscriptions/merge_request_prepared.subscription.graphql';
+
+function required(name) {
+ throw new Error(`${name} is a required argument`);
+}
+
+async function observeMergeRequestFinishingPreparation({ apollo, signaler }) {
+ const { namespace, project, id: iid } = getDerivedMergeRequestInformation({
+ endpoint: document.location.pathname,
+ });
+ const projectPath = `${namespace}/${project}`;
+
+ if (projectPath && iid) {
+ const currentStatus = await apollo.query({
+ query: getMr,
+ variables: { projectPath, iid },
+ });
+ const { id: gqlMrId, preparedAt } = currentStatus.data.project.mergeRequest;
+ let preparationObservable;
+ let preparationSubscriber;
+
+ if (!preparedAt) {
+ preparationObservable = apollo.subscribe({
+ query: mrPreparation,
+ variables: {
+ issuableId: gqlMrId,
+ },
+ });
+
+ preparationSubscriber = preparationObservable.subscribe((preparationUpdate) => {
+ if (preparationUpdate.data.mergeRequestMergeStatusUpdated?.preparedAt) {
+ signaler.$emit(EVT_MR_PREPARED);
+ preparationSubscriber.unsubscribe();
+ }
+ });
+ }
+ }
+}
+
+export async function start({
+ signalBus = required('signalBus'),
+ apolloClient = createApolloClient(),
+} = {}) {
+ await observeMergeRequestFinishingPreparation({ signaler: signalBus, apollo: apolloClient });
+}
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue
index db8476c44f3..9e0d2cc73ec 100644
--- a/app/assets/javascripts/saved_replies/components/app.vue
+++ b/app/assets/javascripts/comment_templates/components/app.vue
@@ -6,18 +6,18 @@ 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>
</div>
<div class="col-lg-8">
- <router-view />
+ <keep-alive><router-view /></keep-alive>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
new file mode 100644
index 00000000000..47efccc3d0c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -0,0 +1,182 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { logError } from '~/lib/logger';
+import { __ } from '~/locale';
+import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql';
+import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlAlert,
+ MarkdownField,
+ },
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ content: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ errors: [],
+ saving: false,
+ showValidation: false,
+ updateCommentTemplate: {
+ name: this.name,
+ content: this.content,
+ },
+ };
+ },
+ computed: {
+ isNameValid() {
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.name);
+
+ return true;
+ },
+ isContentValid() {
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.content);
+
+ return true;
+ },
+ isValid() {
+ return this.isNameValid && this.isContentValid;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.showValidation = true;
+
+ if (!this.isValid) return;
+
+ this.errors = [];
+ this.saving = true;
+
+ this.$apollo
+ .mutate({
+ mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation,
+ variables: {
+ id: this.id,
+ 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.updateCommentTemplate = { name: '', content: '' };
+ this.showValidation = false;
+ }
+ },
+ })
+ .catch((error) => {
+ const errors = error.graphQLErrors;
+
+ if (errors?.length) {
+ this.errors = errors.map((e) => e.message);
+ } else {
+ // Let's be sure to log the original error so it isn't just swallowed.
+ // Also, we don't want to translate console messages.
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Unexpected error while saving reply', error);
+
+ this.errors = [__('An unexpected error occurred. Please try again.')];
+ }
+ })
+ .finally(() => {
+ this.saving = false;
+ });
+ },
+ },
+ restrictedToolbarItems: ['full-screen'],
+ markdownDocsPath: helpPagePath('user/markdown'),
+};
+</script>
+
+<template>
+ <gl-form
+ class="new-note common-note-form gl-mb-6"
+ data-testid="comment-template-form"
+ @submit.prevent="onSubmit"
+ >
+ <gl-alert
+ v-for="error in errors"
+ :key="error"
+ variant="danger"
+ class="gl-mb-3"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ <gl-form-group
+ :label="__('Name')"
+ :state="isNameValid"
+ :invalid-feedback="__('Please enter a name for the comment template.')"
+ data-testid="comment-template-name-form-group"
+ >
+ <gl-form-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 comment template content.')"
+ data-testid="comment-template-content-form-group"
+ >
+ <markdown-field
+ :enable-preview="false"
+ :is-submitting="saving"
+ :add-spacing-classes="false"
+ :textarea-value="updateCommentTemplate.content"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ class="js-no-autosize gl-border-gray-400!"
+ >
+ <template #textarea>
+ <textarea
+ 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 comment template content here…')"
+ data-testid="comment-template-content-input"
+ @keydown.meta.enter="onSubmit"
+ @keydown.ctrl.enter="onSubmit"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <gl-button
+ variant="confirm"
+ class="gl-mr-3 js-no-auto-disable"
+ type="submit"
+ :loading="saving"
+ data-testid="comment-template-form-submit-btn"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ <gl-button v-if="id" :to="{ path: '/' }">{{ __('Cancel') }}</gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
new file mode 100644
index 00000000000..52bebfd050c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import ListItem from './list_item.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlSprintf,
+ ListItem,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ savedReplies: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ prevPage() {
+ this.$emit('input', {
+ before: this.pageInfo.beforeCursor,
+ });
+ },
+ nextPage() {
+ this.$emit('input', {
+ after: this.pageInfo.endCursor,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <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 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="template in savedReplies" :key="template.id" :template="template" />
+ </ul>
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ class="gl-mt-4"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </template>
+ </div>
+</template>
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/comment_templates/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue
new file mode 100644
index 00000000000..343efdccefa
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/pages/edit.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { fetchPolicies } from '~/lib/graphql';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_USERS_SAVED_REPLY } from '~/graphql_shared/constants';
+import CreateForm from '../components/form.vue';
+import getSavedReply from '../queries/get_saved_reply.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ CreateForm,
+ },
+ apollo: {
+ savedReply: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: getSavedReply,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPE_USERS_SAVED_REPLY, this.$route.params.id),
+ };
+ },
+ update: (r) => r.currentUser.savedReply,
+ skip() {
+ return !this.$route.params.id;
+ },
+ result({
+ data: {
+ currentUser: { savedReply },
+ },
+ }) {
+ if (!savedReply) {
+ createAlert({ message: __('Unable to find comment template') });
+ this.redirectToRoot();
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ savedReply: null,
+ };
+ },
+ methods: {
+ redirectToRoot() {
+ this.$router.push({ path: '/' });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Edit comment template') }}
+ </h5>
+ <gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" />
+ <create-form
+ v-else-if="savedReply"
+ :id="savedReply.id"
+ :name="savedReply.name"
+ :content="savedReply.content"
+ @saved="redirectToRoot"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
new file mode 100644
index 00000000000..72a94dafc58
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -0,0 +1,67 @@
+<script>
+import { fetchPolicies } from '~/lib/graphql';
+import CreateForm from '../components/form.vue';
+import savedRepliesQuery from '../queries/saved_replies.query.graphql';
+import List from '../components/list.vue';
+
+export default {
+ apollo: {
+ savedReplies: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ variables() {
+ return {
+ ...this.pagination,
+ };
+ },
+ result({ data }) {
+ const pageInfo = data.currentUser?.savedReplies?.pageInfo;
+
+ this.count = data.currentUser?.savedReplies?.count;
+
+ if (pageInfo) {
+ this.pageInfo = pageInfo;
+ }
+ },
+ },
+ },
+ components: {
+ CreateForm,
+ List,
+ },
+ data() {
+ return {
+ savedReplies: [],
+ count: 0,
+ pageInfo: {},
+ pagination: {},
+ };
+ },
+ methods: {
+ refetchSavedReplies() {
+ this.pagination = {};
+ this.$apollo.queries.savedReplies.refetch();
+ },
+ changePage(pageInfo) {
+ this.pagination = pageInfo;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new comment template') }}
+ </h5>
+ <create-form @saved="refetchSavedReplies" />
+ <list
+ :loading="$apollo.queries.savedReplies.loading"
+ :saved-replies="savedReplies"
+ :page-info="pageInfo"
+ :count="count"
+ @input="changePage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..c4e632d0f16
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
@@ -0,0 +1,10 @@
+mutation savedReplyCreate($name: String!, $content: String!) {
+ savedReplyMutation: savedReplyCreate(input: { name: $name, content: $content }) {
+ errors
+ savedReply {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..76571ba628c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteSavedReply($id: UsersSavedReplyID!) {
+ savedReplyDestroy(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
new file mode 100644
index 00000000000..66f5f43af49
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
@@ -0,0 +1,10 @@
+query getSavedReply($id: UsersSavedReplyID!) {
+ currentUser {
+ id
+ savedReply(id: $id) {
+ id
+ name
+ content
+ }
+ }
+}
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 af1f12f3ceb..d8e76b5e2a8 100644
--- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
+++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql
@@ -1,7 +1,7 @@
-query savedReplies {
+query savedReplies($after: String = "", $before: String = "") {
currentUser {
id
- savedReplies {
+ savedReplies(after: $after, before: $before) {
nodes {
id
name
diff --git a/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..14a47d7bc9c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
@@ -0,0 +1,10 @@
+mutation savedReplyUpdate($id: UsersSavedReplyID!, $name: String!, $content: String!) {
+ savedReplyMutation: savedReplyUpdate(input: { id: $id, name: $name, content: $content }) {
+ errors
+ savedReply {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/comment_templates/routes.js
index bd582a5ed86..7687c6f335a 100644
--- a/app/assets/javascripts/saved_replies/routes.js
+++ b/app/assets/javascripts/comment_templates/routes.js
@@ -1,8 +1,15 @@
import IndexComponent from './pages/index.vue';
+import EditComponent from './pages/edit.vue';
+
export default [
{
path: '/',
component: IndexComponent,
},
+ {
+ name: 'edit',
+ path: '/:id',
+ component: EditComponent,
+ },
];
diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue
new file mode 100644
index 00000000000..344536df093
--- /dev/null
+++ b/app/assets/javascripts/commit/components/signature_badge.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlBadge, GlLink, GlPopover } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { typeConfig, statusConfig } from '../constants';
+import X509CertificateDetails from './x509_certificate_details.vue';
+
+export default {
+ components: {
+ GlBadge,
+ GlPopover,
+ GlLink,
+ X509CertificateDetails,
+ },
+ props: {
+ signature: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ statusConfig() {
+ return this.$options.statusConfig?.[this.signature?.verificationStatus];
+ },
+ typeConfig() {
+ // eslint-disable-next-line no-underscore-dangle
+ return this.$options.typeConfig?.[this.signature?.__typename];
+ },
+ },
+ methods: {
+ helpPagePath,
+ getSubjectKeyIdentifierToDisplay(subjectKeyIdentifier) {
+ // we need to remove : to not trigger secret detection scan
+ return subjectKeyIdentifier.replaceAll(':', ' ');
+ },
+ },
+ typeConfig,
+ statusConfig,
+};
+</script>
+<template>
+ <span
+ v-if="statusConfig && typeConfig"
+ class="gl-display-flex gl-align-items-center gl-hover-cursor-pointer gl-ml-2"
+ >
+ <button
+ id="signature"
+ tabindex="0"
+ data-testid="signature-badge"
+ role="button"
+ variant="link"
+ class="gl-border-0 gl-outline-0! gl-p-0 gl-bg-transparent"
+ :aria-label="statusConfig.label"
+ >
+ <gl-badge :variant="statusConfig.variant" size="md" data-testid="signature-status">
+ {{ statusConfig.label }}
+ </gl-badge>
+ </button>
+ <gl-popover target="signature" triggers="focus" data-testid="signature-info">
+ <template #title>
+ {{ statusConfig.title }}
+ </template>
+ <p data-testid="signature-description">
+ {{ statusConfig.description }}
+ </p>
+ <p v-if="typeConfig.keyLabel" data-testid="signature-key-label">
+ {{ typeConfig.keyLabel }}
+ <span class="gl-font-monospace" data-testid="signature-key">
+ {{ signature[typeConfig.keyNamespace] || __('Unknown') }}
+ </span>
+ </p>
+ <x509-certificate-details
+ v-if="signature.x509Certificate"
+ :title="typeConfig.subjectTitle"
+ :subject="signature.x509Certificate.subject"
+ :subject-key-identifier="
+ getSubjectKeyIdentifierToDisplay(signature.x509Certificate.subjectKeyIdentifier)
+ "
+ />
+ <x509-certificate-details
+ v-if="signature.x509Certificate && signature.x509Certificate.x509Issuer"
+ :title="typeConfig.issuerTitle"
+ :subject="signature.x509Certificate.x509Issuer.subject"
+ :subject-key-identifier="
+ getSubjectKeyIdentifierToDisplay(
+ signature.x509Certificate.x509Issuer.subjectKeyIdentifier,
+ )
+ "
+ />
+ <gl-link :href="helpPagePath(typeConfig.helpLink.path)">
+ {{ typeConfig.helpLink.label }}
+ </gl-link>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/commit/components/x509_certificate_details.vue b/app/assets/javascripts/commit/components/x509_certificate_details.vue
new file mode 100644
index 00000000000..6880fab9043
--- /dev/null
+++ b/app/assets/javascripts/commit/components/x509_certificate_details.vue
@@ -0,0 +1,45 @@
+<script>
+import { X509_CERTIFICATE_KEY_IDENTIFIER_TITLE } from '../constants';
+
+export default {
+ props: {
+ subject: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ subjectKeyIdentifier: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ subjectValues() {
+ return this.subject.split(',');
+ },
+ subjectKeyIdentifierToDisplay() {
+ return this.subjectKeyIdentifier.replaceAll(':', ' ');
+ },
+ },
+ i18n: {
+ keyIdentifierTitle: X509_CERTIFICATE_KEY_IDENTIFIER_TITLE,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <strong>{{ title }}</strong>
+ <ul class="gl-pl-5">
+ <li v-for="value in subjectValues" :key="value" data-testid="subject-value">
+ {{ value }}
+ </li>
+ <li data-testid="key-identifier">
+ {{ $options.i18n.keyIdentifierTitle }} {{ subjectKeyIdentifierToDisplay }}
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/commit/constants.js b/app/assets/javascripts/commit/constants.js
new file mode 100644
index 00000000000..4f865e99e46
--- /dev/null
+++ b/app/assets/javascripts/commit/constants.js
@@ -0,0 +1,104 @@
+import { __, s__ } from '~/locale';
+
+export const X509_CERTIFICATE_KEY_IDENTIFIER_TITLE = __('Subject Key Identifier:');
+
+export const verificationStatuses = {
+ VERIFIED: 'VERIFIED',
+ UNVERIFIED: 'UNVERIFIED',
+ UNVERIFIED_KEY: 'UNVERIFIED_KEY',
+ UNKNOWN_KEY: 'UNKNOWN_KEY',
+ OTHER_USER: 'OTHER_USER',
+ SAME_USER_DIFFERENT_EMAIL: 'SAME_USER_DIFFERENT_EMAIL',
+ MULTIPLE_SIGNATURES: 'MULTIPLE_SIGNATURES',
+ REVOKED_KEY: 'REVOKED_KEY',
+};
+
+export const signatureTypes = {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ GPG: 'GpgSignature',
+ X509: 'X509Signature',
+ SSH: 'SshSignature',
+ /* eslint-enable @gitlab/require-i18n-strings */
+};
+
+const UNVERIFIED_CONFIG = {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('Unverified signature'),
+ description: __('This commit was signed with an unverified signature.'),
+};
+
+export const statusConfig = {
+ [verificationStatuses.VERIFIED]: {
+ variant: 'success',
+ label: __('Verified'),
+ title: __('Verified commit'),
+ description: __(
+ 'This commit was signed with a verified signature and the committer email was verified to belong to the same user.',
+ ),
+ },
+ [verificationStatuses.UNVERIFIED]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.UNVERIFIED_KEY]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.UNKNOWN_KEY]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.OTHER_USER]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __("Different user's signature"),
+ description: __('This commit was signed with an unverified signature.'),
+ },
+ [verificationStatuses.SAME_USER_DIFFERENT_EMAIL]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('GPG key mismatch'),
+ description: __(
+ 'This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.',
+ ),
+ },
+ [verificationStatuses.MULTIPLE_SIGNATURES]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('Multiple signatures'),
+ description: __('This commit was signed with multiple signatures.'),
+ },
+ [verificationStatuses.REVOKED_KEY]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: s__('CommitSignature|Unverified signature'),
+ description: s__('CommitSignature|This commit was signed with a key that was revoked.'),
+ },
+};
+
+export const typeConfig = {
+ [signatureTypes.GPG]: {
+ keyLabel: __('GPG Key ID:'),
+ keyNamespace: 'gpgKeyPrimaryKeyid',
+ helpLink: {
+ label: __('Learn about signing commits'),
+ path: 'user/project/repository/gpg_signed_commits/index.md',
+ },
+ },
+ [signatureTypes.X509]: {
+ keyLabel: '',
+ helpLink: {
+ label: __('Learn more about X.509 signed commits'),
+ path: '/user/project/repository/x509_signed_commits/index.md',
+ },
+ subjectTitle: __('Certificate Subject'),
+ issuerTitle: __('Certificate Issuer'),
+ keyIdentifierTitle: __('Subject Key Identifier:'),
+ },
+ [signatureTypes.SSH]: {
+ keyLabel: __('SSH key fingerprint:'),
+ keyNamespace: 'keyFingerprintSha256',
+ helpLink: {
+ label: __('Learn about signing commits with SSH keys.'),
+ path: '/user/project/repository/ssh_signed_commits/index.md',
+ },
+ },
+};
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 2109aecdf03..96c274225d8 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,6 +1,14 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { initPipelineCountListener } from './utils';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
/**
* Used in:
* - Project Pipelines List (projects:pipelines:index)
@@ -20,9 +28,12 @@ export default () => {
components: {
CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
},
+ apolloProvider,
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
+ fullPath: pipelineTableViewEl.dataset.fullPath,
+ manualActionsLimit: 50,
},
render(createElement) {
return createElement('commit-pipelines-table', {
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index b0a1c46e619..f2dac15a99e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -234,6 +234,7 @@ export default {
<template v-else-if="shouldRenderEmptyState">
<gl-empty-state
:svg-path="emptyStateSvgPath"
+ :svg-height="150"
:title="$options.i18n.emptyStateTitle"
data-testid="pipeline-empty-state"
>
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
index d40cbe589c0..38abb7bebb0 100644
--- a/app/assets/javascripts/commit_merge_requests.js
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { n__, s__ } from './locale';
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/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index b105273ece7..90dca0310f3 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -30,6 +30,12 @@ function updateMergeRequestCounts(newCount) {
* Refresh user counts (and broadcast if open)
*/
export function refreshUserMergeRequestCounts() {
+ if (gon?.use_new_navigation) {
+ // The new sidebar manages _all_ the counts in
+ // ~/super_sidebar/user_counts_manager.js
+ document.dispatchEvent(new CustomEvent('userCounts:fetch'));
+ return Promise.resolve();
+ }
return getUserCounts()
.then(({ data }) => {
const assignedMergeRequests = data.assigned_merge_requests;
@@ -67,6 +73,12 @@ export function closeUserCountsBroadcast() {
* no special functionality lost except cross tab notifications
*/
export function openUserCountsBroadcast() {
+ if (gon?.use_new_navigation) {
+ // The new sidebar broadcasts _all counts_ and updates
+ // them accordingly. Therefore we do not need this manager
+ // ~/super_sidebar/user_counts_manager.js
+ return;
+ }
closeUserCountsBroadcast();
if (window.BroadcastChannel) {
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index cd24a503631..09d2e065e58 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -9,4 +9,4 @@ if (process.env.NODE_ENV !== 'production') {
Vue.use(GlFeatureFlagsPlugin);
Vue.use(Translate);
-Vue.config.ignoredElements = ['gl-emoji'];
+Vue.config.ignoredElements = ['gl-emoji', 'copy-code'];
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 196f5537a90..97762ff549b 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Api from '~/api';
import { __ } from '~/locale';
import state from '../state';
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/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
index 3891274e35e..7c06417e6b3 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -43,6 +43,7 @@ export default {
this.$emit('hidden', ...args);
this.menuVisible = false;
},
+ appendTo: () => document.body,
},
}),
);
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
index 98b7203778f..caac61fe9a6 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
@@ -250,7 +250,7 @@ export default {
variant="default"
category="tertiary"
size="medium"
- :class="{ active: showPreview }"
+ :class="{ 'gl-bg-gray-100!': showPreview }"
data-testid="preview-diagram"
:aria-label="__('Preview diagram')"
:title="__('Preview diagram')"
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
deleted file mode 100644
index 354db88f11c..00000000000
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ /dev/null
@@ -1,133 +0,0 @@
-<script>
-import { GlButtonGroup } from '@gitlab/ui';
-import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants';
-import trackUIControl from '../../services/track_ui_control';
-import Paragraph from '../../extensions/paragraph';
-import Heading from '../../extensions/heading';
-import Audio from '../../extensions/audio';
-import Video from '../../extensions/video';
-import Image from '../../extensions/image';
-import ToolbarButton from '../toolbar_button.vue';
-import BubbleMenu from './bubble_menu.vue';
-
-export default {
- components: {
- BubbleMenu,
- GlButtonGroup,
- ToolbarButton,
- },
- inject: ['tiptapEditor'],
- methods: {
- trackToolbarControlExecution({ contentType, value }) {
- trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
- },
-
- shouldShow: ({ editor, from, to }) => {
- if (from === to) return false;
-
- const includes = [Paragraph.name, Heading.name];
- const excludes = [Image.name, Audio.name, Video.name];
-
- return (
- includes.some((type) => editor.isActive(type)) &&
- !excludes.some((type) => editor.isActive(type))
- );
- },
- },
- toggleLinkCommandParams: {
- href: '',
- },
-};
-</script>
-<template>
- <bubble-menu
- data-testid="formatting-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- :should-show="shouldShow"
- :plugin-key="'formatting'"
- >
- <gl-button-group>
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- editor-command="toggleBold"
- category="tertiary"
- size="medium"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- editor-command="toggleItalic"
- category="tertiary"
- size="medium"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
- editor-command="toggleStrike"
- category="tertiary"
- size="medium"
- :label="__('Strikethrough')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- editor-command="toggleCode"
- category="tertiary"
- size="medium"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="superscript"
- content-type="superscript"
- icon-name="superscript"
- editor-command="toggleSuperscript"
- category="tertiary"
- size="medium"
- :label="__('Superscript')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="subscript"
- content-type="subscript"
- icon-name="subscript"
- editor-command="toggleSubscript"
- category="tertiary"
- size="medium"
- :label="__('Subscript')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="highlight"
- content-type="highlight"
- icon-name="highlight"
- editor-command="toggleHighlight"
- category="tertiary"
- size="medium"
- :label="__('Highlight')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="link"
- content-type="link"
- icon-name="link"
- editor-command="toggleLink"
- :editor-command-params="$options.toggleLinkCommandParams"
- category="tertiary"
- size="medium"
- :label="__('Insert link')"
- @execute="trackToolbarControlExecution"
- />
- </gl-button-group>
- </bubble-menu>
-</template>
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..91009fad3f4 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
@@ -1,13 +1,16 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
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';
@@ -15,12 +18,14 @@ import BubbleMenu from './bubble_menu.vue';
export default {
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlLink,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
EditorStateObserver,
},
directives: {
@@ -31,12 +36,39 @@ export default {
return {
linkHref: undefined,
linkCanonicalSrc: undefined,
- linkTitle: undefined,
+ linkText: undefined,
isEditing: false,
+
+ uploading: false,
+ uploadProgress: 0,
};
},
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 +84,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 +136,34 @@ export default {
updateLinkToState() {
const editor = this.tiptapEditor;
+ const { href, canonicalSrc, uploading } = editor.getAttributes(Link.name);
+ const text = this.linkTextInDoc();
- const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
+ this.uploading = uploading;
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;
+ },
+
+ onTransaction({ transaction }) {
+ this.linkText = this.linkTextInDoc();
+ if (transaction.getMeta('creatingLink')) {
+ this.isEditing = true;
+ }
- this.isEditing = !this.linkCanonicalSrc;
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
},
copyLinkHref() {
@@ -107,13 +171,28 @@ 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;
+
+ this.isEditing = false;
},
},
tippyOptions: {
@@ -122,18 +201,29 @@ export default {
};
</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-loading-icon v-if="uploading" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<gl-link
+ v-else
v-gl-tooltip
:href="linkHref"
:aria-label="linkCanonicalSrc"
@@ -178,11 +268,16 @@ 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="__('URL')" label-for="link-href">
- <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" />
+ <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="__('Title')" label-for="link-title">
- <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" />
+ <gl-form-group :label="__('URL')" label-for="link-href">
+ <gl-form-input
+ id="link-href"
+ v-model="linkCanonicalSrc"
+ autofocus
+ data-testid="link-href"
+ />
</gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink">
@@ -193,6 +288,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/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
index 310bb1be81f..6bb6bdc4e65 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
@@ -1,6 +1,7 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -11,23 +12,26 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
import Audio from '../../extensions/audio';
+import DrawioDiagram from '../../extensions/drawio_diagram';
import Image from '../../extensions/image';
import Video from '../../extensions/video';
import EditorStateObserver from '../editor_state_observer.vue';
import { acceptedMimes } from '../../services/upload_helpers';
import BubbleMenu from './bubble_menu.vue';
-const MEDIA_TYPES = [Audio.name, Image.name, Video.name];
+const MEDIA_TYPES = [Audio.name, Image.name, Video.name, DrawioDiagram.name];
export default {
i18n: {
copySourceLabels: {
[Audio.name]: __('Copy audio URL'),
+ [DrawioDiagram.name]: __('Copy diagram URL'),
[Image.name]: __('Copy image URL'),
[Video.name]: __('Copy video URL'),
},
editLabels: {
[Audio.name]: __('Edit audio description'),
+ [DrawioDiagram.name]: __('Edit diagram description'),
[Image.name]: __('Edit image description'),
[Video.name]: __('Edit video description'),
},
@@ -38,12 +42,14 @@ export default {
},
deleteLabels: {
[Audio.name]: __('Delete audio'),
+ [DrawioDiagram.name]: __('Delete diagram'),
[Image.name]: __('Delete image'),
[Video.name]: __('Delete video'),
},
},
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -67,7 +73,10 @@ export default {
isEditing: false,
isUpdating: false,
- isUploading: false,
+
+ uploading: false,
+
+ uploadProgress: 0,
};
},
computed: {
@@ -84,7 +93,10 @@ export default {
return this.$options.i18n.deleteLabels[this.mediaType];
},
showProgressIndicator() {
- return this.isUploading || this.isUpdating;
+ return this.uploading || this.isUpdating;
+ },
+ isDrawioDiagram() {
+ return this.mediaType === DrawioDiagram.name;
},
},
methods: {
@@ -150,16 +162,37 @@ export default {
this.mediaTitle = title;
this.mediaAlt = alt;
this.mediaCanonicalSrc = canonicalSrc || src;
- this.isUploading = uploading;
+ this.uploading = uploading;
+
this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc);
this.isUpdating = false;
},
+ onTransaction({ transaction }) {
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
+ },
+
+ resetMediaInfo() {
+ this.mediaTitle = null;
+ this.mediaAlt = null;
+ this.mediaCanonicalSrc = null;
+ this.uploading = false;
+
+ this.uploadProgress = 0;
+ },
+
replaceMedia() {
this.$refs.fileSelector.click();
},
+ editDiagram() {
+ this.tiptapEditor.chain().focus().createOrEditDiagram().run();
+ },
+
onFileSelect(e) {
this.tiptapEditor
.chain()
@@ -186,15 +219,26 @@ export default {
};
</script>
<template>
- <bubble-menu
- data-testid="media-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuMedia"
- :should-show="shouldShow"
+ <editor-state-observer
+ :debounce="0"
+ @selectionUpdate="updateMediaInfoToState"
+ @transaction="onTransaction"
>
- <editor-state-observer @transaction="updateMediaInfoToState">
+ <bubble-menu
+ data-testid="media-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuMedia"
+ :should-show="shouldShow"
+ @show="updateMediaInfoToState"
+ @hidden="resetMediaInfo"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<input
ref="fileSelector"
type="file"
@@ -240,6 +284,19 @@ export default {
@click="startEditingMedia"
/>
<gl-button
+ v-if="isDrawioDiagram"
+ v-gl-tooltip
+ variant="default"
+ category="tertiary"
+ size="medium"
+ data-testid="edit-diagram"
+ :aria-label="replaceLabel"
+ title="Edit diagram"
+ icon="diagram"
+ @click="editDiagram"
+ />
+ <gl-button
+ v-else
v-gl-tooltip
variant="default"
category="tertiary"
@@ -247,7 +304,7 @@ export default {
data-testid="replace-media"
:aria-label="replaceLabel"
:title="replaceLabel"
- icon="upload"
+ icon="retry"
@click="replaceMedia"
/>
<gl-button
@@ -282,6 +339,6 @@ export default {
<gl-button variant="confirm" type="submit">{{ __('Apply') }}</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 237808983ee..4c5bbca4110 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,13 +1,13 @@
<script>
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { __ } from '~/locale';
-import { VARIANT_DANGER } from '~/flash';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { VARIANT_DANGER } from '~/alert';
import { createContentEditor } from '../services/create_content_editor';
import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
-import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue';
import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue';
import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue';
import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue';
@@ -16,12 +16,13 @@ import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
+ GlSprintf,
+ GlLink,
LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
FormattingToolbar,
- FormattingBubbleMenu,
CodeBlockBubbleMenu,
LinkBubbleMenu,
MediaBubbleMenu,
@@ -51,17 +52,42 @@ export default {
required: false,
default: '',
},
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
autofocus: {
type: [String, Boolean],
required: false,
default: false,
validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus),
},
- useBottomToolbar: {
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
type: Boolean,
required: false,
default: false,
},
+ editable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -70,15 +96,33 @@ export default {
latestMarkdown: null,
};
},
+ computed: {
+ showPlaceholder() {
+ return this.placeholder && !this.markdown && !this.focused;
+ },
+ },
watch: {
markdown(markdown) {
if (markdown !== this.latestMarkdown) {
this.setSerializedContent(markdown);
}
},
+ editable(value) {
+ this.contentEditor.setEditable(value);
+ },
},
created() {
- const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this;
+ const {
+ renderMarkdown,
+ uploadsPath,
+ extensions,
+ serializerConfig,
+ autofocus,
+ drawioEnabled,
+ editable,
+ enableAutocomplete,
+ autocompleteDataSources,
+ } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -86,8 +130,12 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ drawioEnabled,
+ enableAutocomplete,
+ autocompleteDataSources,
tiptapOptions: {
autofocus,
+ editable,
},
});
},
@@ -104,10 +152,10 @@ export default {
try {
await this.contentEditor.setSerializedContent(markdown);
- this.contentEditor.setEditable(true);
this.notifyLoadingSuccess();
this.latestMarkdown = markdown;
} catch {
+ this.contentEditor.setEditable(false);
this.contentEditor.eventHub.$emit(ALERT_EVENT, {
message: __(
'An error occurred while trying to render the content editor. Please try again.',
@@ -115,10 +163,10 @@ export default {
variant: VARIANT_DANGER,
actionLabel: __('Retry'),
action: () => {
+ this.contentEditor.setEditable(true);
this.setSerializedContent(markdown);
},
});
- this.contentEditor.setEditable(false);
this.notifyLoadingError();
}
},
@@ -150,6 +198,11 @@ export default {
});
},
},
+ i18n: {
+ quickActionsText: s__(
+ 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.',
+ ),
+ },
};
</script>
<template>
@@ -165,33 +218,41 @@ export default {
<div
data-testid="content-editor"
data-qa-selector="content_editor_container"
- class="md-area"
+ class="md-area gl-border-none! gl-shadow-none!"
:class="{ 'is-focused': focused }"
>
- <formatting-toolbar
- v-if="!useBottomToolbar"
- ref="toolbar"
- class="gl-border-b"
- @enableMarkdownEditor="$emit('enableMarkdownEditor')"
- />
- <div class="gl-relative gl-mt-4">
- <formatting-bubble-menu />
+ <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" />
+ <div class="gl-relative">
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
+ <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4">
+ {{ placeholder }}
+ </div>
<tiptap-editor-content
- class="md"
+ class="md gl-px-5"
data-testid="content_editor_editablebox"
:editor="contentEditor.tiptapEditor"
/>
<loading-indicator v-if="isLoading" />
+ <div
+ v-if="quickActionsDocsPath"
+ class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary"
+ >
+ <div class="gl-w-full gl-line-height-32 gl-font-sm">
+ <gl-sprintf :message="$options.i18n.quickActionsText">
+ <template #keyboard="{ content }">
+ <kbd>{{ content }}</kbd>
+ </template>
+ <template #quickActionsDocsLink="{ content }">
+ <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
</div>
- <formatting-toolbar
- v-if="useBottomToolbar"
- ref="toolbar"
- class="gl-border-t"
- @enableMarkdownEditor="$emit('enableMarkdownEditor')"
- />
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index 87eff2451ec..59d71169dd3 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -34,7 +34,6 @@ export default {
<editor-state-observer @alert="displayAlert">
<gl-alert
v-if="message"
- class="gl-mb-6"
:variant="variant"
:primary-button-text="actionLabel"
@dismiss="dismissAlert"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index 5dcff1f6295..fa842f23cc3 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -4,7 +4,10 @@ export default {
// We can't use this.contentEditor due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- const { contentEditor } = this.$options.propsData;
+
+ // @vue-compat does not care to normalize propsData fields
+ const contentEditor =
+ this.$options.propsData.contentEditor || this.$options.propsData['content-editor'];
return {
contentEditor,
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 36ca3b8cfb6..fac259cf6a1 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,115 +1,128 @@
<script>
-import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
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: {
- EditorModeDropdown,
ToolbarButton,
ToolbarTextStyleDropdown,
- ToolbarLinkButton,
ToolbarTableButton,
- ToolbarImageButton,
+ ToolbarAttachmentButton,
ToolbarMoreDropdown,
+ EditorModeSwitcher,
},
methods: {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
- handleEditorModeChanged(mode) {
- if (mode === 'markdown') {
- this.$emit('enableMarkdownEditor');
- }
+ handleEditorModeChanged() {
+ this.$emit('enableMarkdownEditor');
},
},
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3">
- <toolbar-text-style-dropdown
- data-testid="text-styles"
- class="gl-mr-3"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- class="gl-mx-2"
- editor-command="toggleBold"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- class="gl-mx-2"
- editor-command="toggleItalic"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- class="gl-mx-2"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- class="gl-mx-2"
- 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-mx-2 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-mx-2 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-mx-2 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" />
-
- <editor-mode-dropdown class="gl-ml-auto" value="richText" @input="handleEditorModeChanged" />
+ <div class="gl-mx-2 gl-mt-2">
+ <div
+ class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-bg-gray-50 gl-px-2 gl-rounded-base gl-justify-content-space-between"
+ data-testid="formatting-toolbar"
+ >
+ <div class="gl-py-2 gl-display-flex gl-flex-wrap">
+ <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="strike"
+ content-type="strike"
+ icon-name="strikethrough"
+ editor-command="toggleStrike"
+ :label="__('Strikethrough')"
+ @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 class="content-editor-switcher gl-display-flex gl-align-items-center gl-ml-auto">
+ <editor-mode-switcher size="small" value="richText" @input="handleEditorModeChanged" />
+ </div>
+ </div>
</div>
</template>
<style>
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..1e13c17bc38
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Link from '../extensions/link';
+
+export default {
+ i18n: {
+ inputLabel: __('Attach a file or image'),
+ },
+ 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) {
+ for (const file of e.target.files) {
+ this.tiptapEditor.chain().focus().uploadAttachment({ file }).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
+ :aria-label="$options.i18n.inputLabel"
+ :title="$options.i18n.inputLabel"
+ category="tertiary"
+ icon="paperclip"
+ size="small"
+ lazy
+ @click="openFileUpload"
+ />
+ <input
+ ref="fileSelector"
+ type="file"
+ multiple
+ name="content_editor_image"
+ class="gl-display-none"
+ :aria-label="$options.i18n.inputLabel"
+ data-qa-selector="file_upload_field"
+ @change="onFileSelect"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
index cef026c5bc6..a62f66d8557 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -47,7 +47,7 @@ export default {
size: {
type: String,
required: false,
- default: 'medium',
+ default: 'small',
},
},
data() {
@@ -78,10 +78,11 @@ export default {
:variant="variant"
:category="category"
:size="size"
- :class="{ active: isActive }"
+ :class="{ 'gl-bg-gray-100!': isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
+ class="gl-mr-3"
@click="execute"
/>
</editor-state-observer>
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_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index ca17443081c..99ba8c51948 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -9,7 +9,7 @@ export default {
GlDisclosureDropdown,
GlTooltip,
},
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'contentEditor'],
data() {
return {
toggleId: uniqueId('dropdown-toggle-btn-'),
@@ -53,6 +53,14 @@ export default {
text: __('PlantUML diagram'),
action: () => this.insert('diagram', { language: 'plantuml' }),
},
+ ...(this.contentEditor.drawioEnabled
+ ? [
+ {
+ text: __('Create or edit diagram'),
+ action: () => this.execute('createOrEditDiagram', 'drawioDiagram'),
+ },
+ ]
+ : []),
{
text: __('Table of contents'),
action: () => this.execute('insertTableOfContents', 'tableOfContents'),
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/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 9c1d1faca48..bd30bdcea0c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -76,6 +76,8 @@ export default {
:disabled="!activeItem"
:data-qa-text-style="activeItemLabel"
data-qa-selector="text_style_dropdown"
+ size="small"
+ toggle-class="btn-default-tertiary"
@select="execute"
/>
</editor-state-observer>
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..4a3dfe3656c 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -80,8 +80,9 @@ 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"
+ dir="auto"
>
<div
v-if="node.attrs.showPreview"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue
index aff15ac3e53..e09f2fd1456 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/details.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue
@@ -28,6 +28,6 @@ export default {
:class="{ 'is-open': open }"
@click="open = !open"
></div>
- <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" />
+ <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" dir="auto" />
</node-view-wrapper>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
index 8b7b02605f7..b96b7400d85 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
@@ -21,6 +21,7 @@ export default {
data-testid="footnote-label"
contenteditable="false"
class="gl-display-inline-flex gl-mr-2"
+ dir="auto"
>{{ node.attrs.label }}:</span
>
<node-view-content />
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/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index 6456540a0dd..5624bae34c2 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
import { __ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
@@ -110,6 +110,7 @@ export default {
<node-view-wrapper
class="gl-relative gl-padding-5 gl-min-w-10"
:as="cellType"
+ dir="auto"
@click="hideDropdown"
>
<span
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
index edd75d232e8..9f0709ca83a 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
@@ -10,11 +10,11 @@ export default {
};
</script>
<template>
- <li>
+ <li dir="auto">
<a v-if="heading.text" href="#" @click.prevent>
{{ heading.text }}
</a>
- <ul v-if="heading.subHeadings.length">
+ <ul v-if="heading.subHeadings.length" dir="auto">
<table-of-contents-heading
v-for="(child, index) in heading.subHeadings"
:key="index"
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 14862727811..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';
@@ -47,6 +47,7 @@ export const KEYDOWN_EVENT = 'keydown';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
+export const PARSE_HTML_PRIORITY_HIGH = 75;
export const PARSE_HTML_PRIORITY_HIGHEST = 100;
export const EXTENSION_PRIORITY_LOWER = 75;
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 9634730f637..e7a6af30266 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -1,7 +1,21 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { handleFileEvent } from '../services/upload_helpers';
+const processFiles = ({ files, uploadsPath, renderMarkdown, eventHub, editor }) => {
+ if (!files.length) {
+ return false;
+ }
+
+ let handled = true;
+
+ for (const file of files) {
+ handled = handled && handleFileEvent({ editor, file, uploadsPath, renderMarkdown, eventHub });
+ }
+
+ return handled;
+};
+
export default Extension.create({
name: 'attachment',
@@ -36,25 +50,17 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.clipboardData.files,
editor,
- file: event.clipboardData.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
handleDrop: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.dataTransfer.files,
editor,
- file: event.dataTransfer.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index 9b424ac8367..f5ffc990061 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -4,6 +4,15 @@ import { getParents } from '~/lib/utils/dom_utils';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default Blockquote.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
index 8d0faf7a9fe..dfd9cac4c66 100644
--- a/app/assets/javascripts/content_editor/extensions/bullet_list.js
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -2,6 +2,15 @@ import { BulletList } from '@tiptap/extension-bullet-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default BulletList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 1d85bfcc965..8917417e55e 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,6 +1,6 @@
import { lowlight } from 'lowlight/lib/core';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import { textblockTypeInputRule } from '@tiptap/core';
+import { mergeAttributes, textblockTypeInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import languageLoader from '../services/code_block_language_loader';
import CodeBlockWrapper from '../components/wrappers/code_block.vue';
@@ -13,6 +13,16 @@ export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export default CodeBlockLowlight.extend({
isolating: true,
exitOnArrowDown: false,
+
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
language: {
@@ -61,7 +71,7 @@ export default CodeBlockLowlight.extend({
return [
'pre',
{
- ...HTMLAttributes,
+ ...mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
},
['code', {}, 0],
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
index deb5029a1f0..c49b541bbaf 100644
--- a/app/assets/javascripts/content_editor/extensions/color_chip.js
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -1,6 +1,6 @@
import { Node } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
-import { Decoration, DecorationSet } from 'prosemirror-view';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { isValidColorExpression } from '~/lib/utils/color_utils';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js
index 957fdede27b..06fecf8196d 100644
--- a/app/assets/javascripts/content_editor/extensions/description_item.js
+++ b/app/assets/javascripts/content_editor/extensions/description_item.js
@@ -5,6 +5,14 @@ export default Node.create({
content: 'block+',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
isTerm: {
@@ -21,7 +29,9 @@ export default Node.create({
renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) {
return [
'li',
- mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }),
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: isTerm ? 'dl-term' : 'dl-description',
+ }),
0,
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js
index 8f5b145cfa3..72c191757d0 100644
--- a/app/assets/javascripts/content_editor/extensions/description_list.js
+++ b/app/assets/javascripts/content_editor/extensions/description_list.js
@@ -6,12 +6,24 @@ export default Node.create({
group: 'block list',
content: 'descriptionItem+',
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'dl' }];
},
renderHTML({ HTMLAttributes }) {
- return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0];
+ return [
+ 'ul',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: 'dl-content' }),
+ 0,
+ ];
},
addInputRules() {
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
index fb6c49d91aa..fbe58664a10 100644
--- a/app/assets/javascripts/content_editor/extensions/details_content.js
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default Node.create({
@@ -6,6 +6,14 @@ export default Node.create({
content: 'block+',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [
{ tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
@@ -13,7 +21,7 @@ export default Node.create({
},
renderHTML({ HTMLAttributes }) {
- return ['li', HTMLAttributes, 0];
+ return ['li', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addKeyboardShortcuts() {
diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
new file mode 100644
index 00000000000..8c3012ecf59
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
@@ -0,0 +1,41 @@
+import { create } from '~/drawio/content_editor_facade';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import createAssetResolver from '../services/asset_resolver';
+import Image from './image';
+
+export default Image.extend({
+ name: 'drawioDiagram',
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ uploadsPath: null,
+ renderMarkdown: null,
+ };
+ },
+ parseHTML() {
+ return [
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: 'a.no-attachment-icon[data-canonical-src$="drawio.svg"]',
+ },
+ {
+ tag: 'img[src]',
+ },
+ ];
+ },
+ addCommands() {
+ return {
+ createOrEditDiagram: () => () => {
+ launchDrawioEditor({
+ editorFacade: create({
+ tiptapEditor: this.editor,
+ drawioNodeName: this.name,
+ uploadsPath: this.options.uploadsPath,
+ assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }),
+ }),
+ });
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
index e940614083e..e48100c15a7 100644
--- a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
+++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { KEYDOWN_EVENT } from '../constants';
/**
diff --git a/app/assets/javascripts/content_editor/extensions/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js
index b2076894412..e82c2cb9813 100644
--- a/app/assets/javascripts/content_editor/extensions/figure.js
+++ b/app/assets/javascripts/content_editor/extensions/figure.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
export default Node.create({
name: 'figure',
@@ -6,11 +6,19 @@ export default Node.create({
group: 'block',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'figure' }];
},
renderHTML({ HTMLAttributes }) {
- return ['figure', HTMLAttributes, 0];
+ return ['figure', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js
index ffd1b474f03..c08db6d9f4d 100644
--- a/app/assets/javascripts/content_editor/extensions/figure_caption.js
+++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
export default Node.create({
name: 'figureCaption',
@@ -6,11 +6,19 @@ export default Node.create({
group: 'block',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'figcaption' }];
},
renderHTML({ HTMLAttributes }) {
- return ['figcaption', HTMLAttributes, 0];
+ return ['figcaption', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
index ae5b8edc7af..270f0977a7a 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_reference.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
@@ -17,6 +17,14 @@ export default Node.create({
selectable: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
identifier: {
@@ -35,6 +43,6 @@ export default Node.create({
},
renderHTML({ HTMLAttributes: { label, ...HTMLAttributes } }) {
- return ['sup', mergeAttributes(HTMLAttributes), label];
+ return ['sup', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), label];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
index 2b2c4177e1d..2fdad39635e 100644
--- a/app/assets/javascripts/content_editor/extensions/footnotes_section.js
+++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
@@ -9,6 +9,14 @@ export default Node.create({
isolating: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [
{ tag: 'section.footnotes', skip: true },
@@ -17,6 +25,12 @@ export default Node.create({
},
renderHTML({ HTMLAttributes }) {
- return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0];
+ return [
+ 'ol',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: 'footnotes gl-font-sm',
+ }),
+ 0,
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js
index 41903162ba5..8927d7b9c1e 100644
--- a/app/assets/javascripts/content_editor/extensions/heading.js
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -2,6 +2,15 @@ import { Heading } from '@tiptap/extension-heading';
import { textblockTypeInputRule } from '@tiptap/core';
export default Heading.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addInputRules() {
return this.options.levels.map((level) => {
return textblockTypeInputRule({
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index fc4c108b773..58c16297886 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,5 +1,5 @@
import { Image } from '@tiptap/extension-image';
-import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import { PARSE_HTML_PRIORITY_HIGH } from '../constants';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
@@ -77,7 +77,7 @@ export default Image.extend({
parseHTML() {
return [
{
- priority: PARSE_HTML_PRIORITY_HIGHEST,
+ priority: PARSE_HTML_PRIORITY_HIGH,
tag: 'a.no-attachment-icon',
},
{
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index e985e561fda..b83814103d1 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?.(),
@@ -27,7 +29,6 @@ export default Link.extend({
addInputRules() {
const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
- const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
return [
markInputRule({
@@ -35,16 +36,15 @@ export default Link.extend({
type: this.type,
getAttributes: extractHrefFromMarkdownLink,
}),
- markInputRule({
- find: urlSyntaxRegExp,
- type: this.type,
- getAttributes: extractHrefFromMatch,
- }),
];
},
addAttributes() {
return {
...this.parent?.(),
+ uploading: {
+ default: false,
+ renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}),
+ },
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
@@ -64,4 +64,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/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js
index 72454b0905d..7b9ca4e14c8 100644
--- a/app/assets/javascripts/content_editor/extensions/list_item.js
+++ b/app/assets/javascripts/content_editor/extensions/list_item.js
@@ -1 +1,12 @@
-export { ListItem as default } from '@tiptap/extension-list-item';
+import ListItem from '@tiptap/extension-list-item';
+
+export default ListItem.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js
deleted file mode 100644
index 2324e9b132d..00000000000
--- a/app/assets/javascripts/content_editor/extensions/loading.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Node } from '@tiptap/core';
-
-export default Node.create({
- name: 'loading',
- inline: true,
- group: 'inline',
-
- addAttributes() {
- return {
- label: {
- default: null,
- },
- };
- },
-
- renderHTML({ node }) {
- return [
- 'span',
- { class: 'gl-display-inline-flex gl-align-items-center' },
- ['span', { class: 'gl-spinner gl-mx-2' }],
- ['span', { class: 'gl-link' }, node.attrs.label],
- ];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
index 57d5bd6ebf8..d0b760010de 100644
--- a/app/assets/javascripts/content_editor/extensions/ordered_list.js
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -2,6 +2,15 @@ import { OrderedList } from '@tiptap/extension-ordered-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default OrderedList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
index 33bf1c94003..c63b64fd784 100644
--- a/app/assets/javascripts/content_editor/extensions/paragraph.js
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -1 +1,12 @@
-export { Paragraph as default } from '@tiptap/extension-paragraph';
+import Paragraph from '@tiptap/extension-paragraph';
+
+export default Paragraph.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 848c4c12a9a..82fa5ce6c1d 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -1,7 +1,7 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { __ } from '~/locale';
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
@@ -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..47766c966a1 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
},
];
},
@@ -63,7 +61,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
- ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
+ [
+ 'a',
+ { href: node.attrs.src, class: 'with-attachment-icon' },
+ node.attrs.title || node.attrs.alt || '',
+ ],
];
},
});
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/selection.js b/app/assets/javascripts/content_editor/extensions/selection.js
new file mode 100644
index 00000000000..2e0bb29e5a1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/selection.js
@@ -0,0 +1,26 @@
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+
+export default Extension.create({
+ name: 'selection',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('selection'),
+ props: {
+ decorations(state) {
+ if (state.selection.empty) return null;
+
+ return DecorationSet.create(state.doc, [
+ Decoration.inline(state.selection.from, state.selection.to, {
+ class: 'content-editor-selection',
+ }),
+ ]);
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 54d69d83188..f02d0c2ca52 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -31,6 +31,8 @@ import TableOfContents from './table_of_contents';
import Video from './video';
export default Extension.create({
+ name: 'sourcemap',
+
addGlobalAttributes() {
return [
{
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index a9628c78add..e72b5c7365c 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -2,7 +2,7 @@ import { Node } from '@tiptap/core';
import { VueRenderer } from '@tiptap/vue-2';
import tippy from 'tippy.js';
import Suggestion from '@tiptap/suggestion';
-import { PluginKey } from 'prosemirror-state';
+import { PluginKey } from '@tiptap/pm/state';
import { isFunction, uniqueId, memoize } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, getAllEmoji } from '~/emoji';
@@ -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/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index d7456ab4094..de8170eff93 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1,6 +1,6 @@
import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
-import { VARIANT_WARNING } from '~/flash';
+import { VARIANT_WARNING } from '~/alert';
import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers';
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 6efef3f8198..849fd55034e 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -4,8 +4,9 @@ import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
addOptions() {
return {
+ ...this.parent?.(),
nested: true,
- HTMLAttributes: {},
+ HTMLAttributes: { dir: 'auto' },
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js
index 72c6e020102..01e5bddb97a 100644
--- a/app/assets/javascripts/content_editor/extensions/task_list.js
+++ b/app/assets/javascripts/content_editor/extensions/task_list.js
@@ -4,6 +4,13 @@ import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default TaskList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: { dir: 'auto' },
+ };
+ },
+
addAttributes() {
return {
numeric: {
@@ -33,6 +40,10 @@ export default TaskList.extend({
},
renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
- return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
+ return [
+ numeric ? 'ol' : 'ul',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'taskList' }),
+ 0,
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 514ab9699bc..a988e1df2a6 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,12 +1,14 @@
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
+ constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
this._assetResolver = assetResolver;
this._pristineDoc = null;
+
+ this.drawioEnabled = drawioEnabled;
}
get tiptapEditor() {
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 61c6be574d0..3958f77745a 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -16,6 +16,7 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
+import DrawioDiagram from '../extensions/drawio_diagram';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
@@ -39,7 +40,6 @@ 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';
@@ -47,6 +47,7 @@ import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import ReferenceLabel from '../extensions/reference_label';
import ReferenceDefinition from '../extensions/reference_definition';
+import Selection from '../extensions/selection';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -74,7 +75,7 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
extensions: [...extensions],
editorProps: {
attributes: {
- class: 'gl-outline-0!',
+ class: 'gl-shadow-none!',
},
},
...options,
@@ -86,6 +87,9 @@ export const createContentEditor = ({
extensions = [],
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
+ drawioEnabled = false,
+ enableAutocomplete,
+ autocompleteDataSources = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -131,7 +135,6 @@ export const createContentEditor = ({
ExternalKeydownHandler.configure({ eventHub }),
Link,
ListItem,
- Loading,
MathInline,
OrderedList,
Paragraph,
@@ -139,10 +142,10 @@ export const createContentEditor = ({
Reference,
ReferenceLabel,
ReferenceDefinition,
+ Selection,
Sourcemap,
Strike,
Subscript,
- Suggestions,
Superscript,
TableCell,
TableHeader,
@@ -157,6 +160,10 @@ 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);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ serializerConfig });
@@ -173,5 +180,6 @@ export const createContentEditor = ({
eventHub,
deserializer,
assetResolver,
+ drawioEnabled,
});
};
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index 796dc06ad93..91f8aaf6324 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -1,4 +1,4 @@
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
import { replaceCommentsWith } from '~/lib/utils/dom_utils';
export default ({ render }) => {
@@ -18,10 +18,7 @@ export default ({ render }) => {
*/
return {
deserialize: async ({ schema, markdown }) => {
- const html = await render(markdown);
-
- if (!html) return {};
-
+ const html = markdown ? await render(markdown) : '<p></p>';
const parser = new DOMParser();
const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html');
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 28a50adca6b..c8972515c25 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -19,7 +19,7 @@
* visit-parents documentation: https://github.com/syntax-tree/unist-util-visit-parents
*/
-import { Mark } from 'prosemirror-model';
+import { Mark } from '@tiptap/pm/model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
import { isFunction, isString, noop, mapValues } from 'lodash';
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 4e29f85004b..3b77064e903 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -12,6 +12,7 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
+import DrawioDiagram from '../extensions/drawio_diagram';
import Comment from '../extensions/comment';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
@@ -134,6 +135,10 @@ const defaultSerializerConfig = {
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Comment.name]: renderComment,
[Diagram.name]: preserveUnchanged(renderCodeBlock),
+ [DrawioDiagram.name]: preserveUnchanged({
+ render: renderImage,
+ inline: true,
+ }),
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -222,6 +227,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..478b87372d7 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -309,12 +309,13 @@ export function renderHardBreak(state, node, parent, index) {
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs;
+ const realSrc = canonicalSrc || src || '';
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return;
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
- const sourceExpression = isReference
- ? `[${canonicalSrc}]`
- : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
+ const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`;
const sizeAttributes = [];
if (width) {
@@ -335,9 +336,9 @@ export function renderPlayable(state, node) {
}
export function renderComment(state, node) {
- state.text('<!--');
- state.text(node.textContent);
- state.text('-->');
+ state.write('<!--');
+ state.write(node.textContent);
+ state.write('-->');
state.closeBlock(node);
}
@@ -604,7 +605,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 +621,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 +639,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/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 09f0738b51b..f5785397bf0 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,20 +1,66 @@
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import { extractFilename, readFileAsDataURL } from './utils';
+import { __, sprintf } from '~/locale';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+import TappablePromise from '~/lib/utils/tappable_promise';
+import { ALERT_EVENT } from '../constants';
+
+const chain = (editor) => editor.chain().setMeta('preventAutolink', true);
+
+const findUploadedFilePosition = (editor, filename) => {
+ let position;
+
+ editor.view.state.doc.descendants((descendant, pos) => {
+ if (descendant.attrs.uploading === filename) {
+ position = pos;
+ return false;
+ }
+
+ for (const mark of descendant.marks) {
+ if (mark.type.name === 'link' && mark.attrs.uploading === filename) {
+ position = pos + 1;
+ return false;
+ }
+ }
+
+ return true;
+ });
+
+ return position;
+};
export const acceptedMimes = {
- image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
- audio: [
- 'audio/basic',
- 'audio/mid',
- 'audio/mpeg',
- 'audio/x-aiff',
- 'audio/ogg',
- 'audio/vorbis',
- 'audio/vnd.wav',
- ],
- video: ['video/mp4', 'video/quicktime'],
+ drawioDiagram: {
+ mimes: ['image/svg+xml'],
+ ext: 'drawio.svg',
+ },
+ image: {
+ mimes: [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/svg+xml',
+ 'image/webp',
+ 'image/tiff',
+ 'image/bmp',
+ 'image/vnd.microsoft.icon',
+ 'image/x-icon',
+ ],
+ },
+ audio: {
+ mimes: [
+ 'audio/basic',
+ 'audio/mid',
+ 'audio/mpeg',
+ 'audio/x-aiff',
+ 'audio/ogg',
+ 'audio/vorbis',
+ 'audio/vnd.wav',
+ ],
+ },
+ video: {
+ mimes: ['video/mp4', 'video/quicktime'],
+ },
};
const extractAttachmentLinkUrl = (html) => {
@@ -27,6 +73,18 @@ const extractAttachmentLinkUrl = (html) => {
return { src, canonicalSrc };
};
+class UploadError extends Error {}
+
+const notifyUploadError = (eventHub, error) => {
+ eventHub.$emit(ALERT_EVENT, {
+ message:
+ error instanceof UploadError
+ ? error.message
+ : __('An error occurred while uploading the file. Please try again.'),
+ variant: VARIANT_DANGER,
+ });
+};
+
/**
* Uploads a file with a post request to the URL indicated
* in the uploadsPath parameter. The expected response of the
@@ -44,93 +102,155 @@ const extractAttachmentLinkUrl = (html) => {
* and returns a rendered version in HTML format.
* @param {File} params.file The file to upload
*
- * @returns Returns an object with two properties:
+ * @returns {TappablePromise} Returns an object with two properties:
*
* canonicalSrc: The URL as defined in the Markdown
* src: The absolute URL that points to the resource in the server
*/
-export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
- const formData = new FormData();
- formData.append('file', file, file.name);
+export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => {
+ return new TappablePromise(async (tap) => {
+ const maxFileSize = (gon.max_file_size || 10).toFixed(0);
+ const fileSize = bytesToMiB(file.size);
+ if (fileSize > maxFileSize) {
+ throw new UploadError(
+ sprintf(__('File is too big (%{fileSize}MiB). Max filesize: %{maxFileSize}MiB.'), {
+ fileSize: fileSize.toFixed(2),
+ maxFileSize,
+ }),
+ );
+ }
- const { data } = await axios.post(uploadsPath, formData);
- const { markdown } = data.link;
- const rendered = await renderMarkdown(markdown);
+ const formData = new FormData();
+ formData.append('file', file, file.name);
- return extractAttachmentLinkUrl(rendered);
+ const { data } = await axios.post(uploadsPath, formData, {
+ onUploadProgress: (e) => tap(e.loaded / e.total),
+ });
+ const { markdown } = data.link;
+ const rendered = await renderMarkdown(markdown);
+
+ return extractAttachmentLinkUrl(rendered);
+ });
};
-const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
- const encodedSrc = await readFileAsDataURL(file);
- const { view } = editor;
+const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
+ await Promise.resolve();
+
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type,
+ attrs: { uploading: file.name, src: objectUrl, alt: file.name },
+ };
+ let selectionIncrement = 0;
+
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ selectionIncrement = 1;
+ }
- editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
+ chain(editor)
+ .insertContentAt(position, content)
+ .setNodeSelection(position + selectionIncrement)
+ .run();
- const { state } = view;
- const position = state.selection.from - 1;
- const { tr } = state;
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- editor.commands.setNodeSelection(position);
+ editor.view.dispatch(
+ editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, {
+ uploading: false,
+ src: objectUrl,
+ alt: file.name,
+ canonicalSrc,
+ }),
+ );
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+ chain(editor).setNodeSelection(position).run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
- view.dispatch(
- tr.setNodeMarkup(position, undefined, {
- uploading: false,
- src: encodedSrc,
- alt: extractFilename(src),
- canonicalSrc,
- }),
- );
+ chain(editor)
+ .deleteRange({ from: position, to: position + 1 })
+ .run();
- editor.commands.setNodeSelection(position);
- } catch (e) {
- editor.commands.deleteRange({ from: position, to: position + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ notifyUploadError(eventHub, e);
});
- }
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
await Promise.resolve();
- const { view } = editor;
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type: 'text',
+ text: file.name,
+ marks: [{ type: 'link', attrs: { href: objectUrl, uploading: file.name } }],
+ };
- const text = extractFilename(file.name);
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ }
- const { state } = view;
- const { from } = state.selection;
+ chain(editor).insertContentAt(position, content).extendMarkRange('link').run();
- editor.commands.insertContent({
- type: 'loading',
- attrs: { label: text },
- });
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ src, canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
-
- editor.commands.insertContentAt(
- { from, to: from + 1 },
- { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
- );
- } catch (e) {
- editor.commands.deleteRange({ from, to: from + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .updateAttributes('link', { href: src, canonicalSrc, uploading: false })
+ .run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
+
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .unsetLink()
+ .deleteSelection()
+ .run();
+
+ notifyUploadError(eventHub, e);
});
- }
};
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
- for (const [type, mimes] of Object.entries(acceptedMimes)) {
- if (mimes.includes(file?.type)) {
- uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
+ for (const [type, { mimes, ext }] of Object.entries(acceptedMimes)) {
+ if (mimes.includes(file?.type) && (!ext || file?.name.endsWith(ext))) {
+ uploadMedia({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index e352fa8a9db..1c128b4aa19 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -4,26 +4,4 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
-/**
- * Extracts filename from a URL
- *
- * @example
- * > extractFilename('https://gitlab.com/images/logo-full.png')
- * < 'logo-full'
- *
- * @param {string} src The URL to extract filename from
- * @returns {string}
- */
-export const extractFilename = (src) => {
- return src.replace(/^.*\/|\.[^.]+?$/g, '');
-};
-
-export const readFileAsDataURL = (file) => {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
- reader.readAsDataURL(file);
- });
-};
-
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index f2ff77daf02..ea444b5c146 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -38,9 +38,6 @@ export default class ContextualSidebar {
this.toggleCollapsedSidebar(value, true);
}
});
- this.$page.on('transitionstart transitionend', () => {
- $(document).trigger('content.resize');
- });
$(window).on(
'resize',
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 17e6cc87ff8..ce99d5da3cc 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -9,7 +9,6 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g;
@@ -26,7 +25,6 @@ export default {
GlAreaChart,
GlButton,
GlLoadingIcon,
- ResizableChartContainer,
RefSelector,
},
props: {
@@ -249,18 +247,15 @@ export default {
<div data-testid="contributors-charts">
<h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- class="gl-mb-5"
- :width="width"
- :data="masterChartData"
- :option="masterChartOptions"
- :height="masterChartHeight"
- @created="onMasterChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <gl-area-chart
+ class="gl-mb-5"
+ responsive
+ width="auto"
+ :data="masterChartData"
+ :option="masterChartOptions"
+ :height="masterChartHeight"
+ @created="onMasterChartCreated"
+ />
<div class="row">
<div
@@ -272,17 +267,14 @@ export default {
<p class="gl-mb-3">
{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
</p>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- :width="width"
- :data="contributor.dates"
- :option="individualChartOptions"
- :height="individualChartHeight"
- @created="onIndividualChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <gl-area-chart
+ responsive
+ width="auto"
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js
index 3a6f4191031..5a8349aa1fd 100644
--- a/app/assets/javascripts/contributors/stores/actions.js
+++ b/app/assets/javascripts/contributors/stores/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import service from '../services/contributors_service';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index c67b544eacd..b13b0ede9f0 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -48,15 +48,13 @@ export default {
addDeployFreezeButton() {
return {
text: this.isEditing ? __('Save deploy freeze') : __('Add deploy freeze'),
- attributes: [
- { variant: 'confirm' },
- {
- disabled:
- !isValidCron(this.freezeStartCron) ||
- !isValidCron(this.freezeEndCron) ||
- !this.selectedTimezone,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ disabled:
+ !isValidCron(this.freezeStartCron) ||
+ !isValidCron(this.freezeEndCron) ||
+ !this.selectedTimezone,
+ },
};
},
invalidFreezeStartCron() {
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
index 76a4eaaff3f..77d3037ff57 100644
--- a/app/assets/javascripts/deploy_freeze/store/actions.js
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index db5e9a954cf..5fc15578827 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import eventHub from '../eventhub';
diff --git a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
index 1932435c42a..25551d7b5cb 100644
--- a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
+++ b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
@@ -22,11 +22,11 @@ export default {
title: __('Do you want to remove this deploy key?'),
actionPrimary: {
text: __('Remove deploy key'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
actionSecondary: {
text: __('Cancel'),
- attributes: [{ category: 'tertiary' }],
+ attributes: { category: 'tertiary' },
},
static: true,
modalId: 'confirm-remove-deploy-key',
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/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
index 57fae608efa..c49ab1ac43c 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -66,6 +66,11 @@ export default {
},
},
methods: {
+ getWritePackageRegistryHelpText() {
+ return this.tokenType === 'group'
+ ? this.$options.translations.groupWritePackageRegistryHelp
+ : this.$options.translations.projectWritePackageRegistryHelp;
+ },
defaultData() {
return {
expiresAt: null,
@@ -110,7 +115,7 @@ export default {
id: 'deploy_token_write_package_registry',
isShown: this.$props.packagesRegistryEnabled,
value: false,
- helpText: this.$options.translations.writePackageRegistryHelp,
+ helpText: this.getWritePackageRegistryHelpText(),
scopeName: 'write_package_registry',
},
],
diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
index 3767e9e6170..410864a83a2 100644
--- a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
+++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
@@ -32,9 +32,12 @@ const translations = {
readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'),
writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'),
readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'),
- writePackageRegistryHelp: s__(
+ groupWritePackageRegistryHelp: s__(
'DeployTokens|Allows read and write access to the package registry.',
),
+ projectWritePackageRegistryHelp: s__(
+ 'DeployTokens|Allows read, write and delete access to the package registry.',
+ ),
createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'),
};
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 7503df9194b..a46a8d4affa 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -16,7 +16,7 @@ import $ from 'jquery';
import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
@@ -53,9 +53,9 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes {
- static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM) {
+ static initialize(notes_url, last_fetched_at, view, enableGFM) {
if (!this.instance) {
- this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM);
+ this.instance = new Notes(notes_url, last_fetched_at, view, enableGFM);
}
}
@@ -63,7 +63,7 @@ export default class Notes {
return this.instance;
}
- constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) {
+ constructor(notes_url, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
this.visibilityChange = this.visibilityChange.bind(this);
@@ -85,9 +85,9 @@ export default class Notes {
this.postComment = this.postComment.bind(this);
this.clearAlertWrapper = this.clearAlert.bind(this);
this.onHashChange = this.onHashChange.bind(this);
+ this.note_ids = [];
this.notes_url = notes_url;
- this.note_ids = note_ids;
this.enableGFM = enableGFM;
// Used to keep track of updated notes while people are editing things
this.updatedNotesTrackingMap = {};
@@ -449,8 +449,6 @@ export default class Notes {
return;
}
- this.note_ids.push(noteEntity.id);
-
if ($notesList.length) {
$notesList.find('.system-note.being-posted').remove();
}
@@ -497,7 +495,6 @@ export default class Notes {
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
- this.note_ids.push(noteEntity.id);
const form =
$form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
@@ -602,7 +599,10 @@ export default class Notes {
// remove validation errors
form.find('.js-errors').remove();
// reset text and preview
- form.find('.js-md-write-button').click();
+ if (form.find('.js-md-preview-button').val() === 'edit') {
+ form.find('.js-md-preview-button').click();
+ }
+
form.find('.js-note-text').val('').trigger('input');
form.find('.js-note-text').each(function reset() {
this.$autosave.reset();
@@ -745,7 +745,7 @@ export default class Notes {
$noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
- renderGFM($noteEntityEl.get(0));
+ renderGFM(Notes.getNodeToRender($noteEntityEl));
// Find the note's `li` element by ID and replace it with the updated HTML
const $note_li = $(`.note-row-${noteEntity.id}`);
@@ -942,6 +942,7 @@ export default class Notes {
const replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink.closest('.discussion-reply-holder').hide().after(form);
+
// show the form
return this.setupDiscussionNoteForm(replyLink, form);
}
@@ -1182,9 +1183,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) {
@@ -1203,6 +1206,7 @@ export default class Notes {
commentTypeComponent.disabled = false;
}
} else {
+ savebtn.disable();
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
@@ -1241,7 +1245,10 @@ export default class Notes {
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm.find('.js-note-text').focus().val(originalContent);
- $editForm.find('.js-md-write-button').trigger('click');
+ // reset preview
+ if ($editForm.find('.js-md-preview-button').val() === 'edit') {
+ $editForm.find('.js-md-preview-button').click();
+ }
$editForm.find('.referenced-users').hide();
}
@@ -1396,8 +1403,29 @@ export default class Notes {
/**
* Check if note does not exist on page
*/
- static isNewNote(noteEntity, noteIds) {
- return $.inArray(noteEntity.id, noteIds) === -1;
+ static isNewNote(noteEntity, note_ids) {
+ if (note_ids.length === 0) {
+ note_ids = Notes.getNotesIds();
+ }
+ const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1;
+ if (isNewEntry) {
+ note_ids.push(noteEntity.id);
+ }
+ return isNewEntry;
+ }
+
+ /**
+ * Get notes ids
+ */
+ 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));
}
/**
@@ -1422,7 +1450,7 @@ export default class Notes {
const $note = $(noteHtml);
$note.addClass('fade-in-full');
- renderGFM($note.get(0));
+ renderGFM(Notes.getNodeToRender($note));
$notesList.append($note);
return $note;
}
@@ -1431,11 +1459,20 @@ export default class Notes {
const $updatedNote = $(noteHtml);
$updatedNote.addClass('fade-in');
- renderGFM($updatedNote.get(0));
+ renderGFM(Notes.getNodeToRender($updatedNote));
$note.replaceWith($updatedNote);
return $updatedNote;
}
+ static getNodeToRender($note) {
+ for (const $item of $note) {
+ if (Notes.isNodeTypeElement($item)) {
+ return $item;
+ }
+ }
+ return '';
+ }
+
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
@@ -1829,4 +1866,11 @@ export default class Notes {
return $closeBtn.text($closeBtn.data('originalText'));
}
+
+ /**
+ * Function to check if node is element to avoid comment and text
+ */
+ static isNodeTypeElement($node) {
+ return $node.nodeType === Node.ELEMENT_NODE;
+ }
}
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 3091c6703b4..680a101b118 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -1,16 +1,19 @@
<script>
import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { ApolloMutation } from 'vue-apollo';
-import { createAlert } from '~/flash';
-import { s__ } from '~/locale';
+import * as Sentry from '@sentry/browser';
+import { createAlert } from '~/alert';
+import { __, s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import { updateGlobalTodoCount } from '~/sidebar/utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import { isLoggedIn } from '~/lib/utils/common_utils';
-import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+import { TYPENAME_NOTE, TYPENAME_DISCUSSION } from '~/graphql_shared/constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES, DELETE_NOTE_ERROR_MSG } from '../../constants';
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
+import destroyNoteMutation from '../../graphql/mutations/destroy_note.mutation.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../../mixins/all_versions';
@@ -23,8 +26,14 @@ import DesignReplyForm from './design_reply_form.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
export default {
+ i18n: {
+ deleteNote: {
+ confirmationText: __('Are you sure you want to delete this comment?'),
+ primaryModalBtnText: __('Delete comment'),
+ errorText: DELETE_NOTE_ERROR_MSG,
+ },
+ },
components: {
- ApolloMutation,
DesignNote,
DesignNotePin,
DesignNoteSignedOut,
@@ -97,9 +106,9 @@ export default {
},
data() {
return {
- discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
+ noteToDelete: null,
isResolving: false,
shouldChangeResolvedStatus: false,
areRepliesCollapsed: this.discussion.resolved,
@@ -107,10 +116,9 @@ export default {
};
},
computed: {
- mutationPayload() {
+ mutationVariables() {
return {
noteableId: this.noteableId,
- body: this.discussionComment,
discussionId: this.discussion.id,
};
},
@@ -156,19 +164,21 @@ export default {
onDone({ data: { createNote } }) {
if (hasErrors(createNote)) {
createAlert({ message: ADD_DISCUSSION_COMMENT_ERROR });
+ } else {
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * Hide the form once the create note mutation is completed.
+ */
+ this.hideForm();
}
- this.discussionComment = '';
- this.hideForm();
+
if (this.shouldChangeResolvedStatus) {
this.toggleResolvedStatus();
}
},
- onCreateNoteError(err) {
- this.$emit('create-note-error', err);
- },
hideForm() {
this.isFormRendered = false;
- this.discussionComment = '';
},
showForm() {
this.$emit('open-form', this.discussion.id);
@@ -219,13 +229,65 @@ export default {
const { source } = activeDiscussion;
return ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(source) && this.isDiscussionActive;
},
+ async showDeleteNoteConfirmationModal(note) {
+ const isLast = note?.discussion?.notes?.nodes.length === 1;
+ this.noteToDelete = { ...note, isLast };
+
+ const confirmed = await confirmAction(this.$options.i18n.deleteNote.confirmationText, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: this.$options.i18n.deleteNote.primaryModalBtnText,
+ });
+
+ if (confirmed) {
+ await this.deleteNote();
+ }
+ },
+ async deleteNote() {
+ const { id, discussion, isLast } = this.noteToDelete;
+ try {
+ await this.$apollo.mutate({
+ mutation: destroyNoteMutation,
+ variables: {
+ input: {
+ id,
+ },
+ },
+ update: (cache, { data }) => {
+ const { errors } = data.destroyNote;
+
+ if (errors?.length) {
+ this.$emit('delete-note-error', errors[0]);
+ }
+
+ const objectToIdentify = isLast
+ ? { __typename: TYPENAME_DISCUSSION, id: discussion?.id }
+ : { __typename: TYPENAME_NOTE, id };
+
+ cache.modify({
+ id: cache.identify(objectToIdentify),
+ fields: (_, { DELETE }) => DELETE,
+ });
+ },
+ optimisticResponse: {
+ destroyNote: {
+ note: null,
+ errors: [],
+ __typename: 'DestroyNotePayload',
+ },
+ },
+ });
+ } catch (error) {
+ this.$emit('delete-note-error', this.$options.i18n.deleteNote.errorText);
+ Sentry.captureException(error);
+ }
+ },
},
createNoteMutation,
};
</script>
<template>
- <div class="design-discussion-wrapper">
+ <div class="design-discussion-wrapper" @click="$emit('update-active-discussion')">
<design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" />
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
@@ -235,9 +297,10 @@ export default {
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :is-discussion="true"
:noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
- @error="$emit('update-note-error', $event)"
+ @delete-note="showDeleteNoteConfirmationModal($event)"
>
<template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion>
<gl-button
@@ -279,8 +342,9 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:noteable-id="noteableId"
+ :is-discussion="false"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
- @error="$emit('update-note-error', $event)"
+ @delete-note="showDeleteNoteConfirmationModal($event)"
/>
<li
v-show="isReplyPlaceholderVisible"
@@ -296,33 +360,24 @@ export default {
:placeholder-text="__('Reply…')"
@focus="showForm"
/>
- <apollo-mutation
+ <design-reply-form
v-else
- #default="{ mutate, loading }"
- :mutation="$options.createNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- @done="onDone"
- @error="onCreateNoteError"
+ :design-note-mutation="$options.createNoteMutation"
+ :mutation-variables="mutationVariables"
+ :markdown-preview-path="markdownPreviewPath"
+ :noteable-id="noteableId"
+ :discussion-id="discussion.id"
+ :is-discussion="false"
+ @note-submit-complete="onDone"
+ @cancel-form="hideForm"
>
- <design-reply-form
- v-model="discussionComment"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :noteable-id="noteableId"
- :discussion-id="discussion.id"
- @submit-form="mutate"
- @cancel-form="hideForm"
- >
- <template v-if="discussion.resolvable" #resolve-checkbox>
- <label data-testid="resolve-checkbox">
- <input v-model="shouldChangeResolvedStatus" type="checkbox" />
- {{ resolveCheckboxText }}
- </label>
- </template>
- </design-reply-form>
- </apollo-mutation>
+ <template v-if="discussion.resolvable" #resolve-checkbox>
+ <label data-testid="resolve-checkbox">
+ <input v-model="shouldChangeResolvedStatus" type="checkbox" />
+ {{ resolveCheckboxText }}
+ </label>
+ </template>
+ </design-reply-form>
</template>
</li>
</ul>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index af4bf7eb14d..b92a2392948 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,6 +1,13 @@
<script>
-import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { ApolloMutation } from 'vue-apollo';
+import {
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
@@ -14,13 +21,16 @@ import DesignReplyForm from './design_reply_form.vue';
export default {
i18n: {
editCommentLabel: __('Edit comment'),
+ moreActionsLabel: __('More actions'),
+ deleteCommentText: __('Delete comment'),
},
components: {
- ApolloMutation,
DesignReplyForm,
GlAvatar,
GlAvatarLink,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlLink,
TimeAgoTooltip,
TimelineEntryItem,
@@ -39,6 +49,11 @@ export default {
required: false,
default: '',
},
+ isDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
noteableId: {
type: String,
required: true,
@@ -46,8 +61,8 @@ export default {
},
data() {
return {
- noteText: this.note.body,
isEditing: false,
+ isError: true,
};
},
computed: {
@@ -63,20 +78,24 @@ export default {
isNoteLinked() {
return extractDesignNoteId(this.$route.hash) === this.noteAnchorId;
},
- mutationPayload() {
+ mutationVariables() {
return {
id: this.note.id,
- body: this.noteText,
};
},
isEditButtonVisible() {
- return !this.isEditing && this.note.userPermissions.adminNote;
+ return !this.isEditing && this.adminPermissions;
+ },
+ isMoreActionsButtonVisible() {
+ return !this.isEditing && this.adminPermissions;
+ },
+ adminPermissions() {
+ return this.note.userPermissions.adminNote;
},
},
methods: {
hideForm() {
this.isEditing = false;
- this.noteText = this.note.body;
},
onDone({ data }) {
this.hideForm();
@@ -132,6 +151,30 @@ export default {
size="small"
@click="isEditing = true"
/>
+ <gl-dropdown
+ v-if="isMoreActionsButtonVisible"
+ v-gl-tooltip.hover
+ class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ icon="ellipsis_v"
+ category="tertiary"
+ data-qa-selector="design_discussion_actions_ellipsis_dropdown"
+ data-testid="more-actions-dropdown"
+ :text="$options.i18n.moreActionsLabel"
+ text-sr-only
+ :title="$options.i18n.moreActionsLabel"
+ :aria-label="$options.i18n.moreActionsLabel"
+ no-caret
+ left
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-qa-selector="delete_design_note_button"
+ data-testid="delete-note-button"
+ @click="$emit('delete-note', note)"
+ >
+ {{ $options.i18n.deleteCommentText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
<template v-if="!isEditing">
@@ -143,26 +186,18 @@ export default {
></div>
<slot name="resolved-status"></slot>
</template>
- <apollo-mutation
+ <design-reply-form
v-else
- #default="{ mutate, loading }"
- :mutation="$options.updateNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- @error="$emit('error', $event)"
- @done="onDone"
- >
- <design-reply-form
- v-model="noteText"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :is-new-comment="false"
- :noteable-id="noteableId"
- class="gl-mt-5"
- @submit-form="mutate"
- @cancel-form="hideForm"
- />
- </apollo-mutation>
+ :markdown-preview-path="markdownPreviewPath"
+ :design-note-mutation="$options.updateNoteMutation"
+ :mutation-variables="mutationVariables"
+ :value="note.body"
+ :is-new-comment="false"
+ :is-discussion="isDiscussion"
+ :noteable-id="noteableId"
+ class="gl-mt-5"
+ @note-submit-complete="onDone"
+ @cancel-form="hideForm"
+ />
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 830f16b50ee..4fd90130284 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlAlert } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import Autosave from '~/autosave';
@@ -7,6 +7,12 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_NOTE_ERROR,
+} from '../../utils/error_messages';
export default {
name: 'DesignReplyForm',
@@ -23,22 +29,29 @@ export default {
components: {
MarkdownField,
GlButton,
+ GlAlert,
},
props: {
+ designNoteMutation: {
+ type: Object,
+ required: true,
+ },
+ mutationVariables: {
+ type: Object,
+ required: false,
+ default: null,
+ },
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- value: {
- type: String,
- required: true,
- },
- isSaving: {
+ isNewComment: {
type: Boolean,
- required: true,
+ required: false,
+ default: true,
},
- isNewComment: {
+ isDiscussion: {
type: Boolean,
required: false,
default: true,
@@ -52,16 +65,24 @@ export default {
required: false,
default: 'new',
},
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- formText: this.value,
+ noteText: this.value,
+ saving: false,
+ noteUpdateDirty: false,
isLoggedIn: isLoggedIn(),
+ errorMessage: '',
};
},
computed: {
hasValue() {
- return this.value.trim().length > 0;
+ return this.noteText.length > 0;
},
buttonText() {
return this.isNewComment
@@ -75,18 +96,69 @@ export default {
mounted() {
this.focusInput();
},
+ beforeDestroy() {
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ * Reply form closes and component destroys
+ * only when comment submission was successful,
+ * so we're safe to clear autosave data here conditionally.
+ */
+ this.$nextTick(() => {
+ if (!this.noteUpdateDirty) {
+ this.autosaveDiscussion.reset();
+ }
+ });
+ },
methods: {
+ handleInput() {
+ /**
+ * While the form is saving using ctrl+enter
+ * Do not mark it as dirty.
+ *
+ */
+ if (!this.saving) {
+ this.noteUpdateDirty = true;
+ }
+ },
submitForm() {
if (this.hasValue) {
- this.$emit('submit-form');
- this.autosaveDiscussion.reset();
+ this.saving = true;
+ this.$apollo
+ .mutate({
+ mutation: this.designNoteMutation,
+ variables: {
+ input: {
+ ...this.mutationVariables,
+ body: this.noteText,
+ },
+ },
+ update: () => {
+ this.noteUpdateDirty = false;
+ },
+ })
+ .then((response) => {
+ this.$emit('note-submit-complete', response);
+ })
+ .catch(() => {
+ this.errorMessage = this.getErrorMessage();
+ })
+ .finally(() => {
+ this.saving = false;
+ });
}
},
+ getErrorMessage() {
+ if (this.isNewComment) {
+ return this.isDiscussion ? ADD_IMAGE_DIFF_NOTE_ERROR : ADD_DISCUSSION_COMMENT_ERROR;
+ }
+ return this.isDiscussion ? UPDATE_IMAGE_DIFF_NOTE_ERROR : UPDATE_NOTE_ERROR;
+ },
cancelComment() {
- if (this.hasValue && this.formText !== this.value) {
+ if (this.hasValue && this.noteUpdateDirty) {
this.confirmCancelCommentModal();
} else {
this.$emit('cancel-form');
+ this.noteUpdateDirty = false;
}
},
async confirmCancelCommentModal() {
@@ -130,24 +202,29 @@ export default {
<template>
<form class="new-note common-note-form" @submit.prevent>
+ <div v-if="errorMessage" class="gl-pb-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:enable-autocomplete="true"
- :textarea-value="value"
+ :textarea-value="noteText"
:markdown-docs-path="$options.markdownDocsPath"
class="bordered-box"
>
<template #textarea>
<textarea
ref="textarea"
- :value="value"
+ v-model.trim="noteText"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
data-qa-selector="note_textarea"
:aria-label="__('Description')"
:placeholder="__('Write a comment…')"
- @input="$emit('input', $event.target.value)"
+ @input="handleInput"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keyup.esc.stop="cancelComment"
@@ -159,7 +236,8 @@ export default {
<div class="note-form-actions gl-display-flex">
<gl-button
ref="submitButton"
- :disabled="!hasValue || isSaving"
+ :disabled="!hasValue"
+ :loading="saving"
class="gl-mr-3 gl-w-auto!"
category="primary"
variant="confirm"
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 24cc93f5eaf..c34d5cea0c2 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -57,7 +57,6 @@ export default {
},
data() {
return {
- isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -87,13 +86,13 @@ export default {
unresolvedDiscussions() {
return this.discussions.filter((discussion) => !discussion.resolved);
},
- },
- watch: {
- resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
- this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
- },
- isResolvedDiscussionsExpanded() {
- this.$emit('toggleResolvedComments');
+ isResolvedDiscussionsExpanded: {
+ get() {
+ return this.resolvedDiscussionsExpanded;
+ },
+ set(isExpanded) {
+ this.$emit('toggleResolvedComments', isExpanded);
+ },
},
},
mounted() {
@@ -129,7 +128,7 @@ export default {
</script>
<template>
- <div class="image-notes gl-pt-0" @click="handleSidebarClick">
+ <div class="image-notes gl-pt-0" @click.self="handleSidebarClick">
<div
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
@@ -179,8 +178,9 @@ export default {
data-testid="unresolved-discussion"
@create-note-error="$emit('onDesignDiscussionError', $event)"
@update-note-error="$emit('updateNoteError', $event)"
+ @delete-note-error="$emit('deleteNoteError', $event)"
@resolve-discussion-error="$emit('resolveDiscussionError', $event)"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @update-active-discussion="updateActiveDiscussion(discussion.notes[0].id)"
@open-form="updateDiscussionWithOpenForm"
/>
<gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
@@ -202,9 +202,10 @@ export default {
:discussion-with-open-form="discussionWithOpenForm"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
- @updateNoteError="$emit('updateNoteError', $event)"
+ @update-note-error="$emit('updateNoteError', $event)"
+ @delete-note-error="$emit('deleteNoteError', $event)"
@open-form="updateDiscussionWithOpenForm"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @update-active-discussion="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-accordion-item>
</gl-accordion>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index f52486f0629..9c1bcf5bf90 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -133,7 +133,11 @@ export default {
<div
class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
>
- <div v-if="icon.name" data-testid="design-event" class="gl-top-5 gl-right-5 gl-absolute">
+ <div
+ v-if="icon.name"
+ data-testid="design-event"
+ class="gl-absolute gl-top-3 gl-right-3 gl-mr-1"
+ >
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon
:name="icon.name"
@@ -165,11 +169,11 @@ export default {
/>
</gl-intersection-observer>
</div>
- <div class="card-footer gl-display-flex gl-w-full">
+ <div class="card-footer gl-display-flex gl-w-full gl-bg-white gl-py-3 gl-px-4">
<div class="gl-display-flex gl-flex-direction-column str-truncated-100">
<span
v-gl-tooltip
- class="gl-font-weight-bold str-truncated-100"
+ class="gl-font-weight-semibold str-truncated-100"
data-qa-selector="design_file_name"
:data-testid="`design-img-filename-${id}`"
:title="filename"
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index 0bbbc795fff..e08eb853ad7 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -1,12 +1,11 @@
<script>
-/* global Mousetrap */
-import 'mousetrap';
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import {
keysFor,
ISSUE_PREVIOUS_DESIGN,
ISSUE_NEXT_DESIGN,
} from '~/behaviors/shortcuts/keybindings';
+import { Mousetrap } from '~/lib/mousetrap';
import { s__, sprintf } from '~/locale';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index 6d571365306..cd76b6c1885 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -60,7 +60,8 @@ export default {
},
image: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
isLoading: {
type: Boolean,
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index afe621ac3c5..6720245b5f1 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -1,3 +1,4 @@
+import { __ } from '~/locale';
// WARNING: replace this with something
// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
export const VALID_DESIGN_FILE_MIMETYPE = {
@@ -14,3 +15,7 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+export const DELETE_NOTE_ERROR_MSG = __(
+ 'Something went wrong when deleting a comment. Please try again.',
+);
diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql
new file mode 100644
index 00000000000..58fb05e2140
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql
@@ -0,0 +1,8 @@
+mutation destroyNote($input: DestroyNoteInput!) {
+ destroyNote(input: $input) {
+ errors
+ note {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index b856ac6c627..12bb4b830f8 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -8,7 +8,14 @@ import createRouter from './router';
export default () => {
const el = document.querySelector('.js-design-management');
- const { issueIid, projectPath, issuePath, registerPath, signInPath } = el.dataset;
+ const {
+ issueIid,
+ projectPath,
+ issuePath,
+ registerPath,
+ signInPath,
+ newCommentTemplatePath,
+ } = el.dataset;
const router = createRouter(issuePath);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -32,6 +39,7 @@ export default () => {
issueIid,
registerPath,
signInPath,
+ newCommentTemplatePath,
},
mounted() {
performanceMarkAndMeasure({
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index b783ec43cd1..b182e68260a 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -1,6 +1,6 @@
import { propertyOf } from 'lodash';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { s__ } from '~/locale';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
import allVersionsMixin from './all_versions';
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index f448e2f9e3d..eeb36e59b89 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -1,10 +1,9 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
-import Mousetrap from 'mousetrap';
-import { ApolloMutation } from 'vue-apollo';
+import { Mousetrap } from '~/lib/mousetrap';
import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { fetchPolicies } from '~/lib/graphql';
import { updateGlobalTodoCount } from '~/sidebar/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -34,13 +33,11 @@ import {
getPageLayoutElement,
} from '../../utils/design_management_utils';
import {
- ADD_DISCUSSION_COMMENT_ERROR,
- ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
- UPDATE_NOTE_ERROR,
TOGGLE_TODO_ERROR,
+ DELETE_NOTE_ERROR,
designDeletionError,
} from '../../utils/error_messages';
import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking';
@@ -50,7 +47,6 @@ const DEFAULT_MAX_SCALE = 2;
export default {
components: {
- ApolloMutation,
DesignReplyForm,
DesignPresentation,
DesignScaler,
@@ -90,7 +86,6 @@ export default {
data() {
return {
design: {},
- comment: '',
annotationCoordinates: null,
errorMessage: '',
scale: DEFAULT_SCALE,
@@ -129,9 +124,6 @@ export default {
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
- isSubmitButtonDisabled() {
- return this.comment.trim().length === 0;
- },
designVariables() {
return {
fullPath: this.projectPath,
@@ -140,11 +132,10 @@ export default {
atVersion: this.designsVersion,
};
},
- mutationPayload() {
+ mutationVariables() {
const { x, y, width, height } = this.annotationCoordinates;
return {
noteableId: this.design.id,
- body: this.comment,
position: {
headSha: this.design.diffRefs.headSha,
baseSha: this.design.diffRefs.baseSha,
@@ -196,13 +187,23 @@ export default {
Mousetrap.unbind(keysFor(ISSUE_CLOSE_DESIGN));
},
methods: {
- addImageDiffNoteToStore(store, { data: { createImageDiffNote } }) {
+ addImageDiffNoteToStore({ data }) {
+ const { createImageDiffNote } = data;
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * The getClient method is not documented. In future,
+ * need to check for any alternative.
+ */
+ const { cache } = this.$apollo.getClient();
+
updateStoreAfterAddImageDiffNote(
- store,
+ cache,
createImageDiffNote,
getDesignQuery,
this.designVariables,
);
+ this.closeCommentForm(data);
},
updateImageDiffNoteInStore(store, { data: { repositionImageDiffNote } }) {
return updateStoreAfterRepositionImageDiffNote(
@@ -249,7 +250,7 @@ export default {
},
onQueryError(message) {
// because we redirect user to /designs (the issue page),
- // we want to create these flashes on the issue page
+ // we want to create these alerts on the issue page
createAlert({ message });
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
},
@@ -257,14 +258,8 @@ export default {
this.errorMessage = message;
if (e) throw e;
},
- onCreateImageDiffNoteError(e) {
- this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
- },
- onUpdateNoteError(e) {
- this.onError(UPDATE_NOTE_ERROR, e);
- },
- onDesignDiscussionError(e) {
- this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
+ onDeleteNoteError(e) {
+ this.onError(DELETE_NOTE_ERROR, e);
},
onUpdateImageDiffNoteError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
@@ -285,7 +280,6 @@ export default {
}
},
closeCommentForm(data) {
- this.comment = '';
this.annotationCoordinates = null;
if (data?.data && !isNull(this.prevCurrentUserTodos)) {
@@ -324,8 +318,8 @@ export default {
const diffNoteGid = noteId ? toDiffNoteGid(noteId) : undefined;
return this.updateActiveDiscussion(diffNoteGid, ACTIVE_DISCUSSION_SOURCE_TYPES.url);
},
- toggleResolvedComments() {
- this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
+ toggleResolvedComments(newValue) {
+ this.resolvedDiscussionsExpanded = newValue;
},
setMaxScale(event) {
this.maxScale = 1 / event;
@@ -338,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"
@@ -394,35 +388,24 @@ export default {
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:markdown-preview-path="markdownPreviewPath"
:is-loading="isLoading"
- @onDesignDiscussionError="onDesignDiscussionError"
- @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
- @updateNoteError="onUpdateNoteError"
+ @deleteNoteError="onDeleteNoteError"
@resolveDiscussionError="onResolveDiscussionError"
@toggleResolvedComments="toggleResolvedComments"
@todoError="onTodoError"
>
<template #reply-form>
- <apollo-mutation
+ <design-reply-form
v-if="isAnnotating"
- #default="{ mutate, loading }"
- :mutation="$options.createImageDiffNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- :update="addImageDiffNoteToStore"
- @done="closeCommentForm"
- @error="onCreateImageDiffNoteError"
- >
- <design-reply-form
- ref="newDiscussionForm"
- v-model="comment"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :noteable-id="design.id"
- @submit-form="mutate"
- @cancel-form="closeCommentForm"
- /> </apollo-mutation
- ></template>
+ ref="newDiscussionForm"
+ :design-note-mutation="$options.createImageDiffNoteMutation"
+ :mutation-variables="mutationVariables"
+ :markdown-preview-path="markdownPreviewPath"
+ :noteable-id="design.id"
+ :is-discussion="true"
+ @note-submit-complete="addImageDiffNoteToStore"
+ @cancel-form="closeCommentForm"
+ />
+ </template>
</design-sidebar>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index ab003fb2879..dcc65c957fe 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;
@@ -141,6 +141,12 @@ export default {
}
return 'col-12';
},
+ designContentWrapperClass() {
+ if (this.hasDesigns) {
+ return 'gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5';
+ }
+ return null;
+ },
},
mounted() {
if (this.$route.path === '/designs') {
@@ -364,7 +370,7 @@ export default {
</gl-alert>
<header
v-if="showToolbar"
- class="gl-display-flex gl-my-0 gl-text-gray-900"
+ class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!"
data-testid="design-toolbar-wrapper"
>
<div
@@ -375,6 +381,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"
@@ -417,7 +424,7 @@ export default {
</div>
</div>
</header>
- <div>
+ <div :class="designContentWrapperClass">
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
@@ -482,14 +489,18 @@ export default {
v-if="canSelectDesign(design.filename)"
:checked="isDesignSelected(design.filename)"
type="checkbox"
- class="design-checkbox"
+ class="design-checkbox gl-absolute gl-top-4 gl-left-6 gl-ml-2"
data-qa-selector="design_checkbox"
:data-qa-design="design.filename"
@change="changeSelectedDesigns(design.filename)"
/>
</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 cfec5828c85..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 '~/flash';
+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/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 42f752efc9e..1ed054abe22 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -13,7 +13,13 @@ export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
-export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+export const UPDATE_NOTE_ERROR = s__(
+ 'DesignManagement|Could not update comment. Please try again.',
+);
+
+export const DELETE_NOTE_ERROR = s__(
+ 'DesignManagement|Could not delete comment. Please try again.',
+);
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 65816495432..e3cd43ac22f 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { merge } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import FilesCommentButton from './files_comment_button';
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 35d1a564178..02307150e2f 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,8 +1,8 @@
<script>
import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import api from '~/api';
import {
keysFor,
@@ -11,16 +11,18 @@ import {
MR_COMMITS_NEXT_COMMIT,
MR_COMMITS_PREVIOUS_COMMIT,
} from '~/behaviors/shortcuts/keybindings';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
+import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
@@ -39,12 +41,15 @@ import {
TRACKING_WHITESPACE_HIDE,
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
+ EVT_MR_PREPARED,
} from '../constants';
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';
@@ -53,15 +58,15 @@ import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
import TreeList from './tree_list.vue';
import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
+import PreRenderer from './pre_renderer.vue';
export default {
name: 'DiffsApp',
components: {
- DynamicScroller: () =>
- import('vendor/vue-virtual-scroller').then(({ DynamicScroller }) => DynamicScroller),
- DynamicScrollerItem: () =>
- import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem),
- PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer),
+ FindingsDrawer,
+ DynamicScroller,
+ DynamicScrollerItem,
+ PreRenderer,
VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
@@ -75,6 +80,8 @@ export default {
GlPagination,
GlSprintf,
GlAlert,
+ GenerateTestFileDrawer: () =>
+ import('ee_component/ai/components/generate_test_file_drawer.vue'),
},
mixins: [glFeatureFlagsMixin()],
alerts: {
@@ -95,6 +102,10 @@ export default {
type: String,
required: true,
},
+ endpointDiffForPath: {
+ type: String,
+ required: true,
+ },
endpointCoverage: {
type: String,
required: false,
@@ -195,6 +206,7 @@ export default {
numTotalFiles: 'realSize',
numVisibleFiles: 'size',
}),
+ ...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
'showTreeList',
'isLoading',
@@ -218,6 +230,7 @@ export default {
'showWhitespace',
'targetBranchName',
'branchName',
+ 'generateTestFilePath',
]),
...mapGetters('diffs', [
'whichCollapsedTypes',
@@ -226,8 +239,10 @@ export default {
'isVirtualScrollingEnabled',
'isBatchLoading',
'isBatchLoadingError',
+ 'flatBlobsList',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
+ ...mapGetters('findingsDrawer', ['activeDrawer']),
diffs() {
if (!this.viewDiffsFileByFile) {
return this.diffFiles;
@@ -241,7 +256,10 @@ export default {
return this.currentUser.can_fork === true && this.currentUser.can_create_merge_request;
},
renderDiffFiles() {
- return this.diffFiles.length > 0;
+ return this.flatBlobsList.length > 0;
+ },
+ diffsIncomplete() {
+ return this.flatBlobsList.length !== this.diffFiles.length;
},
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
@@ -253,7 +271,7 @@ export default {
return this.startVersion === null && this.latestDiff;
},
showFileByFileNavigation() {
- return this.diffFiles.length > 1 && this.viewDiffsFileByFile;
+ return this.flatBlobsList.length > 1 && this.viewDiffsFileByFile;
},
currentFileNumber() {
return this.currentDiffIndex + 1;
@@ -264,9 +282,9 @@ export default {
return currentDiffIndex >= 1 ? currentDiffIndex : null;
},
nextFileNumber() {
- const { currentFileNumber, diffFiles } = this;
+ const { currentFileNumber, flatBlobsList } = this;
- return currentFileNumber < diffFiles.length ? currentFileNumber + 1 : null;
+ return currentFileNumber < flatBlobsList.length ? currentFileNumber + 1 : null;
},
visibleWarning() {
let visible = false;
@@ -284,6 +302,9 @@ export default {
fileReviews() {
return reviewStatuses(this.diffFiles, this.mrReviews);
},
+ resourceId() {
+ return convertToGraphQLId('MergeRequest', this.getNoteableData.id);
+ },
},
watch: {
commit(newCommit, oldCommit) {
@@ -303,6 +324,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
@@ -321,6 +347,7 @@ export default {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
+ endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointUpdateUser: this.endpointUpdateUser,
projectPath: this.projectPath,
@@ -331,8 +358,6 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
- this.interfaceWithDOM();
-
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -385,7 +410,7 @@ export default {
this.subscribeToEvents();
this.unwatchDiscussions = this.$watch(
- () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
+ () => `${this.flatBlobsList.length}:${this.$store.state.notes.discussions.length}`,
() => {
this.setDiscussions();
@@ -420,41 +445,51 @@ export default {
'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
+ 'fetchFileByFile',
'fetchCoverageFiles',
'fetchCodequality',
+ 'rereadNoteHash',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
'cacheTreeListWidth',
- 'scrollToFile',
+ 'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
'setFileByFile',
'disableVirtualScroller',
+ 'setGenerateTestFilePath',
]),
+ ...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);
+ }
+ diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData);
},
unsubscribeFromEvents() {
+ diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData);
+ 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]);
@@ -462,42 +497,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();
@@ -572,8 +617,11 @@ export default {
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
- if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
- this.scrollToFile({ path: this.diffFiles[targetIndex].file_path });
+ if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) {
+ this.goToFile({
+ path: this.flatBlobsList[targetIndex].path,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
}
},
setTreeDisplay() {
@@ -582,7 +630,7 @@ export default {
if (storedTreeShow !== null) {
showTreeList = parseBoolean(storedTreeShow);
- } else if (!bp.isDesktop() || (!this.isBatchLoading && this.diffFiles.length <= 1)) {
+ } else if (!bp.isDesktop() || (!this.isBatchLoading && this.flatBlobsList.length <= 1)) {
showTreeList = false;
}
@@ -634,6 +682,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" />
@@ -753,7 +806,7 @@ export default {
/>
<gl-sprintf :message="__('File %{current} of %{total}')">
<template #current>{{ currentFileNumber }}</template>
- <template #total>{{ diffFiles.length }}</template>
+ <template #total>{{ flatBlobsList.length }}</template>
</gl-sprintf>
</div>
<gl-loading-icon v-else-if="retrievingBatches" size="lg" />
@@ -765,5 +818,11 @@ export default {
</div>
</div>
</div>
+ <generate-test-file-drawer
+ v-if="getNoteableData.id"
+ :resource-id="resourceId"
+ :file-path="generateTestFilePath"
+ @close="() => setGenerateTestFilePath('')"
+ />
</div>
</template>
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 11aa856619b..f3f05e3d9d9 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -1,34 +1,26 @@
<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>
<template>
<div
data-testid="diff-codequality"
- class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-pl-5 gl-pt-4 gl-pb-4"
+ class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4"
>
<h4
data-testid="diff-codequality-findings-heading"
@@ -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_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 8fcbc4b5cce..53a55aac1ec 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, sprintf } from '~/locale';
import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants';
import * as utils from '../store/utils';
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 564f776edd2..4c2cb83ffb3 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -5,7 +5,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { IdState } from 'vendor/vue-virtual-scroller';
import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -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();
}
@@ -246,11 +250,11 @@ export default {
async postRender() {
const eventsForThisFile = [];
- if (this.isFirstFile) {
+ if (this.isFirstFile || this.viewDiffsFileByFile) {
eventsForThisFile.push(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
}
- if (this.isLastFile) {
+ if (this.isLastFile || this.viewDiffsFileByFile) {
eventsForThisFile.push(EVT_PERF_MARK_DIFF_FILES_END);
}
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 16f45c3ad6a..792be3de1e5 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -50,6 +50,12 @@ export default {
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
+ fileModeTooltip: __('File permissions'),
+ },
+ inject: {
+ showGenerateTestFileButton: {
+ default: false,
+ },
},
props: {
discussionPath: {
@@ -201,6 +207,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': {
@@ -223,6 +232,7 @@ export default {
'setCurrentFileHash',
'reviewFile',
'setFileCollapsedByUser',
+ 'setGenerateTestFilePath',
]),
handleToggleFile() {
this.$emit('toggleFile');
@@ -287,12 +297,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 +354,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 +382,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"
@@ -400,14 +418,6 @@ export default {
<gl-icon name="ellipsis_v" class="mr-0" />
<span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span>
</template>
- <gl-dropdown-item
- v-if="diffFile.replaced_view_path"
- ref="replacedFileButton"
- :href="diffFile.replaced_view_path"
- target="_blank"
- >
- {{ viewReplacedFileButtonText }}
- </gl-dropdown-item>
<gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank">
{{ viewFileButtonText }}
</gl-dropdown-item>
@@ -431,6 +441,20 @@ export default {
>
{{ __('Open in Web IDE') }}
</gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showGenerateTestFileButton"
+ @click="setGenerateTestFilePath(diffFile.new_path)"
+ >
+ {{ __('Generate test with AI') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="diffFile.replaced_view_path"
+ ref="replacedFileButton"
+ :href="diffFile.replaced_view_path"
+ target="_blank"
+ >
+ {{ viewReplacedFileButtonText }}
+ </gl-dropdown-item>
</template>
<template v-if="!isCollapsed">
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_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index dfca6d61270..1f5c9b4f2f5 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -6,6 +6,7 @@ https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57842
* */
import { memoize } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
+import { compatFunctionalMixin } from '~/lib/utils/vue3compat/compat_functional_mixin';
import {
PARALLEL_DIFF_VIEW_TYPE,
CONFLICT_MARKER_THEIR,
@@ -24,6 +25,10 @@ import * as utils from './diff_row_utils';
export default {
DiffGutterAvatars,
CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'),
+
+ // Temporary mixin for migration from Vue.js 2 to @vue/compat
+ mixins: [compatFunctionalMixin],
+
props: {
fileHash: {
type: String,
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/file_row_stats.vue b/app/assets/javascripts/diffs/components/file_row_stats.vue
index 784f74e498f..f99f363a6be 100644
--- a/app/assets/javascripts/diffs/components/file_row_stats.vue
+++ b/app/assets/javascripts/diffs/components/file_row_stats.vue
@@ -10,7 +10,7 @@ export default {
</script>
<template>
- <span v-once class="file-row-stats">
+ <span class="file-row-stats">
<span class="cgreen"> +{{ file.addedLines }} </span>
<span class="cred"> -{{ file.removedLines }} </span>
</span>
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 8bb1872567c..4f1875e9175 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -2,9 +2,11 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import micromatch from 'micromatch';
+import { debounce } from 'lodash';
import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
-import FileTree from '~/vue_shared/components/file_tree.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { RecycleScroller } from 'vendor/vue-virtual-scroller';
import DiffFileRow from './diff_file_row.vue';
const MODIFIER_KEY = getModifierKey();
@@ -15,8 +17,10 @@ export default {
},
components: {
GlIcon,
- FileTree,
+ DiffFileRow,
+ RecycleScroller,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
hideFileStats: {
type: Boolean,
@@ -26,6 +30,10 @@ export default {
data() {
return {
search: '',
+ scrollerHeight: 0,
+ resizeObserver: null,
+ rowHeight: 0,
+ debouncedHeightCalc: null,
};
},
computed: {
@@ -61,12 +69,51 @@ export default {
return acc;
}, []);
},
+ // Flatten the treeList so there's no nested trees
+ // This gives us fixed row height for virtual scrolling
+ // in: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'c' }]
+ // out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }]
+ flatFilteredTreeList() {
+ const result = [];
+ const createFlatten = (level) => (item) => {
+ result.push({
+ ...item,
+ level: item.isHeader ? 0 : level,
+ key: item.key || item.path,
+ });
+ if (item.opened || item.isHeader) {
+ item.tree.forEach(createFlatten(level + 1));
+ }
+ };
+
+ this.filteredTreeList.forEach(createFlatten(0));
+
+ return result;
+ },
+ },
+ created() {
+ this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50);
+ },
+ mounted() {
+ const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height');
+ this.rowHeight = parseInt(heightProp, 10);
+ this.calculateScrollerHeight();
+ this.resizeObserver = new ResizeObserver(() => {
+ this.debouncedHeightCalc();
+ });
+ this.resizeObserver.observe(this.$refs.scrollRoot);
+ },
+ beforeDestroy() {
+ this.resizeObserver.disconnect();
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
+ ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
clearSearch() {
this.search = '';
},
+ calculateScrollerHeight() {
+ this.scrollerHeight = this.$refs.scrollRoot.clientHeight;
+ },
},
searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
MODIFIER_KEY,
@@ -76,10 +123,14 @@ export default {
</script>
<template>
- <div class="tree-list-holder d-flex flex-column" data-qa-selector="file_tree_container">
- <div class="gl-mb-3 position-relative tree-list-search d-flex">
+ <div
+ ref="wrapper"
+ class="tree-list-holder d-flex flex-column"
+ data-qa-selector="file_tree_container"
+ >
+ <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"
@@ -89,6 +140,7 @@ export default {
name="diff-tree-search"
class="form-control"
data-testid="diff-tree-search"
+ data-qa-selector="diff_tree_search"
/>
<button
v-show="search"
@@ -101,24 +153,37 @@ export default {
</button>
</div>
</div>
- <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList || search }" class="tree-list-scroll">
- <template v-if="filteredTreeList.length">
- <file-tree
- v-for="file in filteredTreeList"
- :key="file.key"
- :file="file"
- :level="0"
- :viewed-files="viewedDiffFileIds"
- :hide-file-stats="hideFileStats"
- :file-row-component="$options.DiffFileRow"
- :current-diff-file-id="currentDiffFileId"
- :style="{ '--level': 0 }"
- :class="{ 'tree-list-parent': file.tree.length }"
- class="gl-relative"
- @toggleTreeOpen="toggleTreeOpen"
- @clickFile="(path) => scrollToFile({ path })"
- />
- </template>
+ <div
+ ref="scrollRoot"
+ :class="{ 'tree-list-blobs': !renderTreeList || search }"
+ class="gl-flex-grow-1 mr-tree-list"
+ >
+ <recycle-scroller
+ v-if="flatFilteredTreeList.length"
+ :style="{ height: `${scrollerHeight}px` }"
+ :items="flatFilteredTreeList"
+ :item-size="rowHeight"
+ :buffer="100"
+ key-field="key"
+ >
+ <template #default="{ item }">
+ <diff-file-row
+ :file="item"
+ :level="item.level"
+ :viewed-files="viewedDiffFileIds"
+ :hide-file-stats="hideFileStats"
+ :current-diff-file-id="currentDiffFileId"
+ :style="{ '--level': item.level }"
+ :class="{ 'tree-list-parent': item.level > 0 }"
+ class="gl-relative"
+ @toggleTreeOpen="toggleTreeOpen"
+ @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })"
+ />
+ </template>
+ <template #after>
+ <div class="tree-list-gutter"></div>
+ </template>
+ </recycle-scroller>
<p v-else class="prepend-top-20 append-bottom-20 text-center">
{{ s__('MergeRequest|No files found') }}
</p>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 6c0c9c4e1d0..063e36fa7fb 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -6,10 +6,8 @@ export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline';
export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline';
export const CONTEXT_LINE_TYPE = 'context';
export const EMPTY_CELL_TYPE = 'empty-cell';
-export const COMMENT_FORM_TYPE = 'commentForm';
export const DIFF_NOTE_TYPE = 'DiffNote';
export const LEGACY_DIFF_NOTE_TYPE = 'LegacyDiffNote';
-export const NOTE_TYPE = 'Note';
export const NEW_LINE_TYPE = 'new';
export const OLD_LINE_TYPE = 'old';
export const TEXT_DIFF_POSITION_TYPE = 'text';
@@ -17,14 +15,10 @@ export const IMAGE_DIFF_POSITION_TYPE = 'image';
export const LINE_POSITION_LEFT = 'left';
export const LINE_POSITION_RIGHT = 'right';
-export const LINE_SIDE_LEFT = 'left-side';
-export const LINE_SIDE_RIGHT = 'right-side';
export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
export const DIFF_WHITESPACE_COOKIE_NAME = 'diff_whitespace';
export const LINE_HOVER_CLASS_NAME = 'is-over';
-export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold';
-export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded';
export const UNFOLD_COUNT = 20;
export const COUNT_OF_AVATARS_IN_GUTTER = 3;
@@ -46,14 +40,12 @@ export const TREE_HIDE_STATS_WIDTH = 260;
export const OLD_LINE_KEY = 'old_line';
export const NEW_LINE_KEY = 'new_line';
export const TYPE_KEY = 'type';
-export const LEFT_LINE_KEY = 'left';
export const MAX_RENDERING_DIFF_LINES = 500;
export const MAX_RENDERING_BULK_ROWS = 30;
export const MIN_RENDERING_MS = 2;
export const START_RENDERING_INDEX = 200;
export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines';
-export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
@@ -87,6 +79,7 @@ export const RENAMED_DIFF_TRANSITIONS = {
};
// MR Diffs known events
+export const EVT_MR_PREPARED = 'mr:asyncPreparationFinished';
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
@@ -120,3 +113,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 7da5ef54b80..53c27632c4f 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,5 +1,7 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
import notesStore from '~/mr_notes/stores';
@@ -11,20 +13,29 @@ import { getReviewsForMergeRequest } from './utils/file_reviews';
import { getDerivedMergeRequestInformation } from './utils/merge_request';
export default function initDiffsApp(store = notesStore) {
+ const el = document.getElementById('js-diffs-app');
+ const { dataset } = el;
+
+ Vue.use(VueApollo);
+
const vm = new Vue({
- el: '#js-diffs-app',
+ el,
name: 'MergeRequestDiffs',
components: {
DiffsApp,
},
store,
+ apolloProvider,
+ provide: {
+ newCommentTemplatePath: dataset.newCommentTemplatePath,
+ showGenerateTestFileButton: parseBoolean(dataset.showGenerateTestFileButton),
+ },
data() {
- const { dataset } = document.querySelector(this.$options.el);
-
return {
endpoint: dataset.endpoint,
endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '',
+ endpointDiffForPath: dataset.endpointDiffForPath || '',
endpointCoverage: dataset.endpointCoverage || '',
endpointCodequality: dataset.endpointCodequality || '',
endpointUpdateUser: dataset.updateCurrentUserPath,
@@ -86,6 +97,7 @@ export default function initDiffsApp(store = notesStore) {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
+ endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointCodequality: this.endpointCodequality,
endpointUpdateUser: this.endpointUpdateUser,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 9f90de9abde..0668551902a 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -5,7 +5,7 @@ import {
historyPushState,
scrollToElement,
} from '~/lib/utils/common_utils';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
@@ -14,6 +14,9 @@ import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
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,
@@ -46,13 +49,14 @@ import {
TRACKING_CLICK_SINGLE_FILE_SETTING,
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
+ EVT_MR_PREPARED,
} 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';
import { getDerivedMergeRequestInformation } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
-import TreeWorker from '../workers/tree_worker?worker';
import * as types from './mutation_types';
import {
getDiffPositionByLineCode,
@@ -61,6 +65,8 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareLineForRenamedFile,
+ parseUrlHashAsFileHash,
+ isUrlHashNoteLink,
} from './utils';
export const setBaseConfig = ({ commit }, options) => {
@@ -68,6 +74,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -81,6 +88,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -98,6 +106,58 @@ 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 versionPath = state.mergeRequestDiff?.version_path;
+ const treeEntry = id
+ ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id)
+ : getters.flatBlobsList[0];
+
+ eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
+
+ 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',
+ commit_id: getters.commitId,
+ };
+
+ if (versionPath) {
+ const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath });
+
+ urlParams.diff_id = diffId;
+ urlParams.start_sha = startSha;
+ }
+
+ 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;
@@ -199,21 +259,12 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
};
export const fetchDiffFilesMeta = ({ commit, state }) => {
- const worker = new TreeWorker();
const urlParams = {
view: 'inline',
w: state.showWhitespace ? '0' : '1',
};
commit(types.SET_LOADING, true);
- eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
-
- worker.addEventListener('message', ({ data }) => {
- commit(types.SET_TREE_DATA, data);
- eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
-
- worker.terminate();
- });
return axios
.get(mergeUrlParams(urlParams, state.endpointMetadata))
@@ -225,18 +276,28 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
commit(types.SET_DIFF_METADATA, strippedData);
- worker.postMessage(data.diff_files);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
+ const { treeEntries, tree } = generateTreeList(data.diff_files);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
+ commit(types.SET_TREE_DATA, {
+ treeEntries,
+ tree: sortTree(tree),
+ });
return data;
})
.catch((error) => {
- worker.terminate();
-
if (error.response.status === HTTP_STATUS_NOT_FOUND) {
- createAlert({
- message: __('Building your merge request. Wait a few moments, then refresh this page.'),
+ const alert = createAlert({
+ message: __(
+ 'Building your merge request… This page will update when the build is complete.',
+ ),
variant: VARIANT_WARNING,
});
+
+ eventHub.$once(EVT_MR_PREPARED, () => alert.dismiss());
+ } else {
+ throw error;
}
});
};
@@ -512,13 +573,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 +607,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 +872,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,23 +912,48 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
-export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, noteId) => {
+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];
if (!note) return;
const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file?.file_hash;
- if (fileHash && state.diffFiles.some((f) => f.file_hash === fileHash)) {
+ if (fileHash && getters.flatBlobsList.some((f) => f.fileHash === fileHash)) {
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
}
};
-export const navigateToDiffFileIndex = ({ commit, state }, index) => {
- const fileHash = state.diffFiles[index].file_hash;
+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/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 3a85c1a9fe1..10a6a872fe4 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -90,6 +90,12 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = (state) => (fileHash) =>
state.diffFiles.find((file) => file.file_hash === fileHash);
+export function isTreePathLoaded(state) {
+ return (path) => {
+ return Boolean(state.treeEntries[path]?.diffLoaded);
+ };
+}
+
export const flatBlobsList = (state) =>
Object.values(state.treeEntries).filter((f) => f.type === 'blob');
@@ -148,7 +154,7 @@ export const fileLineCodequality = () => () => {
export const currentDiffIndex = (state) =>
Math.max(
0,
- state.diffFiles.findIndex((diff) => diff.file_hash === state.currentDiffFileId),
+ flatBlobsList(state).findIndex((diff) => diff.fileHash === state.currentDiffFileId),
);
export const diffLines = (state) => (file) => {
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 329db1fe2cf..593c28f20ec 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -16,6 +16,7 @@ export default () => ({
removedLines: null,
endpoint: '',
endpointUpdateUser: '',
+ endpointDiffForPath: '',
basePath: '',
commit: null,
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index d2b798245fc..5e7fe8b5cd8 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -15,6 +15,7 @@ import {
prepareDiffData,
isDiscussionApplicableToLine,
updateLineInFile,
+ markTreeEntriesLoaded,
} from './utils';
function updateDiffFilesInState(state, files) {
@@ -33,6 +34,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -46,6 +48,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -80,9 +83,15 @@ export default {
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- state.diffFiles = prepareDiffData({
- diff: data,
- priorFiles: state.diffFiles,
+ Object.assign(state, {
+ diffFiles: prepareDiffData({
+ diff: data,
+ priorFiles: state.diffFiles,
+ }),
+ treeEntries: markTreeEntriesLoaded({
+ priorEntries: state.treeEntries,
+ loadedFiles: data.diff_files,
+ }),
});
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 0519ca3d715..4ca353333b7 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -19,6 +19,8 @@ import {
} from '../constants';
import { prepareRawDiffFile } from '../utils/diff_file';
+const SHA1 = /\b([a-f0-9]{40})\b/;
+
export const isAdded = (line) => ['new', 'new-nonewline'].includes(line.type);
export const isRemoved = (line) => ['old', 'old-nonewline'].includes(line.type);
export const isUnchanged = (line) => !line.type;
@@ -556,3 +558,44 @@ export const allDiscussionWrappersExpanded = (diff) => {
return discussionsExpanded;
};
+
+export function isUrlHashNoteLink(urlHash = '') {
+ const id = urlHash.replace(/^#/, '');
+
+ return id.startsWith('note');
+}
+
+export function isUrlHashFileHeader(urlHash = '') {
+ const id = urlHash.replace(/^#/, '');
+
+ return id.startsWith('diff-content');
+}
+
+export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') {
+ const isNoteLink = isUrlHashNoteLink(urlHash);
+ let id = urlHash.replace(/^#/, '');
+
+ if (isNoteLink && currentDiffFileId) {
+ id = currentDiffFileId;
+ } else if (isUrlHashFileHeader(urlHash)) {
+ id = id.replace('diff-content-', '');
+ } else if (!SHA1.test(id) || isNoteLink) {
+ id = null;
+ }
+
+ return id;
+}
+
+export function markTreeEntriesLoaded({ priorEntries, loadedFiles }) {
+ const newEntries = { ...priorEntries };
+
+ loadedFiles.forEach((newFile) => {
+ const entry = newEntries[newFile.new_path];
+
+ if (entry) {
+ entry.diffLoaded = true;
+ }
+ });
+
+ return newEntries;
+}
diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index bcd9fa01278..e2fb24f7b57 100644
--- a/app/assets/javascripts/diffs/utils/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -39,12 +39,12 @@ function collapsed(file) {
}
function identifier(file) {
- const { userOrGroup, project, id } = getDerivedMergeRequestInformation({
+ const { namespace, project, id } = getDerivedMergeRequestInformation({
endpoint: file.load_collapsed_diff_url,
});
return uuids({
- seeds: [userOrGroup, project, id, file.file_identifier_hash, file.blob?.id],
+ seeds: [namespace, project, id, file.file_identifier_hash, file.blob?.id],
})[0];
}
diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js
index 43e04a814c5..bc81c0b0a05 100644
--- a/app/assets/javascripts/diffs/utils/merge_request.js
+++ b/app/assets/javascripts/diffs/utils/merge_request.js
@@ -1,4 +1,6 @@
-const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i;
+import { ZERO_CHANGES_ALT_DISPLAY } from '../constants';
+
+const endpointRE = /^(\/?(.+\/)+(.+)\/-\/merge_requests\/(\d+)).*$/i;
function getVersionInfo({ endpoint } = {}) {
const dummyRoot = 'https://gitlab.com';
@@ -13,9 +15,20 @@ 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;
+ let namespace;
let project;
let id;
let diffId;
@@ -23,13 +36,15 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) {
const matches = endpointRE.exec(endpoint);
if (matches) {
- [, mrPath, userOrGroup, project, id] = matches;
+ [, mrPath, namespace, project, id] = matches;
({ diffId, startSha } = getVersionInfo({ endpoint }));
+
+ namespace = namespace.replace(/\/$/, '');
}
return {
mrPath,
- userOrGroup,
+ namespace,
project,
id,
diffId,
diff --git a/app/assets/javascripts/diffs/utils/tree_worker_utils.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index a90c1a5c64e..8689809cfa9 100644
--- a/app/assets/javascripts/diffs/utils/tree_worker_utils.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
@@ -85,6 +85,11 @@ export const generateTreeList = (files) => {
if (type === 'blob') {
Object.assign(entry, {
changed: true,
+ diffLoaded: false,
+ filePaths: {
+ old: file.old_path,
+ new: file.new_path,
+ },
tempFile: file.new_file,
deleted: file.deleted_file,
fileHash: file.file_hash,
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
deleted file mode 100644
index 04010a99b52..00000000000
--- a/app/assets/javascripts/diffs/workers/tree_worker.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { sortTree } from '~/ide/stores/utils';
-import { generateTreeList } from '../utils/tree_worker_utils';
-
-// eslint-disable-next-line no-restricted-globals
-self.addEventListener('message', (e) => {
- const { data } = e;
-
- if (data === undefined) {
- return;
- }
-
- const { treeEntries, tree } = generateTreeList(data);
-
- // eslint-disable-next-line no-restricted-globals
- self.postMessage({
- treeEntries,
- tree: sortTree(tree),
- });
-});
diff --git a/app/assets/javascripts/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js
index 897439f56b0..32aa4a22cba 100644
--- a/app/assets/javascripts/docs/docs_bundle.js
+++ b/app/assets/javascripts/docs/docs_bundle.js
@@ -1,4 +1,4 @@
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
function addMousetrapClick(el, key) {
el.addEventListener('click', () => Mousetrap.trigger(key));
diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js
new file mode 100644
index 00000000000..2e1e074db3b
--- /dev/null
+++ b/app/assets/javascripts/drawio/constants.js
@@ -0,0 +1,15 @@
+/*
+ * TODO: Make this URL configurable
+ */
+export const DRAWIO_EDITOR_URL =
+ 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; // TODO Make it configurable
+
+export const DRAWIO_FRAME_ID = 'drawio-frame';
+
+export const DARK_BACKGROUND_COLOR = '#202020';
+
+export const DIAGRAM_BACKGROUND_COLOR = '#ffffff';
+
+export const DRAWIO_IFRAME_TIMEOUT = 4000;
+
+export const DIAGRAM_MAX_SIZE = 10 * 1024 * 1024; // 1MB
diff --git a/app/assets/javascripts/drawio/content_editor_facade.js b/app/assets/javascripts/drawio/content_editor_facade.js
new file mode 100644
index 00000000000..1c41194c1f5
--- /dev/null
+++ b/app/assets/javascripts/drawio/content_editor_facade.js
@@ -0,0 +1,80 @@
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * A set of functions to decouple the content_editor component from
+ * the draw.io editor.
+ * It allows the draw.io editor to obtain a selected drawio_diagram
+ * and replace it or insert a new drawio_diagram node without coupling
+ * the drawio_editor to the Content Editor implementation details
+ * *
+ * @param {Object} params Factory function parameters
+ * @param {Object} params.tiptapEditor See https://tiptap.dev/api/editor
+ * @param {String} params.drawioNodeName Name of the drawio_diagram node in
+ * the ProseMirror document
+ * @param {String} params.uploadsPath API endpoint to upload files
+ * @param {Object} params.assetResolver See
+ * app/assets/javascripts/content_editor/services/asset_resolver.js
+ *
+ * @returns A content_editor_facade object with operations
+ * to get a selected diagram, upload a diagram, insert a new one in the
+ * Content Editor, and update an existing’s diagram URL.
+ */
+export const create = ({ tiptapEditor, drawioNodeName, uploadsPath, assetResolver }) => ({
+ getDiagram: async () => {
+ const { node } = tiptapEditor.state.selection;
+
+ if (!node || node.type.name !== drawioNodeName) {
+ return null;
+ }
+
+ const { src } = node.attrs;
+ const response = await axios.get(src, { responseType: 'text' });
+ const diagramSvg = response.data;
+ const contentType = response.headers['content-type'];
+ const filename = src.split('/').pop();
+
+ return {
+ diagramURL: src,
+ filename,
+ diagramSvg,
+ contentType,
+ };
+ },
+ updateDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => {
+ const src = await assetResolver.resolveUrl(canonicalSrc);
+
+ tiptapEditor
+ .chain()
+ .focus()
+ .updateAttributes(drawioNodeName, {
+ src,
+ canonicalSrc,
+ })
+ .run();
+ },
+ insertDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => {
+ const src = await assetResolver.resolveUrl(canonicalSrc);
+
+ tiptapEditor
+ .chain()
+ .focus()
+ .insertContent({
+ type: drawioNodeName,
+ attrs: {
+ src,
+ canonicalSrc,
+ },
+ })
+ .run();
+ },
+ uploadDiagram: async ({ filename, diagramSvg }) => {
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ const response = await axios.post(uploadsPath, formData);
+
+ return response.data;
+ },
+});
diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js
new file mode 100644
index 00000000000..38d1cadcc63
--- /dev/null
+++ b/app/assets/javascripts/drawio/drawio_editor.js
@@ -0,0 +1,277 @@
+import _ from 'lodash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
+import { __ } from '~/locale';
+import { setAttributes } from '~/lib/utils/dom_utils';
+import {
+ DARK_BACKGROUND_COLOR,
+ DRAWIO_EDITOR_URL,
+ DRAWIO_FRAME_ID,
+ DIAGRAM_BACKGROUND_COLOR,
+ DRAWIO_IFRAME_TIMEOUT,
+ DIAGRAM_MAX_SIZE,
+} from './constants';
+
+function updateDrawioEditorState(drawIOEditorState, data) {
+ Object.assign(drawIOEditorState, data);
+}
+
+function postMessageToDrawioEditor(drawIOEditorState, message) {
+ const { origin } = new URL(DRAWIO_EDITOR_URL);
+
+ drawIOEditorState.iframe.contentWindow.postMessage(JSON.stringify(message), origin);
+}
+
+function disposeDrawioEditor(drawIOEditorState) {
+ drawIOEditorState.disposeEventListener();
+ drawIOEditorState.iframe.remove();
+}
+
+function getSvg(data) {
+ const svgPath = atob(data.substring(data.indexOf(',') + 1));
+
+ return `<?xml version="1.0" encoding="UTF-8"?>\n\
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n\
+ ${svgPath}`;
+}
+
+async function saveDiagram(drawIOEditorState, editorFacade) {
+ const { newDiagram, diagramMarkdown, filename, diagramSvg } = drawIOEditorState;
+ const filenameWithExt = filename.endsWith('.drawio.svg') ? filename : `${filename}.drawio.svg`;
+
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'spinner',
+ show: true,
+ messageKey: 'saving',
+ });
+
+ try {
+ const uploadResults = await editorFacade.uploadDiagram({
+ filename: filenameWithExt,
+ diagramSvg,
+ });
+
+ if (newDiagram) {
+ editorFacade.insertDiagram({ uploadResults });
+ } else {
+ editorFacade.updateDiagram({ diagramMarkdown, uploadResults });
+ }
+
+ createAlert({
+ message: __('Diagram saved successfully.'),
+ variant: VARIANT_SUCCESS,
+ fadeTransition: true,
+ });
+ setTimeout(() => disposeDrawioEditor(drawIOEditorState), 10);
+ } catch {
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'spinner', show: false });
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ modified: true,
+ buttonKey: 'close',
+ messageKey: 'errorSavingFile',
+ });
+ }
+}
+
+function promptName(drawIOEditorState, name, errKey) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'prompt',
+ titleKey: 'filename',
+ okKey: 'save',
+ defaultValue: name || '',
+ });
+
+ if (errKey !== null) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ messageKey: errKey,
+ buttonKey: 'ok',
+ });
+ }
+}
+
+function sendLoadDiagramMessage(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'load',
+ xml: drawIOEditorState.diagramSvg,
+ border: 8,
+ background: DIAGRAM_BACKGROUND_COLOR,
+ dark: drawIOEditorState.dark,
+ title: drawIOEditorState.filename,
+ });
+}
+
+async function loadExistingDiagram(drawIOEditorState, editorFacade) {
+ let diagram = null;
+
+ try {
+ diagram = await editorFacade.getDiagram();
+ } catch (e) {
+ throw new Error(__('Cannot load the diagram into the diagrams.net editor'));
+ }
+
+ if (diagram) {
+ const { diagramMarkdown, filename, diagramSvg, contentType, diagramURL } = diagram;
+ const resolvedURL = new URL(diagramURL, window.location.origin);
+ const diagramSvgSize = new Blob([diagramSvg]).size;
+
+ if (contentType !== 'image/svg+xml') {
+ throw new Error(__('The selected image is not a valid SVG diagram'));
+ }
+
+ if (resolvedURL.origin !== window.location.origin) {
+ throw new Error(__('The selected image is not an asset uploaded in the application'));
+ }
+
+ if (diagramSvgSize > DIAGRAM_MAX_SIZE) {
+ throw new Error(__('The selected image is too large.'));
+ }
+
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: false,
+ filename,
+ diagramMarkdown,
+ diagramSvg,
+ });
+ } else {
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: true,
+ });
+ }
+
+ sendLoadDiagramMessage(drawIOEditorState);
+}
+
+async function prepareEditor(drawIOEditorState, editorFacade) {
+ const { iframe } = drawIOEditorState;
+
+ iframe.style.cursor = 'wait';
+
+ try {
+ await loadExistingDiagram(drawIOEditorState, editorFacade);
+
+ iframe.style.visibility = 'visible';
+ iframe.style.cursor = '';
+ window.scrollTo(0, 0);
+ } catch (e) {
+ createAlert({
+ message: e.message,
+ error: e,
+ });
+ disposeDrawioEditor(drawIOEditorState);
+ }
+}
+
+function configureDrawIOEditor(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'configure',
+ config: {
+ darkColor: DARK_BACKGROUND_COLOR,
+ settingsName: 'gitlab',
+ },
+ colorSchemeMeta: drawIOEditorState.dark, // For transparent iframe background in dark mode
+ });
+ updateDrawioEditorState(drawIOEditorState, {
+ initialized: true,
+ });
+}
+
+function onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt) {
+ if (_.isNil(evt) || evt.source !== drawIOEditorState.iframe.contentWindow) {
+ return;
+ }
+
+ const msg = JSON.parse(evt.data);
+
+ if (msg.event === 'configure') {
+ configureDrawIOEditor(drawIOEditorState);
+ } else if (msg.event === 'init') {
+ prepareEditor(drawIOEditorState, editorFacade);
+ } else if (msg.event === 'exit') {
+ disposeDrawioEditor(drawIOEditorState);
+ } else if (msg.event === 'prompt') {
+ updateDrawioEditorState(drawIOEditorState, {
+ filename: msg.value,
+ });
+
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', 'filenameShort');
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ } else if (msg.event === 'export') {
+ updateDrawioEditorState(drawIOEditorState, {
+ diagramSvg: getSvg(msg.data),
+ });
+ // TODO Add this to draw.io editor configuration
+ sendLoadDiagramMessage(drawIOEditorState); // Save removes diagram from the editor, so we need to reload it.
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'status', modified: true }); // And set editor modified flag to true.
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', null);
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ }
+}
+
+function createEditorIFrame(drawIOEditorState) {
+ const iframe = document.createElement('iframe');
+
+ setAttributes(iframe, {
+ id: DRAWIO_FRAME_ID,
+ src: DRAWIO_EDITOR_URL,
+ class: 'drawio-editor',
+ });
+
+ document.body.appendChild(iframe);
+
+ setTimeout(() => {
+ if (drawIOEditorState.initialized === false) {
+ disposeDrawioEditor(drawIOEditorState);
+ createAlert({ message: __('The diagrams.net editor could not be loaded.') });
+ }
+ }, DRAWIO_IFRAME_TIMEOUT);
+
+ updateDrawioEditorState(drawIOEditorState, {
+ iframe,
+ });
+}
+
+function attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade) {
+ const evtHandler = (evt) => {
+ onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt);
+ };
+
+ window.addEventListener('message', evtHandler);
+
+ // Stores a function in the editor state object that allows disposing
+ // the message event listener when the editor exits.
+ updateDrawioEditorState(drawIOEditorState, {
+ disposeEventListener: () => {
+ window.removeEventListener('message', evtHandler);
+ },
+ });
+}
+
+const createDrawioEditorState = ({ filename = null }) => ({
+ newDiagram: true,
+ filename,
+ diagramSvg: null,
+ diagramMarkdown: null,
+ iframe: null,
+ isBusy: false,
+ initialized: false,
+ dark: darkModeEnabled(),
+ disposeEventListener: null,
+});
+
+export function launchDrawioEditor({ editorFacade, filename }) {
+ const drawIOEditorState = createDrawioEditorState({ filename });
+
+ // The execution order of these two functions matter
+ attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade);
+ createEditorIFrame(drawIOEditorState);
+}
diff --git a/app/assets/javascripts/drawio/markdown_field_editor_facade.js b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
new file mode 100644
index 00000000000..4ef203c7aa0
--- /dev/null
+++ b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
@@ -0,0 +1,72 @@
+import { insertMarkdownText, resolveSelectedImage } from '~/lib/utils/text_markdown';
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * A set of functions to decouple the markdown_field component from
+ * the draw.io editor.
+ * It allows the draw.io editor to obtain a selected drawio_diagram
+ * and replace it or insert a new drawio_diagram node without coupling
+ * the drawio_editor to the Markdown Field implementation details
+ *
+ * @param {Object} params Factory function parameters
+ * @param {Object} params.textArea Textarea used to edit and display markdown source
+ * @param {String} params.markdownPreviewPath API endpoint to render Markdown
+ * @param {String} params.uploadsPath API endpoint to upload files
+ *
+ * @returns A markdown_field_facade object with operations
+ * with operations to get a selected diagram, upload a diagram,
+ * insert a new one in the Markdown Field, and update
+ * an existing’s diagram URL.
+ */
+export const create = ({ textArea, markdownPreviewPath, uploadsPath }) => ({
+ getDiagram: async () => {
+ const image = await resolveSelectedImage(textArea, markdownPreviewPath);
+
+ if (!image) {
+ return null;
+ }
+
+ const { imageURL, imageMarkdown, filename } = image;
+ const response = await axios.get(imageURL, { responseType: 'text' });
+ const diagramSvg = response.data;
+ const contentType = response.headers['content-type'];
+
+ return {
+ diagramURL: imageURL,
+ diagramMarkdown: imageMarkdown,
+ filename,
+ diagramSvg,
+ contentType,
+ };
+ },
+ updateDiagram: ({ uploadResults, diagramMarkdown }) => {
+ textArea.focus();
+
+ // eslint-disable-next-line no-param-reassign
+ textArea.value = textArea.value.replace(diagramMarkdown, uploadResults.link.markdown);
+ textArea.dispatchEvent(new Event('input'));
+ },
+ insertDiagram: ({ uploadResults }) => {
+ textArea.focus();
+ const markdown = textArea.value;
+ const selectedMD = markdown.substring(textArea.selectionStart, textArea.selectionEnd);
+
+ // This method dispatches the input event.
+ insertMarkdownText({
+ textArea,
+ text: markdown,
+ tag: uploadResults.link.markdown,
+ selected: selectedMD,
+ });
+ },
+ uploadDiagram: async ({ filename, diagramSvg }) => {
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ const response = await axios.post(uploadsPath, formData);
+
+ return response.data;
+ },
+});
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
index c72145f9d2f..0afee7bebe0 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -2,7 +2,7 @@
import { isEmpty } from 'lodash';
import { GlButtonGroup } from '@gitlab/ui';
import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
-import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import { EDITOR_TOOLBAR_BUTTON_GROUPS } from '~/editor/constants';
import SourceEditorToolbarButton from './source_editor_toolbar_button.vue';
export default {
@@ -34,8 +34,7 @@ export default {
return nodes.map((item) => {
return {
...item,
- group:
- (this.$options.groups.includes(item.group) && item.group) || EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS[item.group] || EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
};
});
},
@@ -46,24 +45,38 @@ export default {
return !isEmpty(this.getGroupItems(group));
},
},
- groups: [EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP],
+ groups: EDITOR_TOOLBAR_BUTTON_GROUPS,
};
</script>
<template>
<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-justify-content-space-between gl-align-items-center"
+ class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center"
>
- <div v-for="group in $options.groups" :key="group">
- <gl-button-group v-if="hasGroupItems(group)">
- <source-editor-toolbar-button
- v-for="item in getGroupItems(group)"
- :key="item.id"
- :button="item"
- @click="$emit('click', item)"
- />
- </gl-button-group>
- </div>
+ <gl-button-group v-if="hasGroupItems($options.groups.file)">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.file)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
+ <gl-button-group v-if="hasGroupItems($options.groups.edit)">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.edit)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
+ <gl-button-group v-if="hasGroupItems($options.groups.settings)" class="gl-ml-auto">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.settings)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
</section>
</template>
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d235319dfd7..2be671ec7d8 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -1,3 +1,4 @@
+import { KeyMod, KeyCode } from 'monaco-editor';
import { getModifierKey } from '~/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
@@ -15,15 +16,15 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
-export const EDITOR_TOOLBAR_LEFT_GROUP = 'left';
-export const EDITOR_TOOLBAR_RIGHT_GROUP = 'right';
+export const EDITOR_TOOLBAR_BUTTON_GROUPS = {
+ file: 'file', // external helpers (file-tree, etc.)
+ edit: 'edit', // formatting the text in the editor (bold, italic, add link, etc.)
+ settings: 'settings', // editor-wide settings (soft-wrap, full-screen, etc.)
+};
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
-export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
- 'SourceEditor|Source Editor instance is required to set up an extension.',
-);
export const EDITOR_EXTENSION_DEFINITION_ERROR = s__(
'SourceEditor|Extension definition should be either a class or a function',
);
@@ -73,7 +74,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '**',
- mdShortcuts: '["mod+b"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyB],
},
},
{
@@ -83,7 +85,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '_',
- mdShortcuts: '["mod+i"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyI],
},
},
{
@@ -93,7 +96,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '~~',
- mdShortcuts: '["mod+shift+x]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyX],
},
},
{
@@ -114,13 +118,14 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
},
{
id: 'link',
- label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), {
+ label: sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
modifierKey,
}),
data: {
mdTag: '[{text}](url)',
mdSelect: 'url',
- mdShortcuts: '["mod+k"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyK],
},
},
{
@@ -166,3 +171,4 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
},
},
];
+export const EXTENSION_SOFTWRAP_ID = 'soft-wrap';
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 0590bb7455a..8ec83e4df1c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -1,8 +1,11 @@
import { Range } from 'monaco-editor';
+import { __ } from '~/locale';
import {
EDITOR_TYPE_CODE,
EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
EXTENSION_BASE_LINE_NUMBERS_CLASS,
+ EDITOR_TOOLBAR_BUTTON_GROUPS,
+ EXTENSION_SOFTWRAP_ID,
} from '../constants';
const hashRegexp = /#?L/g;
@@ -24,6 +27,13 @@ export class SourceEditorExtension {
return 'BaseExtension';
}
+ onSetup(instance) {
+ this.toolbarButtons = [];
+ if (instance.toolbar) {
+ this.setupToolbar(instance);
+ }
+ }
+
// eslint-disable-next-line class-methods-use-this
onUse(instance) {
SourceEditorExtension.highlightLines(instance);
@@ -32,6 +42,31 @@ export class SourceEditorExtension {
}
}
+ onBeforeUnuse(instance) {
+ const ids = this.toolbarButtons.map((item) => item.id);
+ if (instance.toolbar) {
+ instance.toolbar.removeItems(ids);
+ }
+ }
+
+ setupToolbar(instance) {
+ this.toolbarButtons = [
+ {
+ id: EXTENSION_SOFTWRAP_ID,
+ label: __('Soft wrap'),
+ icon: 'soft-wrap',
+ selected: instance.getOption(116) === 'on',
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
+ category: 'primary',
+ selectedLabel: __('No wrap'),
+ selectedIcon: 'soft-unwrap',
+ class: 'soft-wrap-toggle',
+ onClick: () => instance.toggleSoftwrap(),
+ },
+ ];
+ instance.toolbar.addItems(this.toolbarButtons);
+ }
+
static onMouseMoveHandler(e) {
const target = e.target.element;
if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) {
@@ -108,6 +143,16 @@ export class SourceEditorExtension {
highlightLines(instance, bounds = null) {
SourceEditorExtension.highlightLines(instance, bounds);
},
+
+ toggleSoftwrap(instance) {
+ const isSoftWrapped = instance.getOption(116) === 'on';
+ instance.updateOptions({ wordWrap: isSoftWrapped ? 'off' : 'on' });
+ if (instance.toolbar) {
+ instance.toolbar.updateItem(EXTENSION_SOFTWRAP_ID, {
+ selected: !isSoftWrapped,
+ });
+ }
+ },
};
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 6105a577996..0a5843ec631 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -1,5 +1,5 @@
import { insertMarkdownText } from '~/lib/utils/text_markdown';
-import { EDITOR_TOOLBAR_RIGHT_GROUP, EXTENSION_MARKDOWN_BUTTONS } from '../constants';
+import { EDITOR_TOOLBAR_BUTTON_GROUPS, EXTENSION_MARKDOWN_BUTTONS } from '../constants';
export class EditorMarkdownExtension {
static get extensionName() {
@@ -8,6 +8,7 @@ export class EditorMarkdownExtension {
onSetup(instance) {
this.toolbarButtons = [];
+ this.actions = [];
if (instance.toolbar) {
this.setupToolbar(instance);
}
@@ -17,14 +18,30 @@ export class EditorMarkdownExtension {
if (instance.toolbar) {
instance.toolbar.removeItems(ids);
}
+ this.actions.forEach((action) => {
+ action.dispose();
+ });
+ this.actions = [];
}
setupToolbar(instance) {
this.toolbarButtons = EXTENSION_MARKDOWN_BUTTONS.map((btn) => {
+ if (btn.data.mdShortcuts) {
+ this.actions.push(
+ instance.addAction({
+ id: btn.id,
+ label: btn.label,
+ keybindings: btn.data.mdShortcuts,
+ run(inst) {
+ inst.insertMarkdown(btn.data);
+ },
+ }),
+ );
+ }
return {
...btn,
icon: btn.id,
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.edit,
category: 'tertiary',
onClick: (e) => instance.insertMarkdown(e),
};
@@ -66,12 +83,8 @@ export class EditorMarkdownExtension {
instance.setPosition(pos);
},
insertMarkdown: (instance, e) => {
- const {
- mdTag: tag,
- mdBlock: blockTag,
- mdPrepend,
- mdSelect: select,
- } = e.currentTarget.dataset;
+ const { mdTag: tag, mdBlock: blockTag, mdPrepend, mdSelect: select } =
+ e.currentTarget?.dataset || e;
insertMarkdownText({
tag,
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 58ddaa94d5e..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
@@ -1,7 +1,7 @@
import { KeyMod, KeyCode, Emitter } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
@@ -14,7 +14,7 @@ import {
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
EXTENSION_MARKDOWN_PREVIEW_LABEL,
EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
- EDITOR_TOOLBAR_RIGHT_GROUP,
+ EDITOR_TOOLBAR_BUTTON_GROUPS,
} from '../constants';
const fetchPreview = (text, previewMarkdownPath) => {
@@ -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) {
@@ -116,7 +111,7 @@ export class EditorMarkdownPreviewExtension {
label: EXTENSION_MARKDOWN_PREVIEW_LABEL,
icon: 'live-preview',
selected: false,
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
category: 'primary',
selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
onClick: () => instance.togglePreview(),
@@ -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 57477a993c5..d240ad7353a 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -318,6 +318,10 @@
"cyclonedx": {
"$ref": "#/definitions/string_file_list",
"markdownDescription": "Path to file or list of files with cyclonedx report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportscyclonedx)."
+ },
+ "load_performance": {
+ "$ref": "#/definitions/string_file_list",
+ "markdownDescription": "Path to file or list of files with load performance testing report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsload_performance)."
}
}
}
@@ -356,6 +360,9 @@
},
"rules": {
"$ref": "#/definitions/rules"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -391,6 +398,9 @@
}
}
]
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -407,6 +417,9 @@
"type": "string",
"format": "uri-reference",
"pattern": "\\.ya?ml$"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -421,6 +434,9 @@
"description": "Local path to component directory or full path to external component directory.",
"type": "string",
"format": "uri-reference"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -436,6 +452,9 @@
"type": "string",
"format": "uri-reference",
"pattern": "^https?://.+\\.ya?ml$"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -537,7 +556,7 @@
},
"entrypoint": {
"type": "array",
- "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "markdownDescription": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minItems": 1,
"items": {
"type": "string"
@@ -572,7 +591,7 @@
},
"command": {
"type": "array",
- "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
+ "markdownDescription": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minItems": 1,
"items": {
"type": "string"
@@ -580,8 +599,12 @@
},
"alias": {
"type": "string",
- "description": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information.",
+ "markdownDescription": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minLength": 1
+ },
+ "variables": {
+ "$ref": "#/definitions/jobVariables",
+ "markdownDescription": "Additional environment variables that are passed exclusively to the service. Service variables cannot reference themselves. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)"
}
},
"required": [
@@ -751,6 +774,9 @@
},
"allow_failure": {
"$ref": "#/definitions/allow_failure"
+ },
+ "needs": {
+ "$ref": "#/definitions/rulesNeeds"
}
}
},
@@ -913,6 +939,39 @@
"markdownDescription": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. [Learn More](https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay).",
"minLength": 1
},
+ "rulesNeeds": {
+ "markdownDescription": "Use needs in rules to update job needs for specific conditions. When a condition matches a rule, the job's needs configuration is completely replaced with the needs in the rule. [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#rulesneeds).",
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Name of a job that is defined in the pipeline."
+ },
+ "artifacts": {
+ "type": "boolean",
+ "description": "Download artifacts of the job in needs."
+ },
+ "optional": {
+ "type": "boolean",
+ "description": "Whether the job needs to be present in the pipeline to run ahead of the current job."
+ }
+ },
+ "required": [
+ "job"
+ ]
+ }
+ ]
+ }
+ },
"allow_failure": {
"markdownDescription": "Allow job to fail. A failed job does not cause the pipeline to fail. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#allow_failure).",
"oneOf": [
@@ -1033,6 +1092,14 @@
"on_failure",
"always"
]
+ },
+ "fallback_keys": {
+ "type": "array",
+ "markdownDescription": "List of keys to download cache from if no cache hit occurred for key",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 5
}
}
},
@@ -1244,6 +1311,10 @@
"markdownDescription": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#interruptible).",
"default": false
},
+ "inputs": {
+ "markdownDescription": "Used to pass input values to included templates or components. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#set-input-parameter-values-with-includeinputs).",
+ "type": "object"
+ },
"job": {
"allOf": [
{
@@ -1886,6 +1957,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/editor/utils.js b/app/assets/javascripts/editor/utils.js
index df9d3f2b9fb..cea9a8971b7 100644
--- a/app/assets/javascripts/editor/utils.js
+++ b/app/assets/javascripts/editor/utils.js
@@ -16,17 +16,18 @@ export const setupEditorTheme = () => {
monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME);
};
-export const getBlobLanguage = (blobPath) => {
+export const getBlobLanguage = (path) => {
const defaultLanguage = 'plaintext';
- if (!blobPath) {
+ if (!path) {
return defaultLanguage;
}
- const ext = `.${blobPath.split('.').pop()}`;
+ const blobPath = path.split('/').pop();
+ const ext = blobPath.includes('.') ? `.${blobPath.split('.').pop()}` : blobPath;
const language = monacoLanguages
.getLanguages()
- .find((lang) => lang.extensions.indexOf(ext) !== -1);
+ .find((lang) => lang.extensions.indexOf(ext.toLowerCase()) !== -1);
return language ? language.id : defaultLanguage;
};
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index 427a504e038..677c11277a3 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -2,8 +2,6 @@ import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import showToast from '~/vue_shared/plugins/global_toast';
import {
SET_INITIAL_DATA,
FETCH_AWARDS_SUCCESS,
@@ -62,8 +60,6 @@ export const toggleAward = async ({ commit, state }, name) => {
throw err;
});
-
- showToast(__('Award removed'));
} else {
const optimisticAward = newOptimisticAward(name, state);
@@ -78,8 +74,6 @@ export const toggleAward = async ({ commit, state }, name) => {
});
commit(ADD_NEW_AWARD, data);
-
- showToast(__('Award added'));
}
} catch (error) {
Sentry.captureException(error);
diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue
index 4f4c32af113..bbac6866636 100644
--- a/app/assets/javascripts/emoji/components/emoji_group.vue
+++ b/app/assets/javascripts/emoji/components/emoji_group.vue
@@ -1,5 +1,10 @@
<script>
+import { compatFunctionalMixin } from '~/lib/utils/vue3compat/compat_functional_mixin';
+
export default {
+ // Temporary mixin for migration from Vue.js 2 to @vue/compat
+ mixins: [compatFunctionalMixin],
+
props: {
emojis: {
type: Array,
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
new file mode 100644
index 00000000000..6e88a998096
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -0,0 +1,6 @@
+import '~/webpack';
+import '~/commons';
+import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle';
+
+initSuperSidebar();
+initSuperSidebarToggle();
diff --git a/app/assets/javascripts/entrypoints/tracker.js b/app/assets/javascripts/entrypoints/tracker.js
new file mode 100644
index 00000000000..91d19d249b3
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/tracker.js
@@ -0,0 +1,50 @@
+import {
+ newTracker,
+ enableActivityTracking,
+ trackPageView,
+ setDocumentTitle,
+ trackStructEvent,
+ setCustomUrl,
+ setReferrerUrl,
+} from '@snowplow/browser-tracker';
+import {
+ enableLinkClickTracking,
+ LinkClickTrackingPlugin,
+} from '@snowplow/browser-plugin-link-click-tracking';
+import { enableFormTracking, FormTrackingPlugin } from '@snowplow/browser-plugin-form-tracking';
+import { TimezonePlugin } from '@snowplow/browser-plugin-timezone';
+import { GaCookiesPlugin } from '@snowplow/browser-plugin-ga-cookies';
+import { PerformanceTimingPlugin } from '@snowplow/browser-plugin-performance-timing';
+import { ClientHintsPlugin } from '@snowplow/browser-plugin-client-hints';
+
+const SNOWPLOW_ACTIONS = {
+ newTracker,
+ enableActivityTracking,
+ trackPageView,
+ setDocumentTitle,
+ trackStructEvent,
+ enableLinkClickTracking,
+ enableFormTracking,
+ setCustomUrl,
+ setReferrerUrl,
+};
+
+window.snowplow = (action, ...config) => {
+ if (SNOWPLOW_ACTIONS[action]) {
+ SNOWPLOW_ACTIONS[action](...config);
+ } else {
+ // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
+ console.warn('Unsupported snowplow action:', action);
+ }
+};
+
+window.snowplowPlugins = [
+ LinkClickTrackingPlugin(),
+ FormTrackingPlugin(),
+ TimezonePlugin(),
+ GaCookiesPlugin(),
+ PerformanceTimingPlugin(),
+ ClientHintsPlugin(),
+];
+
+export default {};
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index cacd868bed0..aff7d34f191 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -42,7 +42,7 @@ export default {
modalId: CANARY_UPDATE_MODAL,
actionPrimary: {
text: s__('CanaryIngress|Change ratio'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
actionCancel: { text: __('Cancel') },
static: true,
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 8259574f8e3..2cf71de7ea2 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -3,6 +3,7 @@
* Render modal to confirm rollback/redeploy.
*/
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
@@ -75,7 +76,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;
@@ -120,10 +126,17 @@ export default {
},
onOk() {
if (this.graphql) {
- this.$apollo.mutate({
- mutation: rollbackEnvironment,
- variables: { environment: this.environment },
- });
+ this.$apollo
+ .mutate({
+ mutation: rollbackEnvironment,
+ variables: { environment: this.environment },
+ })
+ .then(() => {
+ this.$emit('rollback');
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ });
} else {
eventHub.$emit('rollbackEnvironment', this.environment);
}
@@ -135,7 +148,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 +169,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/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index 78e1b8d5cb2..47f38980acc 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql';
@@ -29,7 +29,7 @@ export default {
primaryProps() {
return {
text: s__('Environments|Delete environment'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
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/deploy_freeze_alert.vue b/app/assets/javascripts/environments/components/deploy_freeze_alert.vue
new file mode 100644
index 00000000000..aaa7e71758c
--- /dev/null
+++ b/app/assets/javascripts/environments/components/deploy_freeze_alert.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { sortBy } from 'lodash';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import deployFreezesQuery from '../graphql/queries/deploy_freezes.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['projectFullPath'],
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { deployFreezes: [] };
+ },
+
+ apollo: {
+ deployFreezes: {
+ query: deployFreezesQuery,
+ update(data) {
+ const freezes = data?.project?.environment?.deployFreezes;
+ return sortBy(freezes, [(freeze) => freeze.startTime]);
+ },
+ variables() {
+ return {
+ projectFullPath: this.projectFullPath,
+ environmentName: this.name,
+ };
+ },
+ },
+ },
+ computed: {
+ shouldShowDeployFreezeAlert() {
+ return this.deployFreezes.length > 0;
+ },
+ nextDeployFreeze() {
+ return this.deployFreezes[0];
+ },
+ deployFreezeStartTime() {
+ return formatDate(this.nextDeployFreeze.startTime);
+ },
+ deployFreezeEndTime() {
+ return formatDate(this.nextDeployFreeze.endTime);
+ },
+ },
+ i18n: {
+ deployFreezeAlert: s__(
+ 'Environments|A freeze period is in effect from %{startTime} to %{endTime}. Deployments might fail during this time. For more information, see the %{docsLinkStart}deploy freeze documentation%{docsLinkEnd}.',
+ ),
+ },
+ deployFreezeDocsPath: helpPagePath('user/project/releases/index', {
+ anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze',
+ }),
+};
+</script>
+<template>
+ <gl-alert v-if="shouldShowDeployFreezeAlert" :dismissible="false" class="gl-mt-4">
+ <gl-sprintf :message="$options.i18n.deployFreezeAlert">
+ <template #startTime
+ ><span class="gl-font-weight-bold">{{ deployFreezeStartTime }}</span></template
+ >
+ <template #endTime
+ ><span class="gl-font-weight-bold">{{ deployFreezeEndTime }}</span></template
+ >
+ <template #docsLink="{ content }"
+ ><gl-link :href="$options.deployFreezeDocsPath">{{ content }}</gl-link></template
+ >
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index b00a0777a03..01b8208fd55 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -10,7 +10,7 @@ import {
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import deploymentDetails from '../graphql/queries/deployment_details.query.graphql';
import DeploymentStatusBadge from './deployment_status_badge.vue';
import Commit from './commit.vue';
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index 901d0f5b34d..b63a6897a39 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index e40c37b5095..3ac32f0d045 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -1,12 +1,13 @@
<script>
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { ENVIRONMENTS_SCOPE } from '../constants';
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
+ GlSprintf,
},
inject: ['newEnvironmentPath'],
props: {
@@ -14,10 +15,6 @@ export default {
type: String,
required: true,
},
- scope: {
- type: String,
- required: true,
- },
hasTerm: {
type: Boolean,
required: false,
@@ -26,40 +23,40 @@ export default {
},
computed: {
title() {
- return this.hasTerm
- ? this.$options.i18n.searchingTitle
- : this.$options.i18n.title[this.scope];
+ return this.hasTerm ? this.$options.i18n.searchingTitle : this.$options.i18n.title;
},
content() {
return this.hasTerm ? this.$options.i18n.searchingContent : this.$options.i18n.content;
},
- buttonText() {
- return this.hasTerm ? this.$options.i18n.newEnvironmentButtonLabel : '';
- },
},
i18n: {
- title: {
- [ENVIRONMENTS_SCOPE.AVAILABLE]: s__("Environments|You don't have any environments."),
- [ENVIRONMENTS_SCOPE.STOPPED]: s__("Environments|You don't have any stopped environments."),
- },
- content: s__(
- 'Environments|Environments are places where code gets deployed, such as staging or production.',
- ),
searchingTitle: s__('Environments|No results found'),
+ title: s__('Environments|Get started with environments'),
searchingContent: s__('Environments|Edit your search and try again'),
- link: s__('Environments|How do I create an environment?'),
- newEnvironmentButtonLabel: s__('Environments|New environment'),
+ content: s__(
+ 'Environments|Environments are places where code gets deployed, such as staging or production. You can create an environment in the UI or in your .gitlab-ci.yml file. You can also enable review apps, which assist with providing an environment to showcase product changes. %{linkStart}Learn more%{linkEnd} about environments.',
+ ),
+ newEnvironmentButtonLabel: s__('Environments|Create an environment'),
+ enablingReviewButtonLabel: s__('Environments|Enable review apps'),
},
};
</script>
<template>
- <gl-empty-state :primary-button-text="buttonText" :primary-button-link="newEnvironmentPath">
- <template #title>
- <h4>{{ title }}</h4>
- </template>
+ <gl-empty-state class="gl-layout-w-limited" :title="title">
<template #description>
- <p>{{ content }}</p>
- <gl-link v-if="!hasTerm" :href="helpPath">{{ $options.i18n.link }}</gl-link>
+ <gl-sprintf :message="content">
+ <template #link="{ content: contentToDisplay }">
+ <gl-link :href="helpPath">{{ contentToDisplay }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-if="!hasTerm" #actions>
+ <gl-button :href="newEnvironmentPath" variant="confirm">
+ {{ $options.i18n.newEnvironmentButtonLabel }}
+ </gl-button>
+ <gl-button @click="$emit('enable-review')">
+ {{ $options.i18n.enablingReviewButtonLabel }}
+ </gl-button>
</template>
</gl-empty-state>
</template>
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/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 04a390fbba7..27f322eaf93 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,8 +1,6 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import { s__, __ } from '~/locale';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
/**
* Renders the external url link in environments table.
@@ -10,7 +8,6 @@ import { isSafeURL } from '~/lib/utils/url_utility';
export default {
components: {
GlButton,
- ModalCopyButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -24,23 +21,16 @@ export default {
i18n: {
title: s__('Environments|Open live environment'),
open: s__('Environments|Open'),
- copy: __('Copy URL'),
- copyTitle: s__('Environments|Copy live environment URL'),
- },
- computed: {
- isSafeUrl() {
- return isSafeURL(this.externalUrl);
- },
},
};
</script>
<template>
<gl-button
- v-if="isSafeUrl"
v-gl-tooltip
:title="$options.i18n.title"
:aria-label="$options.i18n.title"
:href="externalUrl"
+ is-unsafe-link
class="external-url"
target="_blank"
icon="external-link"
@@ -48,7 +38,4 @@ export default {
>
{{ $options.i18n.open }}
</gl-button>
- <modal-copy-button v-else :title="$options.i18n.copyTitle" :text="externalUrl">
- {{ $options.i18n.copy }}
- </modal-copy-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index ee5d95ae6f0..62ceb66d803 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -17,7 +17,7 @@ export default {
GlLink,
GlSprintf,
},
- inject: ['protectedEnvironmentSettingsPath'],
+ inject: { protectedEnvironmentSettingsPath: { default: '' } },
props: {
environment: {
required: true,
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 1e9924246b9..1486a66fe13 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -16,6 +16,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import DeleteComponent from './environment_delete.vue';
@@ -56,7 +57,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
model: {
@@ -532,6 +533,10 @@ export default {
return this.model.metrics_path || '';
},
+ canShowMetricsLink() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.monitoringUrl);
+ },
+
terminalPath() {
return this.model?.terminal_path ?? '';
},
@@ -544,7 +549,7 @@ export default {
return (
this.actions.length > 0 ||
this.externalURL ||
- this.monitoringUrl ||
+ this.canShowMetricsLink ||
this.canStopEnvironment ||
this.canDeleteEnvironment ||
this.canRetry
@@ -568,7 +573,7 @@ export default {
return Boolean(
this.canRetry ||
this.canShowAutoStopDate ||
- this.monitoringUrl ||
+ this.canShowMetricsLink ||
this.terminalPath ||
this.canDeleteEnvironment,
);
@@ -856,10 +861,11 @@ export default {
/>
<monitoring-button-component
- v-if="monitoringUrl"
+ v-if="canShowMetricsLink"
:monitoring-url="monitoringUrl"
data-track-action="click_button"
data-track-label="environment_monitoring"
+ data-testid="environment-monitoring"
/>
<terminal-button-component
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index b2a69cdb6c6..a95b5b273f7 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -76,7 +76,7 @@ export default {
inject: ['newEnvironmentPath', 'canCreateEnvironment', 'helpPagePath'],
i18n: {
newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
+ reviewAppButtonLabel: s__('Environments|Enable review apps'),
cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'),
available: __('Available'),
stopped: __('Stopped'),
@@ -124,12 +124,24 @@ export default {
hasEnvironments() {
return this.environments.length > 0 || this.folders.length > 0;
},
+ showEmptyState() {
+ return !this.$apollo.queries.environmentApp.loading && !this.hasEnvironments;
+ },
hasSearch() {
return Boolean(this.search);
},
availableCount() {
return this.environmentApp?.availableCount;
},
+ stoppedCount() {
+ return this.environmentApp?.stoppedCount;
+ },
+ hasAnyEnvironment() {
+ return this.availableCount > 0 || this.stoppedCount > 0;
+ },
+ showContent() {
+ return this.hasAnyEnvironment || this.hasSearch;
+ },
addEnvironment() {
if (!this.canCreateEnvironment) {
return null;
@@ -170,9 +182,6 @@ export default {
},
};
},
- stoppedCount() {
- return this.environmentApp?.stoppedCount;
- },
totalItems() {
return this.pageInfo?.total;
},
@@ -253,45 +262,45 @@ export default {
<stop-environment-modal :environment="environmentToStop" graphql />
<confirm-rollback-modal :environment="environmentToRollback" graphql />
<canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
- <gl-tabs
- :action-secondary="openReviewAppModal"
- :action-primary="openCleanUpEnvsModal"
- :action-tertiary="addEnvironment"
- sync-active-tab-with-query-params
- query-param-name="scope"
- @secondary="showReviewAppModal"
- @primary="showCleanUpEnvsModal"
- >
- <gl-tab
- :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
- @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ <template v-if="showContent">
+ <gl-tabs
+ :action-secondary="openReviewAppModal"
+ :action-primary="openCleanUpEnvsModal"
+ :action-tertiary="addEnvironment"
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ @secondary="showReviewAppModal"
+ @primary="showCleanUpEnvsModal"
>
- <template #title>
- <span>{{ $options.i18n.available }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ availableCount }}
- </gl-badge>
- </template>
- </gl-tab>
- <gl-tab
- :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
- @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
- >
- <template #title>
- <span>{{ $options.i18n.stopped }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ stoppedCount }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
- <gl-search-box-by-type
- class="gl-mb-4"
- :value="search"
- :placeholder="$options.i18n.searchPlaceholder"
- @input="setSearch"
- />
- <template v-if="hasEnvironments">
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.available }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ availableCount }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.stopped }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ stoppedCount }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <gl-search-box-by-type
+ class="gl-mb-4"
+ :value="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @input="setSearch"
+ />
<environment-folder
v-for="folder in folders"
:key="folder.name"
@@ -309,10 +318,10 @@ export default {
/>
</template>
<empty-state
- v-else-if="!$apollo.queries.environmentApp.loading"
+ v-if="showEmptyState"
:help-path="helpPagePath"
- :scope="scope"
:has-term="hasSearch"
+ @enable-review="showReviewAppModal"
/>
<gl-pagination
align="center"
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index bb2f053b3fc..0507abf3eaf 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -4,10 +4,10 @@ import csrf from '~/lib/utils/csrf';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import DeployFreezeAlert from './deploy_freeze_alert.vue';
export default {
name: 'EnvironmentsDetailHeader',
@@ -16,15 +16,15 @@ export default {
GlButton,
GlSprintf,
TimeAgo,
+ DeployFreezeAlert,
DeleteEnvironmentModal,
StopEnvironmentModal,
- ModalCopyButton,
},
directives: {
GlModalDirective,
GlTooltip,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
environment: {
type: Object,
@@ -76,8 +76,6 @@ export default {
deleteButtonText: s__('Environments|Delete'),
externalButtonTitle: s__('Environments|Open live environment'),
externalButtonText: __('View deployment'),
- copyUrlText: __('Copy URL'),
- copyUrlTitle: s__('Environments|Copy live environment URL'),
cancelAutoStopButtonTitle: __('Prevent environment from auto-stopping'),
},
computed: {
@@ -87,102 +85,101 @@ export default {
shouldShowExternalUrlButton() {
return Boolean(this.environment.externalUrl);
},
- isSafeUrl() {
- return isSafeURL(this.environment.externalUrl);
- },
shouldShowStopButton() {
return this.canStopEnvironment && this.environment.isAvailable;
},
shouldShowTerminalButton() {
return this.canAdminEnvironment && this.environment.hasTerminals;
},
+ shouldShowMetricsButton() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.shouldShowExternalUrlButton);
+ },
},
};
</script>
<template>
- <header class="top-area gl-justify-content-between">
- <div class="gl-display-flex gl-flex-grow-1 gl-align-items-center">
- <h1 class="page-title gl-font-size-h-display">
- {{ environment.name }}
- </h1>
- <p v-if="shouldShowCancelAutoStopButton" class="gl-mb-0 gl-ml-3" data-testid="auto-stops-at">
- <gl-sprintf :message="$options.i18n.autoStopAtText">
- <template #autoStopAt>
- <time-ago :time="environment.autoStopAt" />
- </template>
- </gl-sprintf>
- </p>
- </div>
- <div class="nav-controls gl-my-1">
- <form method="POST" :action="cancelAutoStopPath" data-testid="cancel-auto-stop-form">
- <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-button
+ <div>
+ <deploy-freeze-alert :name="environment.name" />
+ <header class="top-area gl-justify-content-between">
+ <div class="gl-display-flex gl-flex-grow-1 gl-align-items-center">
+ <h1 class="page-title gl-font-size-h-display">
+ {{ environment.name }}
+ </h1>
+ <p
v-if="shouldShowCancelAutoStopButton"
- v-gl-tooltip.hover
- data-testid="cancel-auto-stop-button"
- :title="$options.i18n.cancelAutoStopButtonTitle"
- type="submit"
- icon="thumbtack"
+ class="gl-mb-0 gl-ml-3"
+ data-testid="auto-stops-at"
+ >
+ <gl-sprintf :message="$options.i18n.autoStopAtText">
+ <template #autoStopAt>
+ <time-ago :time="environment.autoStopAt" />
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="nav-controls gl-my-1">
+ <form method="POST" :action="cancelAutoStopPath" data-testid="cancel-auto-stop-form">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button
+ v-if="shouldShowCancelAutoStopButton"
+ v-gl-tooltip.hover
+ data-testid="cancel-auto-stop-button"
+ :title="$options.i18n.cancelAutoStopButtonTitle"
+ type="submit"
+ icon="thumbtack"
+ />
+ </form>
+ <gl-button
+ v-if="shouldShowTerminalButton"
+ data-testid="terminal-button"
+ :href="terminalPath"
+ icon="terminal"
/>
- </form>
- <gl-button
- v-if="shouldShowTerminalButton"
- data-testid="terminal-button"
- :href="terminalPath"
- icon="terminal"
- />
- <template v-if="shouldShowExternalUrlButton">
<gl-button
- v-if="isSafeUrl"
+ v-if="shouldShowExternalUrlButton"
v-gl-tooltip.hover
data-testid="external-url-button"
:title="$options.i18n.externalButtonTitle"
:href="environment.externalUrl"
+ is-unsafe-link
icon="external-link"
target="_blank"
>{{ $options.i18n.externalButtonText }}</gl-button
>
- <modal-copy-button
- v-else
- :title="$options.i18n.copyUrlTitle"
- :text="environment.externalUrl"
+ <gl-button
+ v-if="shouldShowMetricsButton"
+ v-gl-tooltip.hover
+ data-testid="metrics-button"
+ :href="metricsPath"
+ :title="$options.i18n.metricsButtonTitle"
+ icon="chart"
+ class="gl-mr-2"
+ >
+ {{ $options.i18n.metricsButtonText }}
+ </gl-button>
+ <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath">
+ {{ $options.i18n.editButtonText }}
+ </gl-button>
+ <gl-button
+ v-if="shouldShowStopButton"
+ v-gl-modal-directive="'stop-environment-modal'"
+ data-testid="stop-button"
+ icon="stop"
+ variant="danger"
+ >
+ {{ $options.i18n.stopButtonText }}
+ </gl-button>
+ <gl-button
+ v-if="canDestroyEnvironment"
+ v-gl-modal-directive="'delete-environment-modal'"
+ data-testid="destroy-button"
+ variant="danger"
>
- {{ $options.i18n.copyUrlText }}
- </modal-copy-button>
- </template>
- <gl-button
- v-if="shouldShowExternalUrlButton"
- v-gl-tooltip.hover
- data-testid="metrics-button"
- :href="metricsPath"
- :title="$options.i18n.metricsButtonTitle"
- icon="chart"
- class="gl-mr-2"
- >
- {{ $options.i18n.metricsButtonText }}
- </gl-button>
- <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath">
- {{ $options.i18n.editButtonText }}
- </gl-button>
- <gl-button
- v-if="shouldShowStopButton"
- v-gl-modal-directive="'stop-environment-modal'"
- data-testid="stop-button"
- icon="stop"
- variant="danger"
- >
- {{ $options.i18n.stopButtonText }}
- </gl-button>
- <gl-button
- v-if="canDestroyEnvironment"
- v-gl-modal-directive="'delete-environment-modal'"
- data-testid="destroy-button"
- variant="danger"
- >
- {{ $options.i18n.deleteButtonText }}
- </gl-button>
- </div>
- <delete-environment-modal v-if="canDestroyEnvironment" :environment="environment" />
- <stop-environment-modal v-if="shouldShowStopButton" :environment="environment" />
- </header>
+ {{ $options.i18n.deleteButtonText }}
+ </gl-button>
+ </div>
+ <delete-environment-modal v-if="canDestroyEnvironment" :environment="environment" />
+ <stop-environment-modal v-if="shouldShowStopButton" :environment="environment" />
+ </header>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
new file mode 100644
index 00000000000..7660912f93a
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util';
+import { AGENT_STATUSES } from '~/clusters_list/constants';
+import { s__ } from '~/locale';
+import getK8sClusterAgentQuery from '../graphql/queries/k8s_cluster_agent.query.graphql';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ TimeAgoTooltip,
+ GlAlert,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ agentId: {
+ required: true,
+ type: String,
+ },
+ agentProjectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ apollo: {
+ clusterAgent: {
+ query: getK8sClusterAgentQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.agentProjectPath,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent,
+ error() {
+ this.clusterAgent = null;
+ },
+ },
+ },
+ data() {
+ return {
+ clusterAgent: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.clusterAgent.loading;
+ },
+ agentLastContact() {
+ return getAgentLastContact(this.clusterAgent.tokens.nodes);
+ },
+ agentStatus() {
+ return getAgentStatus(this.agentLastContact);
+ },
+ },
+ methods: {},
+ i18n: {
+ loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
+ agentId: s__('ClusterAgents|Agent ID #%{agentId}'),
+ neverConnectedText: s__('ClusterAgents|Never'),
+ },
+ AGENT_STATUSES,
+};
+</script>
+<template>
+ <gl-loading-icon v-if="isLoading" inline />
+ <div v-else-if="clusterAgent" class="gl-text-gray-900">
+ <gl-icon name="kubernetes-agent" class="gl-text-gray-500" />
+ <gl-link :href="clusterAgent.webPath" class="gl-mr-3">
+ <gl-sprintf :message="$options.i18n.agentId"
+ ><template #agentId>{{ agentId }}</template></gl-sprintf
+ >
+ </gl-link>
+ <span class="gl-mr-3" data-testid="agent-status">
+ <gl-icon
+ :name="$options.AGENT_STATUSES[agentStatus].icon"
+ :class="$options.AGENT_STATUSES[agentStatus].class"
+ />
+ {{ $options.AGENT_STATUSES[agentStatus].name }}
+ </span>
+
+ <span data-testid="agent-last-used-date">
+ <gl-icon name="calendar" />
+ <time-ago-tooltip v-if="agentLastContact" :time="agentLastContact" />
+ <span v-else>{{ $options.i18n.neverConnectedText }}</span>
+ </span>
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ $options.i18n.loadingError }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
new file mode 100644
index 00000000000..1f15c4daa2f
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -0,0 +1,117 @@
+<script>
+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';
+import KubernetesTabs from './kubernetes_tabs.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlButton,
+ GlAlert,
+ KubernetesAgentInfo,
+ KubernetesPods,
+ KubernetesTabs,
+ },
+ inject: ['kasTunnelUrl'],
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ agentId: {
+ required: true,
+ type: String,
+ },
+ agentProjectPath: {
+ required: true,
+ type: String,
+ },
+ namespace: {
+ required: false,
+ type: String,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ error: '',
+ };
+ },
+ computed: {
+ chevronIcon() {
+ return this.isVisible ? 'chevron-down' : 'chevron-right';
+ },
+ 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'),
+ expand: __('Expand'),
+ sectionTitle: s__('Environment|Kubernetes overview'),
+ },
+};
+</script>
+<template>
+ <div class="gl-px-4">
+ <p class="gl-font-weight-bold gl-text-gray-500 gl-display-flex gl-mb-0">
+ <gl-button
+ :icon="chevronIcon"
+ :aria-label="label"
+ category="tertiary"
+ size="small"
+ class="gl-mr-3"
+ @click="toggleCollapse"
+ />{{ $options.i18n.sectionTitle }}
+ </p>
+ <gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4">
+ <template v-if="isVisible">
+ <kubernetes-agent-info
+ :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" />
+ <kubernetes-tabs
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
+ class="gl-mb-5"
+ @cluster-error="onClusterError"
+ /></template>
+ </gl-collapse>
+ </div>
+</template>
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/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue
new file mode 100644
index 00000000000..85fc1c1a07d
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue
@@ -0,0 +1,180 @@
+<script>
+import { GlTab, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import k8sWorkloadsQuery from '../graphql/queries/k8s_workloads.query.graphql';
+import {
+ getDeploymentsStatuses,
+ getDaemonSetStatuses,
+ getStatefulSetStatuses,
+ getReplicaSetStatuses,
+ getJobsStatuses,
+ getCronJobsStatuses,
+} from '../helpers/k8s_integration_helper';
+
+export default {
+ components: {
+ GlTab,
+ GlBadge,
+ GlLoadingIcon,
+ },
+ apollo: {
+ k8sWorkloads: {
+ query: k8sWorkloadsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ update(data) {
+ return data?.k8sWorkloads || {};
+ },
+ error(error) {
+ this.$emit('cluster-error', error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ computed: {
+ summaryLoading() {
+ return this.$apollo.queries.k8sWorkloads.loading;
+ },
+ summaryCount() {
+ return this.k8sWorkloads ? Object.values(this.k8sWorkloads).flat().length : 0;
+ },
+ summaryObjects() {
+ return [
+ this.deploymentsItems,
+ this.daemonSetsItems,
+ this.statefulSetItems,
+ this.replicaSetItems,
+ this.jobItems,
+ this.cronJobItems,
+ ].filter(Boolean);
+ },
+ deploymentsItems() {
+ const items = this.k8sWorkloads?.DeploymentList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.deployments,
+ items: getDeploymentsStatuses(items),
+ };
+ },
+ daemonSetsItems() {
+ const items = this.k8sWorkloads?.DaemonSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.daemonSets,
+ items: getDaemonSetStatuses(items),
+ };
+ },
+ statefulSetItems() {
+ const items = this.k8sWorkloads?.StatefulSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.statefulSets,
+ items: getStatefulSetStatuses(items),
+ };
+ },
+ replicaSetItems() {
+ const items = this.k8sWorkloads?.ReplicaSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.replicaSets,
+ items: getReplicaSetStatuses(items),
+ };
+ },
+ jobItems() {
+ const items = this.k8sWorkloads?.JobList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.jobs,
+ items: getJobsStatuses(items),
+ };
+ },
+ cronJobItems() {
+ const items = this.k8sWorkloads?.CronJobList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.cronJobs,
+ items: getCronJobsStatuses(items),
+ };
+ },
+ },
+ i18n: {
+ summaryTitle: s__('Environment|Summary'),
+ deployments: s__('Environment|Deployments'),
+ daemonSets: s__('Environment|DaemonSets'),
+ statefulSets: s__('Environment|StatefulSets'),
+ replicaSets: s__('Environment|ReplicaSets'),
+ jobs: s__('Environment|Jobs'),
+ cronJobs: s__('Environment|CronJobs'),
+ },
+ badgeVariants: {
+ ready: 'success',
+ completed: 'success',
+ failed: 'danger',
+ suspended: 'neutral',
+ },
+ icons: {
+ Active: { icon: 'status_success', class: 'gl-text-green-500' },
+ },
+};
+</script>
+<template>
+ <gl-tab>
+ <template #title>
+ {{ $options.i18n.summaryTitle }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ summaryCount }}</gl-badge>
+ </template>
+
+ <gl-loading-icon v-if="summaryLoading" />
+
+ <ul v-else class="gl-mt-3 gl-list-style-none gl-bg-white gl-pl-0 gl-mb-0">
+ <li
+ v-for="object in summaryObjects"
+ :key="object.name"
+ class="gl-display-flex gl-align-items-center gl-p-3 gl-border-t gl-text-gray-700"
+ data-testid="summary-list-item"
+ >
+ <div class="gl-flex-grow-1">{{ object.name }}</div>
+
+ <gl-badge
+ v-for="(item, key) in object.items"
+ :key="key"
+ :variant="$options.badgeVariants[key]"
+ size="sm"
+ class="gl-ml-2"
+ >{{ item.length }} {{ key }}</gl-badge
+ >
+ </li>
+ </ul>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
new file mode 100644
index 00000000000..b900c23b2b7
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
@@ -0,0 +1,166 @@
+<script>
+import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql';
+import { generateServicePortsString, getServiceAge } from '../helpers/k8s_integration_helper';
+import { SERVICES_LIMIT_PER_PAGE } from '../constants';
+import KubernetesSummary from './kubernetes_summary.vue';
+
+const tableHeadingClasses = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ GlTable,
+ GlPagination,
+ GlLoadingIcon,
+ KubernetesSummary,
+ },
+ apollo: {
+ k8sServices: {
+ query: k8sServicesQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ };
+ },
+ update(data) {
+ return data?.k8sServices || [];
+ },
+ error(error) {
+ this.$emit('cluster-error', error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ currentPage: 1,
+ };
+ },
+ computed: {
+ servicesItems() {
+ if (!this.k8sServices?.length) return [];
+
+ return this.k8sServices.map((service) => {
+ return {
+ name: service?.metadata?.name,
+ namespace: service?.metadata?.namespace,
+ type: service?.spec?.type,
+ clusterIP: service?.spec?.clusterIP,
+ externalIP: service?.spec?.externalIP,
+ ports: generateServicePortsString(service?.spec?.ports),
+ age: getServiceAge(service?.metadata?.creationTimestamp),
+ };
+ });
+ },
+ servicesLoading() {
+ return this.$apollo.queries.k8sServices.loading;
+ },
+ showPagination() {
+ return this.servicesItems.length > SERVICES_LIMIT_PER_PAGE;
+ },
+ prevPage() {
+ return Math.max(this.currentPage - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.currentPage + 1;
+ return nextPage > Math.ceil(this.servicesItems.length / SERVICES_LIMIT_PER_PAGE)
+ ? null
+ : nextPage;
+ },
+ },
+ i18n: {
+ servicesTitle: s__('Environment|Services'),
+ name: __('Name'),
+ namespace: __('Namespace'),
+ status: __('Status'),
+ type: __('Type'),
+ clusterIP: s__('Environment|Cluster IP'),
+ externalIP: s__('Environment|External IP'),
+ ports: s__('Environment|Ports'),
+ age: s__('Environment|Age'),
+ },
+ servicesFields: [
+ {
+ key: 'name',
+ label: __('Name'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'type',
+ label: __('Type'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'clusterIP',
+ label: s__('Environment|Cluster IP'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'externalIP',
+ label: s__('Environment|External IP'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'ports',
+ label: s__('Environment|Ports'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'age',
+ label: s__('Environment|Age'),
+ thClass: tableHeadingClasses,
+ },
+ ],
+ SERVICES_LIMIT_PER_PAGE,
+};
+</script>
+<template>
+ <gl-tabs>
+ <kubernetes-summary :namespace="namespace" :configuration="configuration" />
+
+ <gl-tab>
+ <template #title>
+ {{ $options.i18n.servicesTitle }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ servicesItems.length }}</gl-badge>
+ </template>
+
+ <gl-loading-icon v-if="servicesLoading" />
+
+ <gl-table
+ v-else
+ :fields="$options.servicesFields"
+ :items="servicesItems"
+ :per-page="$options.SERVICES_LIMIT_PER_PAGE"
+ :current-page="currentPage"
+ stacked="lg"
+ class="gl-bg-white! gl-mt-3"
+ />
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-mt-6"
+ />
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue
index bb4d6ab3428..4b58d133817 100644
--- a/app/assets/javascripts/environments/components/new_environment.vue
+++ b/app/assets/javascripts/environments/components/new_environment.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 73dfd993c5b..912c558c3ce 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -11,6 +11,7 @@ import {
import { __, s__ } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
@@ -22,6 +23,7 @@ import Terminal from './environment_terminal_button.vue';
import Delete from './environment_delete.vue';
import Deployment from './deployment.vue';
import DeployBoardWrapper from './deploy_board_wrapper.vue';
+import KubernetesOverview from './kubernetes_overview.vue';
export default {
components: {
@@ -42,6 +44,7 @@ export default {
Terminal,
TimeAgoTooltip,
Delete,
+ KubernetesOverview,
EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
EnvironmentApproval: () =>
import('ee_component/environments/components/environment_approval.vue'),
@@ -49,6 +52,7 @@ export default {
directives: {
GlTooltip,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['helpPagePath'],
props: {
environment: {
@@ -129,7 +133,7 @@ export default {
return Boolean(
this.retryPath ||
this.canShowAutoStopDate ||
- this.metricsPath ||
+ this.canShowMetricsLink ||
this.terminalPath ||
this.canDeleteEnvironment,
);
@@ -144,12 +148,18 @@ export default {
return now < autoStopDate;
},
+ upcomingDeploymentIid() {
+ return this.environment.upcomingDeployment?.iid.toString() || '';
+ },
autoStopPath() {
return this.environment?.cancelAutoStopPath ?? '';
},
metricsPath() {
return this.environment?.metricsPath ?? '';
},
+ canShowMetricsLink() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.metricsPath);
+ },
terminalPath() {
return this.environment?.terminalPath ?? '';
},
@@ -162,6 +172,19 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
+ agent() {
+ return this.environment?.agent || {};
+ },
+ isKubernetesOverviewAvailable() {
+ return this.glFeatures?.kasUserAccessProject;
+ },
+ hasRequiredAgentData() {
+ const { project, id, name } = this.agent || {};
+ return project && id && name;
+ },
+ showKubernetesOverview() {
+ return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
+ },
},
methods: {
toggleCollapse() {
@@ -184,6 +207,13 @@ export default {
'gl-md-pl-7',
'gl-bg-gray-10',
],
+ kubernetesOverviewClasses: [
+ 'gl-border-gray-100',
+ 'gl-border-t-solid',
+ 'gl-border-1',
+ 'gl-py-4',
+ 'gl-bg-gray-10',
+ ],
};
</script>
<template>
@@ -200,7 +230,7 @@ export default {
:icon="icon"
:aria-label="label"
size="small"
- category="tertiary"
+ category="secondary"
@click="toggleCollapse"
/>
<gl-link
@@ -247,7 +277,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
@@ -281,10 +310,11 @@ export default {
/>
<monitoring
- v-if="metricsPath"
+ v-if="canShowMetricsLink"
:monitoring-url="metricsPath"
data-track-action="click_button"
data-track-label="environment_monitoring"
+ data-testid="environment-monitoring"
/>
<terminal
@@ -328,7 +358,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>
@@ -340,6 +374,14 @@ export default {
</template>
</gl-sprintf>
</div>
+ <div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses">
+ <kubernetes-overview
+ :agent-project-path="agent.project"
+ :agent-name="agent.name"
+ :agent-id="agent.id"
+ :namespace="agent.kubernetesNamespace"
+ />
+ </div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
<deploy-board-wrapper
:rollout-status="rolloutStatus"
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 162ad598c8c..95ece2b653e 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -33,7 +33,7 @@ export default {
primaryProps() {
return {
text: s__('Environments|Stop environment'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
@@ -41,6 +41,9 @@ export default {
text: __('Cancel'),
};
},
+ hasStopAction() {
+ return this.graphql ? this.environment.hasStopAction : this.environment.has_stop_action;
+ },
},
methods: {
@@ -81,7 +84,7 @@ export default {
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
- <div v-if="!environment.has_stop_action" class="warning_message">
+ <div v-if="!hasStopAction" class="warning_message">
<p>
<gl-sprintf
:message="
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 28424322dd2..448cee530f6 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -51,7 +51,7 @@ export const ENVIRONMENT_COUNT_BY_SCOPE = {
};
export const REVIEW_APP_MODAL_I18N = {
- title: s__('ReviewApp|Enable Review App'),
+ title: s__('Environments|Enable Review Apps'),
intro: s__(
'EnableReviewApp|Review apps are dynamic environments that you can use to provide a live preview of changes made in a feature branch.',
),
@@ -87,3 +87,5 @@ export const ENVIRONMENT_NEW_HELP_TEXT = __(
);
export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT;
+
+export const SERVICES_LIMIT_PER_PAGE = 10;
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..e7b10aed20d 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -1,6 +1,7 @@
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+export const ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL = 3000;
export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20;
export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
@@ -30,7 +31,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 +49,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 +62,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..f37f93798ae 100644
--- a/app/assets/javascripts/environments/environment_details/deployments_table.vue
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -48,13 +48,21 @@ export default {
<deployment-job :job="item.job" />
</template>
<template #cell(created)="{ item }">
- <time-ago-tooltip :time="item.created" />
+ <time-ago-tooltip :time="item.created" data-testid="deployment-created-at" />
</template>
<template #cell(deployed)="{ item }">
- <time-ago-tooltip :time="item.deployed" />
+ <time-ago-tooltip
+ v-if="item.deployed"
+ :time="item.deployed"
+ data-testid="deployment-deployed-at"
+ />
</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..ff2dd9935ae 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,20 +1,29 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { logError } from '~/lib/logger';
+import { toggleQueryPollingByVisibility, etagQueryHeaders } from '~/graphql_shared/utils';
+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';
import Pagination from './pagination.vue';
-import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants';
+import {
+ ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL,
+ ENVIRONMENT_DETAILS_PAGE_SIZE,
+} from './constants';
export default {
components: {
+ ConfirmRollbackModal,
Pagination,
DeploymentsTable,
EmptyState,
GlLoadingIcon,
},
+ inject: { graphqlEtagKey: { default: '' } },
props: {
projectFullPath: {
type: String,
@@ -48,11 +57,21 @@ export default {
before: this.before,
};
},
+ pollInterval() {
+ return this.graphqlEtagKey ? ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL : null;
+ },
+ context() {
+ return etagQueryHeaders('environment_details', this.graphqlEtagKey);
+ },
+ },
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
},
},
data() {
return {
project: {},
+ environmentToRollback: {},
isInitialPageDataReceived: false,
isPrefetchingPages: false,
};
@@ -129,6 +148,26 @@ export default {
this.isPrefetchingPages = false;
},
},
+ errorCaptured(error) {
+ Sentry.withScope((scope) => {
+ scope.setTag('vue_component', 'EnvironmentDetailsIndex');
+
+ Sentry.captureException(error);
+ });
+ },
+ mounted() {
+ if (this.graphqlEtagKey) {
+ toggleQueryPollingByVisibility(
+ this.$apollo.queries.project,
+ ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL,
+ );
+ }
+ },
+ methods: {
+ resetPage() {
+ this.$router.push({ query: {} });
+ },
+ },
};
</script>
<template>
@@ -143,5 +182,6 @@ export default {
<pagination :page-info="pageInfo" :disabled="isPaginationDisabled" />
</div>
<empty-state v-if="!isDeploymentTableShown && !isLoading" />
+ <confirm-rollback-modal :environment="environmentToRollback" graphql @rollback="resetPage" />
</div>
</template>
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 26514b59995..6d06cff06b9 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -5,12 +5,16 @@ 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 k8sServicesQuery from './queries/k8s_services.query.graphql';
+import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
export const apolloProvider = (endpoint) => {
const defaultClient = createDefaultClient(resolvers(endpoint), {
typeDefs,
+ useGet: true,
});
const { cache } = defaultClient;
@@ -82,6 +86,81 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: k8sPodsQuery,
+ data: {
+ status: {
+ phase: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: k8sServicesQuery,
+ data: {
+ metadata: {
+ name: null,
+ namespace: null,
+ creationTimestamp: null,
+ },
+ spec: {
+ type: null,
+ clusterIP: null,
+ externalIP: null,
+ ports: [],
+ },
+ },
+ });
+ cache.writeQuery({
+ query: k8sWorkloadsQuery,
+ data: {
+ DeploymentList: {
+ status: {
+ conditions: [],
+ },
+ },
+ DaemonSetList: {
+ status: {
+ numberMisscheduled: 0,
+ numberReady: 0,
+ desiredNumberScheduled: 0,
+ },
+ },
+ StatefulSetList: {
+ status: {
+ readyReplicas: 0,
+ },
+ spec: {
+ replicas: 0,
+ },
+ },
+ ReplicaSetList: {
+ status: {
+ readyReplicas: 0,
+ },
+ spec: {
+ replicas: 0,
+ },
+ },
+ JobList: {
+ status: {
+ failed: 0,
+ succeeded: 0,
+ },
+ spec: {
+ completions: 0,
+ },
+ },
+ CronJobList: {
+ status: {
+ active: 0,
+ lastScheduleTime: '',
+ },
+ spec: {
+ suspend: false,
+ },
+ },
+ },
+ });
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/mutations/stop_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
index 7eae0ef4ce4..f530b798253 100644
--- a/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
+++ b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
@@ -1,5 +1,5 @@
mutation stopEnvironment($environment: LocalEnvironment) {
- stopEnvironment(environment: $environment) @client {
+ stopEnvironmentREST(environment: $environment) @client {
errors
}
}
diff --git a/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql b/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql
new file mode 100644
index 00000000000..7d701b95bbf
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql
@@ -0,0 +1,12 @@
+query getEnvironmentFreezes($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ deployFreezes {
+ startTime
+ endTime
+ }
+ }
+ }
+}
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_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
new file mode 100644
index 00000000000..bd45d2dba2f
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
@@ -0,0 +1,15 @@
+query getK8sClusterAgentQuery($projectPath: ID!, $agentName: String!) {
+ project(fullPath: $projectPath) {
+ id
+ clusterAgent(name: $agentName) {
+ id
+ webPath
+ tokens {
+ nodes {
+ id
+ lastUsedAt
+ }
+ }
+ }
+ }
+}
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..2d57ede8c15
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
@@ -0,0 +1,7 @@
+query getK8sPods($configuration: LocalConfiguration, $namespace: String) {
+ k8sPods(configuration: $configuration, namespace: $namespace) @client {
+ status {
+ phase
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql
new file mode 100644
index 00000000000..d97849eecc1
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql
@@ -0,0 +1,15 @@
+query getK8sServices($configuration: LocalConfiguration) {
+ k8sServices(configuration: $configuration) @client {
+ metadata {
+ name
+ namespace
+ creationTimestamp
+ }
+ spec {
+ type
+ clusterIP
+ externalIP
+ ports
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql
new file mode 100644
index 00000000000..27d39272734
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql
@@ -0,0 +1,50 @@
+query getK8sWorkloads($configuration: LocalConfiguration, $namespace: String) {
+ k8sWorkloads(configuration: $configuration, namespace: $namespace) @client {
+ DeploymentList {
+ status {
+ conditions
+ }
+ }
+ DaemonSetList {
+ status {
+ numberMisscheduled
+ numberReady
+ desiredNumberScheduled
+ }
+ }
+ StatefulSetList {
+ status {
+ readyReplicas
+ }
+ spec {
+ replicas
+ }
+ }
+ ReplicaSetList {
+ status {
+ readyReplicas
+ }
+ spec {
+ replicas
+ }
+ }
+ JobList {
+ status {
+ failed
+ succeeded
+ }
+ spec {
+ completions
+ }
+ }
+ CronJobList {
+ status {
+ active
+ lastScheduleTime
+ }
+ spec {
+ suspend
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index e21670870b8..044e7927606 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,3 +1,4 @@
+import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import {
@@ -28,6 +29,49 @@ const mapEnvironment = (env) => ({
__typename: 'LocalEnvironment',
});
+const mapWorkloadItems = (items, kind) => {
+ return items.map((item) => {
+ const updatedItem = {
+ status: {},
+ spec: {},
+ };
+
+ switch (kind) {
+ case 'DeploymentList':
+ updatedItem.status.conditions = item.status.conditions || [];
+ break;
+ case 'DaemonSetList':
+ updatedItem.status = {
+ numberMisscheduled: item.status.numberMisscheduled || 0,
+ numberReady: item.status.numberReady || 0,
+ desiredNumberScheduled: item.status.desiredNumberScheduled || 0,
+ };
+ break;
+ case 'StatefulSetList':
+ case 'ReplicaSetList':
+ updatedItem.status.readyReplicas = item.status.readyReplicas || 0;
+ updatedItem.spec.replicas = item.spec.replicas || 0;
+ break;
+ case 'JobList':
+ updatedItem.status.failed = item.status.failed || 0;
+ updatedItem.status.succeeded = item.status.succeeded || 0;
+ updatedItem.spec.completions = item.spec.completions || 0;
+ break;
+ case 'CronJobList':
+ updatedItem.status.active = item.status.active || 0;
+ updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || '';
+ updatedItem.spec.suspend = item.spec.suspend || 0;
+ break;
+ default:
+ updatedItem.status = item?.status;
+ updatedItem.spec = item?.spec;
+ break;
+ }
+
+ return updatedItem;
+ });
+};
+
export const resolvers = (endpoint) => ({
Query: {
environmentApp(_context, { page, scope, search }, { cache }) {
@@ -71,9 +115,100 @@ 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;
+ });
+ },
+ k8sServices(_, { configuration }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ return coreV1Api
+ .listCoreV1ServiceForAllNamespaces()
+ .then((res) => {
+ const items = res?.data?.items || [];
+ return items.map((item) => {
+ const { type, clusterIP, externalIP, ports } = item.spec;
+ return {
+ metadata: item.metadata,
+ spec: {
+ type,
+ clusterIP: clusterIP || '-',
+ externalIP: externalIP || '-',
+ ports,
+ },
+ };
+ });
+ })
+ .catch((err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+ });
+ },
+ k8sWorkloads(_, { configuration, namespace }) {
+ const appsV1api = new AppsV1Api(configuration);
+ const batchV1api = new BatchV1Api(configuration);
+
+ let promises;
+
+ if (namespace) {
+ promises = [
+ appsV1api.listAppsV1NamespacedDeployment(namespace),
+ appsV1api.listAppsV1NamespacedDaemonSet(namespace),
+ appsV1api.listAppsV1NamespacedStatefulSet(namespace),
+ appsV1api.listAppsV1NamespacedReplicaSet(namespace),
+ batchV1api.listBatchV1NamespacedJob(namespace),
+ batchV1api.listBatchV1NamespacedCronJob(namespace),
+ ];
+ } else {
+ promises = [
+ appsV1api.listAppsV1DeploymentForAllNamespaces(),
+ appsV1api.listAppsV1DaemonSetForAllNamespaces(),
+ appsV1api.listAppsV1StatefulSetForAllNamespaces(),
+ appsV1api.listAppsV1ReplicaSetForAllNamespaces(),
+ batchV1api.listBatchV1JobForAllNamespaces(),
+ batchV1api.listBatchV1CronJobForAllNamespaces(),
+ ];
+ }
+
+ const summaryList = {
+ DeploymentList: [],
+ DaemonSetList: [],
+ StatefulSetList: [],
+ ReplicaSetList: [],
+ JobList: [],
+ CronJobList: [],
+ };
+
+ return Promise.allSettled(promises).then((results) => {
+ if (results.every((res) => res.status === 'rejected')) {
+ const error = results[0].reason;
+ const errorMessage = error?.response?.data?.message ?? error;
+ throw new Error(errorMessage);
+ }
+ for (const promiseResult of results) {
+ if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) {
+ const { kind, items } = promiseResult.value.data;
+
+ if (items?.length > 0) {
+ summaryList[kind] = mapWorkloadItems(items, kind);
+ }
+ }
+ }
+
+ return summaryList;
+ });
+ },
},
Mutation: {
- stopEnvironment(_, { environment }, { client }) {
+ stopEnvironmentREST(_, { environment }, { client }) {
client.writeQuery({
query: isEnvironmentStoppingQuery,
variables: { environment },
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index b4d1f7326f6..7e46385946f 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -62,6 +62,105 @@ type LocalPageInfo {
previousPage: Int!
}
+type k8sPodStatus {
+ phase: String
+}
+
+type LocalK8sPods {
+ status: k8sPodStatus
+}
+
+input LocalConfiguration {
+ basePath: String
+ baseOptions: JSON
+}
+
+type k8sServiceMetadata {
+ name: String
+ namespace: String
+ creationTimestamp: String
+}
+
+type k8sServiceSpec {
+ type: String
+ clusterIP: String
+ externalIP: String
+ ports: JSON
+}
+
+type LocalK8sServices {
+ metadata: k8sServiceMetadata
+ spec: k8sServiceSpec
+}
+
+type k8sDeploymentStatus {
+ conditions: JSON
+}
+
+type localK8sDeployment {
+ status: k8sDeploymentStatus
+}
+
+type k8sDaemonSetStatus {
+ IntMisscheduled: Int
+ IntReady: Int
+ desiredIntScheduled: Int
+}
+
+type localK8sDaemonSet {
+ status: k8sDaemonSetStatus
+}
+
+type k8sSetStatus {
+ readyReplicas: Int
+}
+
+type k8sSetSpec {
+ replicas: Int
+}
+
+type localK8sSet {
+ status: k8sSetStatus
+ spec: k8sSetSpec
+}
+
+type k8sJobStatus {
+ failed: Int
+ succeeded: Int
+}
+
+type k8sJobSpec {
+ completions: Int
+}
+
+type localK8sJob {
+ status: k8sJobStatus
+ spec: k8sJobSpec
+}
+
+type k8sCronJobStatus {
+ active: Int
+ lastScheduleTime: String
+}
+
+type k8sCronJobSpec {
+ suspend: Boolean
+}
+
+type localK8sCronJob {
+ status: k8sCronJobStatus
+ spec: k8sCronJobSpec
+}
+
+type LocalK8sWorkloads {
+ DeploymentList: [localK8sDeployment]
+ DaemonSetList: [localK8sDaemonSet]
+ StatefulSetList: [localK8sSet]
+ ReplicaSetList: [localK8sSet]
+ JobList: [localK8sJob]
+ CronJobList: [localK8sCronJob]
+}
+
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
@@ -71,10 +170,13 @@ extend type Query {
environmentToStop: LocalEnvironment
isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean
isLastDeployment(environment: LocalEnvironmentInput): Boolean
+ k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods]
+ k8sServices(configuration: LocalConfiguration): [LocalK8sServices]
+ k8sWorkloads(configuration: LocalConfiguration, namespace: String): LocalK8sWorkloads
}
extend type Mutation {
- stopEnvironment(environment: LocalEnvironmentInput): LocalErrors
+ stopEnvironmentREST(environment: LocalEnvironmentInput): LocalErrors
deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors
rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors
cancelAutoStop(autoStopUrl: String!): LocalErrors
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/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
new file mode 100644
index 00000000000..45c65c93a91
--- /dev/null
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -0,0 +1,141 @@
+import { differenceInSeconds } from '~/lib/utils/datetime_utility';
+
+export function generateServicePortsString(ports) {
+ if (!ports?.length) return '';
+
+ return ports
+ .map((port) => {
+ const nodePort = port.nodePort ? `:${port.nodePort}` : '';
+ return `${port.port}${nodePort}/${port.protocol}`;
+ })
+ .join(', ');
+}
+
+export function getServiceAge(creationTimestamp) {
+ if (!creationTimestamp) return '';
+
+ const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date());
+
+ const seconds = Math.floor(timeDifference);
+ const minutes = Math.floor(seconds / 60) % 60;
+ const hours = Math.floor(seconds / 60 / 60) % 24;
+ const days = Math.floor(seconds / 60 / 60 / 24);
+
+ let ageString;
+ if (days > 0) {
+ ageString = `${days}d`;
+ } else if (hours > 0) {
+ ageString = `${hours}h`;
+ } else if (minutes > 0) {
+ ageString = `${minutes}m`;
+ } else {
+ ageString = `${seconds}s`;
+ }
+
+ return ageString;
+}
+
+export function getDeploymentsStatuses(items) {
+ const failed = [];
+ const ready = [];
+
+ items.forEach((item) => {
+ const [available, progressing] = item.status?.conditions ?? [];
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (available.status === 'True') {
+ ready.push(item);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ } else if (available.status !== 'True' && progressing.status !== 'True') {
+ failed.push(item);
+ }
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getDaemonSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return (
+ item.status?.numberMisscheduled > 0 ||
+ item.status?.numberReady !== item.status?.desiredNumberScheduled
+ );
+ });
+ const ready = items.filter((item) => {
+ return (
+ item.status?.numberReady === item.status?.desiredNumberScheduled &&
+ !item.status?.numberMisscheduled
+ );
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getStatefulSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status?.readyReplicas < item.spec?.replicas;
+ });
+ const ready = items.filter((item) => {
+ return item.status?.readyReplicas === item.spec?.replicas;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getReplicaSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status?.readyReplicas < item.spec?.replicas;
+ });
+ const ready = items.filter((item) => {
+ return item.status?.readyReplicas === item.spec?.replicas;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getJobsStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions;
+ });
+ const completed = items.filter((item) => {
+ return item.status?.succeeded === item.spec?.completions;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(completed.length && { completed }),
+ };
+}
+
+export function getCronJobsStatuses(items) {
+ const failed = [];
+ const ready = [];
+ const suspended = [];
+
+ items.forEach((item) => {
+ if (item.status?.active > 0 && !item.status?.lastScheduleTime) {
+ failed.push(item);
+ } else if (item.spec?.suspend) {
+ suspended.push(item);
+ } else if (item.status?.lastScheduleTime) {
+ ready.push(item);
+ }
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(suspended.length && { suspended }),
+ ...(ready.length && { ready }),
+ };
+}
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/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 5e936ad8c96..f8e94cf3ea9 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -3,7 +3,7 @@
*/
import { isEqual, isFunction, omitBy } from 'lodash';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { getParameterByName } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index afce2b7f237..f73cb7fe1bc 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -3,9 +3,13 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EnvironmentsDetailHeader from './components/environments_detail_header.vue';
-import { apolloProvider } from './graphql/client';
+import { apolloProvider as createApolloProvider } from './graphql/client';
import environmentsMixin from './mixins/environments_mixin';
+Vue.use(VueApollo);
+
+const apolloProvider = createApolloProvider();
+
export const initHeader = () => {
const el = document.getElementById('environments-detail-view-header');
const container = document.getElementById('environments-detail-view');
@@ -13,7 +17,11 @@ export const initHeader = () => {
return new Vue({
el,
+ apolloProvider,
mixins: [environmentsMixin],
+ provide: {
+ projectFullPath: dataset.projectFullPath,
+ },
data() {
const environment = {
name: dataset.name,
@@ -60,7 +68,6 @@ export const initPage = async () => {
const dataElement = document.getElementById('environments-detail-view');
const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details));
- Vue.use(VueApollo);
Vue.use(VueRouter);
const el = document.getElementById('environment_details_page');
@@ -90,9 +97,12 @@ export const initPage = async () => {
return new Vue({
el,
- apolloProvider: apolloProvider(),
+ apolloProvider,
router,
- provide: {},
+ provide: {
+ projectPath: dataSet.projectFullPath,
+ graphqlEtagKey: dataSet.graphqlEtagKey,
+ },
render(createElement) {
return createElement('router-view');
},
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index b02c3cd2cba..ccadf940fe3 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -2,7 +2,6 @@
import {
GlButton,
GlFormInput,
- GlLink,
GlLoadingIcon,
GlBadge,
GlAlert,
@@ -10,24 +9,22 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { __, sprintf, n__ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
import query from '../queries/details.query.graphql';
import {
- trackClickErrorLinkToSentryOptions,
trackErrorDetailsViewsOptions,
trackErrorStatusUpdateOptions,
-} from '../utils';
-
+ trackCreateIssueFromError,
+} from '../events_tracking';
import { severityLevel, severityLevelVariant, errorStatus } from '../constants';
import Stacktrace from './stacktrace.vue';
+import ErrorDetailsInfo from './error_details_info.vue';
const SENTRY_TIMEOUT = 10000;
@@ -35,10 +32,8 @@ export default {
components: {
GlButton,
GlFormInput,
- GlLink,
GlLoadingIcon,
TooltipOnTruncate,
- GlIcon,
Stacktrace,
GlBadge,
GlAlert,
@@ -47,9 +42,7 @@ export default {
GlDropdownItem,
GlDropdownDivider,
TimeAgoTooltip,
- },
- directives: {
- TrackEvent: TrackEventDirective,
+ ErrorDetailsInfo,
},
props: {
issueUpdatePath: {
@@ -122,18 +115,7 @@ export default {
'errorStatus',
]),
...mapGetters('details', ['stacktrace']),
- firstReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`;
- },
- lastReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
- },
- firstCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
- },
- lastCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
- },
+
showStacktrace() {
return Boolean(this.stacktrace?.length);
},
@@ -204,9 +186,10 @@ export default {
'updateResolveStatus',
'updateIgnoreStatus',
]),
- trackClickErrorLinkToSentryOptions,
createIssue() {
this.issueCreationInProgress = true;
+ const { category, action } = trackCreateIssueFromError;
+ Tracking.event(category, action);
this.$refs.sentryIssueForm.submit();
},
onIgnoreStatusUpdate() {
@@ -257,6 +240,7 @@ export default {
<div v-if="errorLoading" class="py-3">
<gl-loading-icon size="lg" />
</div>
+
<div v-else-if="error" class="error-details">
<gl-alert v-if="isAlertVisible" @dismiss="isAlertVisible = false">
<gl-sprintf
@@ -386,60 +370,8 @@ export default {
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge>
</template>
- <ul>
- <li v-if="error.gitlabCommit">
- <strong class="bold">{{ __('GitLab commit') }}:</strong>
- <gl-link :href="error.gitlabCommitPath">
- <span>{{ error.gitlabCommit.substr(0, 10) }}</span>
- </gl-link>
- </li>
- <li v-if="error.gitlabIssuePath">
- <strong class="bold">{{ __('GitLab Issue') }}:</strong>
- <gl-link :href="error.gitlabIssuePath">
- <span>{{ error.gitlabIssuePath }}</span>
- </gl-link>
- </li>
- <li v-if="!error.integrated">
- <strong class="bold">{{ __('Sentry event') }}:</strong>
- <gl-link
- v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
- :href="error.externalUrl"
- target="_blank"
- data-testid="external-url-link"
- >
- <span class="text-truncate">{{ error.externalUrl }}</span>
- <gl-icon name="external-link" class="ml-1 flex-shrink-0" />
- </gl-link>
- </li>
- <li v-if="error.firstReleaseVersion">
- <strong class="bold">{{ __('First seen') }}:</strong>
- <time-ago-tooltip :time="error.firstSeen" />
- <gl-link v-if="error.integrated" :href="firstCommitLink">
- {{ __('GitLab commit') }}: {{ error.firstReleaseVersion }}
- </gl-link>
- <gl-link v-else :href="firstReleaseLink" target="_blank">
- {{ __('Release') }}: {{ error.firstReleaseVersion }}
- </gl-link>
- </li>
- <li v-if="error.lastReleaseVersion">
- <strong class="bold">{{ __('Last seen') }}:</strong>
- <time-ago-tooltip :time="error.lastSeen" />
- <gl-link v-if="error.integrated" :href="lastCommitLink">
- {{ __('GitLab commit') }}: {{ error.lastReleaseVersion }}
- </gl-link>
- <gl-link v-else :href="lastReleaseLink" target="_blank">
- {{ __('Release') }}: {{ error.lastReleaseVersion }}
- </gl-link>
- </li>
- <li>
- <strong class="bold">{{ __('Events') }}:</strong>
- <span>{{ error.count }}</span>
- </li>
- <li>
- <strong class="bold">{{ __('Users') }}:</strong>
- <span>{{ error.userCount }}</span>
- </li>
- </ul>
+
+ <error-details-info :error="error" />
<div v-if="loadingStacktrace" class="py-3">
<gl-loading-icon size="lg" />
diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue
new file mode 100644
index 00000000000..f6f39f178fb
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue
@@ -0,0 +1,174 @@
+<script>
+import { GlLink, GlIcon, GlCard, GlTooltipDirective } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { trackClickErrorLinkToSentryOptions } from '../events_tracking';
+
+const CARD_CLASS = 'gl-mr-7 gl-w-15p gl-min-w-fit-content';
+const HEADER_CLASS =
+ 'gl-p-2 gl-font-weight-bold gl-display-flex gl-justify-content-center gl-align-items-center';
+const BODY_CLASS =
+ 'gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column gl-my-0 gl-p-4 gl-font-weight-bold gl-text-center gl-flex-grow-1 gl-font-lg';
+
+export default {
+ components: {
+ GlCard,
+ GlLink,
+ TimeAgoTooltip,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ props: {
+ error: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ firstReleaseLink() {
+ return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`;
+ },
+ lastReleaseLink() {
+ return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
+ },
+ firstCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
+ },
+ lastCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
+ },
+ shortFirstReleaseVersion() {
+ return this.error.firstReleaseVersion.substr(0, 10);
+ },
+ shortLastReleaseVersion() {
+ return this.error.lastReleaseVersion.substr(0, 10);
+ },
+ shortGitlabCommit() {
+ return this.error.gitlabCommit.substr(0, 10);
+ },
+ },
+ methods: {
+ trackClickErrorLinkToSentryOptions,
+ },
+ CARD_CLASS,
+ HEADER_CLASS,
+ BODY_CLASS,
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-if="error"
+ class="gl-display-flex gl-flex-wrap gl-justify-content-center gl-my-7 gl-row-gap-6"
+ >
+ <gl-card
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="error-count-card"
+ >
+ <template #header>
+ <span>{{ __('Events') }}</span>
+ </template>
+
+ <template #default>
+ <span>{{ error.count }}</span>
+ </template>
+ </gl-card>
+
+ <gl-card
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="user-count-card"
+ >
+ <template #header>
+ <span>{{ __('Users') }}</span>
+ </template>
+
+ <template #default>
+ <span>{{ error.userCount }}</span>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.firstReleaseVersion"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="first-release-card"
+ >
+ <template #header>
+ <gl-icon v-gl-tooltip :title="shortFirstReleaseVersion" name="commit" class="gl-mr-1" />
+ <span>{{ __('First seen') }}</span>
+ </template>
+
+ <template #default>
+ <gl-link v-if="error.integrated" :href="firstCommitLink" class="gl-font-lg">
+ <time-ago-tooltip :time="error.firstSeen" />
+ </gl-link>
+
+ <gl-link v-else :href="firstReleaseLink" target="_blank" class="gl-font-lg">
+ <time-ago-tooltip :time="error.firstSeen" />
+ </gl-link>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.lastReleaseVersion"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="last-release-card"
+ >
+ <template #header>
+ <gl-icon v-gl-tooltip :title="shortLastReleaseVersion" name="commit" class="gl-mr-1" />
+ {{ __('Last seen') }}
+ </template>
+
+ <template #default>
+ <gl-link v-if="error.integrated" :href="lastCommitLink" class="gl-font-lg">
+ <time-ago-tooltip :time="error.lastSeen" />
+ </gl-link>
+ <gl-link v-else :href="lastReleaseLink" target="_blank" class="gl-font-lg">
+ <time-ago-tooltip :time="error.lastSeen" />
+ </gl-link>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.gitlabCommit"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="gitlab-commit-card"
+ >
+ <template #header>
+ {{ __('GitLab commit') }}
+ </template>
+
+ <template #default>
+ <gl-link :href="error.gitlabCommitPath" class="gl-font-lg">
+ {{ shortGitlabCommit }}
+ </gl-link>
+ </template>
+ </gl-card>
+ </div>
+ <div v-if="!error.integrated" class="py-3">
+ <span class="gl-font-weight-bold">{{ __('Sentry event') }}:</span>
+ <gl-link
+ v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
+ :href="error.externalUrl"
+ target="_blank"
+ data-testid="external-url-link"
+ >
+ <span class="text-truncate">{{ error.externalUrl }}</span>
+ <gl-icon name="external-link" class="ml-1 flex-shrink-0" />
+ </gl-link>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 2a4bb88b6c2..6750f0f5411 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -23,7 +23,12 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { sanitizeUrl } from '~/lib/utils/url_utility';
-import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils';
+import {
+ trackErrorListViewsOptions,
+ trackErrorStatusUpdateOptions,
+ trackErrorStatusFilterOptions,
+ trackErrorSortedByField,
+} from '../events_tracking';
import { I18N_ERROR_TRACKING_LIST } from '../constants';
import ErrorTrackingActions from './error_tracking_actions.vue';
@@ -237,8 +242,15 @@ export default {
},
filterErrors(status, label) {
this.filterValue = label;
+ const { category, action } = trackErrorStatusFilterOptions(status);
+ Tracking.event(category, action);
return this.filterByStatus(status);
},
+ sortErrorsByField(field) {
+ const { category, action } = trackErrorSortedByField(field);
+ Tracking.event(category, action);
+ return this.sortByField(field);
+ },
updateErrosStatus({ errorId, status }) {
// eslint-disable-next-line promise/catch-or-return
this.updateStatus({
@@ -371,7 +383,7 @@ export default {
<gl-dropdown-item
v-for="(label, field) in $options.sortFields"
:key="field"
- @click="sortByField(field)"
+ @click="sortErrorsByField(field)"
>
<span class="d-flex">
<gl-icon
diff --git a/app/assets/javascripts/error_tracking/events_tracking.js b/app/assets/javascripts/error_tracking/events_tracking.js
new file mode 100644
index 00000000000..aaef274d0cd
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/events_tracking.js
@@ -0,0 +1,60 @@
+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,
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings
+ property: url,
+});
+
+/**
+ * Tracks snowplow event when user views error list
+ */
+export const trackErrorListViewsOptions = {
+ category,
+ action: 'view_errors_list',
+};
+
+/**
+ * Tracks snowplow event when user views error details
+ */
+export const trackErrorDetailsViewsOptions = {
+ category,
+ action: 'view_error_details',
+};
+
+/**
+ * Tracks snowplow event when error status is updated
+ */
+export const trackErrorStatusUpdateOptions = (status) => ({
+ category,
+ action: `update_${status}_status`,
+});
+
+/**
+ * Tracks snowplow event when error list is filter by status
+ */
+export const trackErrorStatusFilterOptions = (status) => ({
+ category,
+ action: `filter_${status}_status`,
+});
+
+/**
+ * Tracks snowplow event when error list is sorted by field
+ */
+export const trackErrorSortedByField = (field) => ({
+ category,
+ action: `sort_by_${field}`,
+});
+
+/**
+ * Tracks snowplow event when the Create Issue button is clicked
+ */
+export const trackCreateIssueFromError = {
+ category,
+ action: 'click_create_issue_from_error',
+};
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index 603f8611005..adbce7750fa 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import service from '../services';
diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js
index 1409399940a..89b9432c377 100644
--- a/app/assets/javascripts/error_tracking/store/details/actions.js
+++ b/app/assets/javascripts/error_tracking/store/details/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import service from '../../services';
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index f633711add3..84e4463ca21 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import Service from '../../services';
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
deleted file mode 100644
index aeed5450022..00000000000
--- a/app/assets/javascripts/error_tracking/utils.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/* eslint-disable @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',
- action: 'click_error_link_to_sentry',
- label: 'Error Link',
- property: url,
-});
-
-/**
- * Tracks snowplow event when user views error list
- */
-export const trackErrorListViewsOptions = {
- category: 'Error Tracking',
- action: 'view_errors_list',
-};
-
-/**
- * Tracks snowplow event when user views error details
- */
-export const trackErrorDetailsViewsOptions = {
- category: 'Error Tracking',
- action: 'view_error_details',
-};
-
-/**
- * Tracks snowplow event when error status is updated
- */
-export const trackErrorStatusUpdateOptions = (status) => ({
- category: 'Error Tracking',
- action: `update_${status}_status`,
-});
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index 4d6fe767f3a..368dd438f89 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
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/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 366ee6bb05b..9fb5d9f0943 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -84,11 +84,9 @@ export default {
cancelActionProps() {
return {
text: this.$options.translations.cancelActionLabel,
- attributes: [
- {
- category: 'secondary',
- },
- ],
+ attributes: {
+ category: 'secondary',
+ },
};
},
canRegenerateInstanceId() {
@@ -98,14 +96,12 @@ export default {
return this.canUserRotateToken
? {
text: this.$options.translations.instanceIdRegenerateActionLabel,
- attributes: [
- {
- category: 'secondary',
- disabled: !this.canRegenerateInstanceId,
- loading: this.isRotating,
- variant: 'danger',
- },
- ],
+ attributes: {
+ category: 'secondary',
+ disabled: !this.canRegenerateInstanceId,
+ loading: this.isRotating,
+ variant: 'danger',
+ },
}
: null;
},
diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
index ce5f7915dbf..57727cb945e 100644
--- a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
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..37a0c679287 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTooltipDirective, GlIcon, GlModal, GlToggle } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { labelForStrategy } from '../utils';
@@ -15,6 +15,7 @@ export default {
components: {
GlBadge,
GlButton,
+ GlIcon,
GlModal,
GlToggle,
StrategyLabel,
@@ -108,7 +109,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') }}
@@ -116,7 +117,12 @@ export default {
</div>
<template v-for="featureFlag in featureFlags">
- <div :key="featureFlag.id" class="gl-responsive-table-row" role="row">
+ <div
+ :key="featureFlag.id"
+ :data-testid="featureFlag.id"
+ class="gl-responsive-table-row"
+ role="row"
+ >
<div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
<div class="table-mobile-content js-feature-flag-id">
@@ -148,16 +154,21 @@ 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">
<div class="feature-flag-name text-monospace text-truncate">
{{ featureFlag.name }}
</div>
- </div>
- <div class="feature-flag-description text-secondary text-truncate">
- {{ featureFlag.description }}
+ <div class="feature-flag-description">
+ <gl-icon
+ v-if="featureFlag.description"
+ v-gl-tooltip.hover="featureFlag.description"
+ class="gl-ml-3 gl-mr-3"
+ name="information-o"
+ />
+ </div>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
index 89400bc4742..420c34a88f1 100644
--- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -8,7 +8,7 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
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/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
index f697f203cf5..1993ec7abf2 100644
--- a/app/assets/javascripts/feature_flags/constants.js
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -1,4 +1,3 @@
-import { property } from 'lodash';
import { s__ } from '~/locale';
export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
@@ -9,15 +8,8 @@ export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
export const PERCENT_ROLLOUT_GROUP_ID = 'default';
-export const DEFAULT_PERCENT_ROLLOUT = '100';
-
export const ALL_ENVIRONMENTS_NAME = '*';
-export const INTERNAL_ID_PREFIX = 'internal_';
-
-export const fetchPercentageParams = property(['parameters', 'percentage']);
-export const fetchUserIdParams = property(['parameters', 'userIds']);
-
export const NEW_VERSION_FLAG = 'new_version_flag';
export const LEGACY_FLAG = 'legacy_flag';
diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js
index 97c22781ac5..585bb1be0c4 100644
--- a/app/assets/javascripts/feature_flags/store/edit/actions.js
+++ b/app/assets/javascripts/feature_flags/store/edit/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
index a9542a9667e..e2218c1ba2e 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 397ba879866..4d3647cdf5c 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -72,6 +72,40 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
}
+ const approvedToken = {
+ token: {
+ formattedKey: __('Approved'),
+ key: 'approved',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'approval',
+ tag: __('Yes or No'),
+ lowercaseValueOnSubmit: true,
+ capitalizeTokenValue: true,
+ hideNotEqual: true,
+ },
+ conditions: [
+ {
+ url: 'approved=yes',
+ tokenKey: 'approved',
+ value: __('Yes'),
+ operator: '=',
+ },
+ {
+ url: 'approved=no',
+ tokenKey: 'approved',
+ value: __('No'),
+ operator: '=',
+ },
+ ],
+ };
+
+ if (gon.features.mrApprovedFilter) {
+ IssuableTokenKeys.tokenKeys.splice(3, 0, approvedToken.token);
+ IssuableTokenKeys.conditions.push(...approvedToken.conditions);
+ }
+
const approvedBy = {
token: {
formattedKey: TOKEN_TITLE_APPROVED_BY,
@@ -117,8 +151,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
],
};
- const tokenPosition = 3;
- IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
+ const tokenPosition = gon.features.mrApprovedFilter ? 4 : 3;
+ IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, approvedBy.token);
IssuableTokenKeys.tokenKeysWithAlternative.splice(
tokenPosition,
0,
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 1f8baa470d8..892e9130fe8 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -138,6 +138,11 @@ export default class AvailableDropdownMappings {
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
+ approved: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-approved'),
+ },
[TOKEN_TYPE_CONFIDENTIAL]: {
reference: null,
gl: DropdownNonUser,
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 23591fc0667..e1330433362 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import AjaxFilter from './droplab/plugins/ajax_filter';
import DropdownUtils from './dropdown_utils';
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index 8c50c1860ec..bb33c3ad935 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import Ajax from './droplab/plugins/ajax';
import Filter from './droplab/plugins/filter';
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index ab95986dc62..3046ad42e24 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import Ajax from './droplab/plugins/ajax';
import Filter from './droplab/plugins/filter';
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 16c70fdd069..684375177bb 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,7 +1,14 @@
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 '~/flash';
+import { createAlert } from '~/alert';
+import {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_MERGED,
+ STATUS_OPEN,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -42,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;
@@ -82,7 +89,7 @@ export default class FilteredSearchManager {
);
const fullPath = this.searchHistoryDropdownElement
? this.searchHistoryDropdownElement.dataset.fullPath
- : 'project';
+ : WORKSPACE_PROJECT;
const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -742,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/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 33fda7533e4..409f6a4a9dc 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -4,7 +4,7 @@ import * as Emoji from '~/emoji';
import FilteredSearchContainer from '~/filtered_search/container';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
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/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
index bf71f682048..f19e047061f 100644
--- a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
+++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
@@ -5,7 +5,7 @@ import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml?raw';
import { logError } from '~/lib/logger';
import { s__ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import pagesMarkOnboardingComplete from '../queries/mark_onboarding_complete.graphql';
export const i18n = {
@@ -57,7 +57,7 @@ export default {
async onDone() {
this.loading = true;
await this.updateOnboardingState();
- redirectTo(this.redirectToWhenDone);
+ redirectTo(this.redirectToWhenDone); // eslint-disable-line import/no-deprecated
},
},
};
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/google_cloud/aiml/panel.vue b/app/assets/javascripts/google_cloud/aiml/panel.vue
new file mode 100644
index 00000000000..f591c47ac40
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/aiml/panel.vue
@@ -0,0 +1,63 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import ServiceTable from './service_table.vue';
+
+export default {
+ components: {
+ IncubationBanner,
+ GoogleCloudMenu,
+ ServiceTable,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ aimlUrl: {
+ type: String,
+ required: true,
+ },
+ visionAiUrl: {
+ type: String,
+ required: true,
+ },
+ translationAiUrl: {
+ type: String,
+ required: true,
+ },
+ languageAiUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="aiml"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ :aiml-url="aimlUrl"
+ />
+
+ <service-table
+ :language-ai-url="languageAiUrl"
+ :translation-ai-url="translationAiUrl"
+ :vision-ai-url="visionAiUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/aiml/service_table.vue b/app/assets/javascripts/google_cloud/aiml/service_table.vue
new file mode 100644
index 00000000000..b53baaa5c6f
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/aiml/service_table.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlButton, GlTable } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const KEY_VISION_AI = 'key-vision-ai';
+const KEY_NATURAL_LANGUAGE_AI = 'key-natural-language-ai';
+const KEY_TRANSLATION_AI = 'key-translation-ai';
+
+const i18n = {
+ visionAi: s__('CloudSeed|Vision AI'),
+ visionAiDescription: s__(
+ 'CloudSeed|Derive insights from your images in the cloud or at the edge',
+ ),
+ naturalLanguageAi: s__('CloudSeed|Language AI'),
+ naturalLanguageAiDescription: s__(
+ 'CloudSeed|Derive insights from unstructured text using Google machine learning',
+ ),
+ translationAi: s__('CloudSeed|Translation AI'),
+ translationAiDescription: s__(
+ 'CloudSeed|Make your content and apps multilingual with fast, dynamic machine translation',
+ ),
+ aiml: s__('CloudSeed|AI / ML'),
+ aimlDescription: s__(
+ "CloudSeed|Google Cloud's AI tools are armed with the best of Google's research and technology to help developers focus exclusively on solving problems that matter",
+ ),
+ configureViaMergeRequest: s__('CloudSeed|Configure via Merge Request'),
+ service: s__('CloudSeed|Service'),
+ description: s__('CloudSeed|Description'),
+};
+
+export default {
+ components: { GlButton, GlTable },
+ props: {
+ visionAiUrl: {
+ type: String,
+ required: true,
+ },
+ languageAiUrl: {
+ type: String,
+ required: true,
+ },
+ translationAiUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ actionUrl(key) {
+ switch (key) {
+ case KEY_VISION_AI:
+ return this.visionAiUrl;
+ case KEY_NATURAL_LANGUAGE_AI:
+ return this.languageAiUrl;
+ case KEY_TRANSLATION_AI:
+ return this.translationAiUrl;
+ default:
+ return '#';
+ }
+ },
+ },
+ fields: [
+ { key: 'title', label: i18n.service },
+ { key: 'description', label: i18n.description },
+ { key: 'action', label: '' },
+ ],
+ items: [
+ {
+ title: i18n.naturalLanguageAi,
+ description: i18n.naturalLanguageAiDescription,
+ action: {
+ key: KEY_NATURAL_LANGUAGE_AI,
+ testId: 'button-natural-language-ai',
+ title: i18n.configureViaMergeRequest,
+ },
+ },
+ {
+ title: i18n.translationAi,
+ description: i18n.translationAiDescription,
+ action: {
+ key: KEY_TRANSLATION_AI,
+ testId: 'button-translation-ai',
+ title: i18n.configureViaMergeRequest,
+ disabled: true,
+ },
+ },
+ {
+ title: i18n.visionAi,
+ description: i18n.visionAiDescription,
+ action: {
+ key: KEY_VISION_AI,
+ testId: 'button-vision-ai',
+ title: i18n.configureViaMergeRequest,
+ },
+ },
+ ],
+ i18n,
+};
+</script>
+<template>
+ <div class="gl-mx-3">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.aiml }}</h2>
+ <p>{{ $options.i18n.aimlDescription }}</p>
+ <gl-table :fields="$options.fields" :items="$options.items">
+ <template #cell(action)="{ value }">
+ <gl-button
+ :disabled="value.disabled"
+ :href="actionUrl(value.key)"
+ :data-testid="value.testId"
+ >
+ {{ value.title }}
+ </gl-button>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
index d6b7c702b54..69b9c6133f1 100644
--- a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
+++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
@@ -4,11 +4,13 @@ import { s__ } from '~/locale';
const CONFIGURATION_KEY = 'configuration';
const DEPLOYMENTS_KEY = 'deployments';
const DATABASES_KEY = 'databases';
+const AIML_KEY = 'aiml';
const i18n = {
configuration: { title: s__('CloudSeed|Configuration') },
deployments: { title: s__('CloudSeed|Deployments') },
databases: { title: s__('CloudSeed|Databases') },
+ aiml: { title: s__('CloudSeed|AI / ML') },
};
export default {
@@ -29,6 +31,11 @@ export default {
type: String,
required: true,
},
+ aimlUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isConfigurationActive() {
@@ -40,6 +47,9 @@ export default {
isDatabasesActive() {
return this.active === DATABASES_KEY;
},
+ isAimlActive() {
+ return this.active === AIML_KEY;
+ },
},
i18n,
};
@@ -80,6 +90,17 @@ export default {
{{ $options.i18n.databases.title }}
</a>
</li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="aimlLink"
+ role="tab"
+ :href="aimlUrl"
+ class="nav-link gl-tab-nav-item hidden"
+ :class="{ 'gl-tab-nav-item-active': isAimlActive }"
+ >
+ {{ $options.i18n.aiml.title }}
+ </a>
+ </li>
</ul>
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 98c9db1fc9a..0a1a7a74d21 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -123,17 +123,6 @@ export const trackSaasTrialSubmit = () => {
pushEvent('saasTrialSubmit');
};
-export const trackSaasTrialSkip = () => {
- if (!isSupported()) {
- return;
- }
-
- const skipLink = document.querySelector('.js-skip-trial');
- skipLink.addEventListener('click', () => {
- pushEvent('saasTrialSkip');
- });
-};
-
export const trackSaasTrialGroup = () => {
if (!isSupported()) {
return;
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index ad339155a59..b45c98b46f6 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js
index 208a92c97c7..9ade29dae69 100644
--- a/app/assets/javascripts/grafana_integration/index.js
+++ b/app/assets/javascripts/grafana_integration/index.js
@@ -4,6 +4,9 @@ import store from './store';
export default () => {
const el = document.querySelector('.js-grafana-integration');
+
+ if (!el) return false;
+
return new Vue({
el,
store: store(el.dataset),
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js
index db2fd3cc256..76e21f09719 100644
--- a/app/assets/javascripts/grafana_integration/store/actions.js
+++ b/app/assets/javascripts/grafana_integration/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -29,7 +29,7 @@ export const updateGrafanaIntegration = ({ state, dispatch }) =>
export const receiveGrafanaIntegrationUpdateSuccess = () => {
/**
* The operations_controller currently handles successful requests
- * by creating a flash banner messsage to notify the user.
+ * by creating an alert banner message to notify the user.
*/
refreshCurrentPage();
};
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 3c4ca4c197e..5ba46697496 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -22,7 +22,10 @@ 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';
export const TYPENAME_WORK_ITEM = 'WorkItem';
+export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply';
+export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace';
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
index 794fe0a6151..a15889613f5 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
@@ -5,7 +5,6 @@ fragment AlertDetailItem on AlertManagementAlert {
...AlertListItem
createdAt
monitoringTool
- metricsDashboardUrl
service
description
updatedAt
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 316bc746051..d0b0a485fe6 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -6,8 +6,9 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap
import createDefaultClient from '~/lib/graphql';
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 workItemQuery from '~/work_items/graphql/work_item.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) {
@@ -146,7 +182,7 @@ export const config = {
export const resolvers = {
Mutation: {
addHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const queryArgs = { query: workItemQuery, variables: { id } };
const sourceData = cache.readQuery(queryArgs);
const data = produce(sourceData, (draftState) => {
@@ -156,7 +192,7 @@ export const resolvers = {
cache.writeQuery({ ...queryArgs, data });
},
removeHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const queryArgs = { query: workItemQuery, variables: { id } };
const sourceData = cache.readQuery(queryArgs);
const data = produce(sourceData, (draftState) => {
@@ -174,6 +210,29 @@ export const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ setActiveBoardItem(_, { boardItem }, { cache }) {
+ cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: { activeBoardItem: boardItem },
+ });
+ return boardItem;
+ },
+ clientToggleListCollapsed(_, { list = {}, collapsed = false }) {
+ return {
+ list: {
+ ...list,
+ collapsed,
+ },
+ };
+ },
+ clientToggleEpicListCollapsed(_, { list = {}, collapsed = false }) {
+ return {
+ list: {
+ ...list,
+ collapsed,
+ },
+ };
+ },
},
};
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 4a5536986bd..f35886716ee 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"
@@ -39,6 +40,10 @@
"BoardEpic",
"Epic"
],
+ "ExternalAuditEventDestinationInterface": [
+ "ExternalAuditEventDestination",
+ "InstanceExternalAuditEventDestination"
+ ],
"Issuable": [
"Epic",
"Issue",
@@ -84,6 +89,22 @@
"NugetMetadata",
"PypiMetadata"
],
+ "Registrable": [
+ "CiSecureFileRegistry",
+ "ContainerRepositoryRegistry",
+ "DependencyProxyBlobRegistry",
+ "DependencyProxyManifestRegistry",
+ "JobArtifactRegistry",
+ "LfsObjectRegistry",
+ "MergeRequestDiffRegistry",
+ "PackageFileRegistry",
+ "PagesDeploymentRegistry",
+ "PipelineArtifactRegistry",
+ "ProjectWikiRepositoryRegistry",
+ "SnippetRepositoryRegistry",
+ "TerraformStateVersionRegistry",
+ "UploadRegistry"
+ ],
"ResolvableInterface": [
"Discussion",
"Note"
@@ -145,6 +166,8 @@
],
"WorkItemWidget": [
"WorkItemWidgetAssignees",
+ "WorkItemWidgetAwardEmoji",
+ "WorkItemWidgetCurrentUserTodos",
"WorkItemWidgetDescription",
"WorkItemWidgetHealthStatus",
"WorkItemWidgetHierarchy",
@@ -152,6 +175,7 @@
"WorkItemWidgetLabels",
"WorkItemWidgetMilestone",
"WorkItemWidgetNotes",
+ "WorkItemWidgetNotifications",
"WorkItemWidgetProgress",
"WorkItemWidgetRequirementLegacy",
"WorkItemWidgetStartAndDueDate",
@@ -159,4 +183,4 @@
"WorkItemWidgetTestReports",
"WorkItemWidgetWeight"
]
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
deleted file mode 100644
index 07398867544..00000000000
--- a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-#import "../fragments/user.fragment.graphql"
-
-query getUsersByUsernames($usernames: [String!]) {
- users(usernames: $usernames) {
- nodes {
- ...User
- }
- }
-}
diff --git a/app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql b/app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql
new file mode 100644
index 00000000000..a6ef5935162
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql
@@ -0,0 +1,9 @@
+query mergeRequestId($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ id
+ mergeRequest(iid: $iid) {
+ id
+ preparedAt
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql
new file mode 100644
index 00000000000..ba658f56ebd
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql
@@ -0,0 +1,8 @@
+subscription mergeRequestPrepared($issuableId: IssuableID!) {
+ mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ preparedAt
+ }
+ }
+}
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/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 806e89d6e9f..6a64e8a2fa8 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -1,4 +1,5 @@
import { isArray } from 'lodash';
+import Visibility from 'visibilityjs';
/**
* Ids generated by GraphQL endpoints are usually in the format
@@ -15,7 +16,10 @@ export const isGid = (id) => {
return false;
};
-const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10);
+const parseGid = (gid) => {
+ const [type, id] = `${gid}`.replace(/gid:\/\/gitlab\//g, '').split('/');
+ return { type, id };
+};
/**
* Ids generated by GraphQL endpoints are usually in the format
@@ -26,8 +30,24 @@ const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, '')
* @returns {Number}
*/
export const getIdFromGraphQLId = (gid = '') => {
- const parsedGid = parseGid(gid);
- return Number.isInteger(parsedGid) ? parsedGid : null;
+ const rawId = isGid(gid) ? parseGid(gid).id : gid;
+ const id = parseInt(rawId, 10);
+ return Number.isInteger(id) ? id : null;
+};
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Environments/123. This method extracts Type string
+ * from the Id path
+ *
+ * @param {String} gid GraphQL global ID
+ * @returns {String}
+ */
+export const getTypeFromGraphQLId = (gid = '') => {
+ if (!isGid(gid)) return null;
+
+ const { type } = parseGid(gid);
+ return type || null;
};
export const MutationOperationMode = {
@@ -116,3 +136,29 @@ export const convertNodeIdsFromGraphQLIds = (nodes) => {
export const getNodesOrDefault = (queryData, nodesField = 'nodes') => {
return queryData?.[nodesField] ?? [];
};
+
+export const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = (query) => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
+export const etagQueryHeaders = (featureCorrelation, etagResource = '') => {
+ return {
+ fetchOptions: {
+ method: 'GET',
+ },
+ headers: {
+ 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': featureCorrelation,
+ 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ };
+};
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index cc70d832edc..3b64606b141 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index 8011090f1cb..a4ec48ffd2f 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,7 +1,16 @@
<script>
import { GlToggle, GlAlert } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import { updateGroup } from '~/api/groups_api';
-import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import {
+ I18N_CONFIRM_MESSAGE,
+ I18N_CONFIRM_OK,
+ I18N_CONFIRM_CANCEL,
+ I18N_CONFIRM_TITLE,
+ I18N_UPDATE_ERROR_MESSAGE,
+ I18N_REFRESH_MESSAGE,
+} from '../constants';
export default {
components: {
@@ -10,6 +19,8 @@ export default {
},
inject: [
'groupId',
+ 'groupName',
+ 'groupIsEmpty',
'sharedRunnersSetting',
'parentSharedRunnersSetting',
'runnerEnabledValue',
@@ -39,9 +50,28 @@ export default {
},
},
methods: {
- onSharedRunnersToggle(value) {
- const newSetting = value ? this.runnerEnabledValue : this.runnerDisabledValue;
- this.updateSetting(newSetting);
+ async onSharedRunnersToggle(enabled) {
+ if (enabled) {
+ this.updateSetting(this.runnerEnabledValue);
+ return;
+ }
+ if (this.groupIsEmpty) {
+ this.updateSetting(this.runnerDisabledValue);
+ return;
+ }
+
+ // Confirm when disabling for a group with subgroups or projects
+ const confirmDisabled = await confirmAction(I18N_CONFIRM_MESSAGE, {
+ title: sprintf(I18N_CONFIRM_TITLE, { groupName: this.groupName }),
+ cancelBtnText: I18N_CONFIRM_CANCEL,
+ primaryBtnText: I18N_CONFIRM_OK,
+ primaryBtnVariant: 'danger',
+ size: 'md',
+ });
+
+ if (confirmDisabled) {
+ this.updateSetting(this.runnerDisabledValue);
+ }
},
onOverrideToggle(value) {
const newSetting = value ? this.runnerAllowOverrideValue : this.runnerDisabledValue;
diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js
index 1b44161903d..d4ac7d94bf4 100644
--- a/app/assets/javascripts/group_settings/constants.js
+++ b/app/assets/javascripts/group_settings/constants.js
@@ -1,4 +1,13 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const I18N_CONFIRM_MESSAGE = s__(
+ 'Runners|Shared runners will be disabled for all projects and subgroups in this group. If you proceed, you must manually re-enable shared runners in the settings of each project and subgroup.',
+);
+export const I18N_CONFIRM_OK = s__('Runners|Yes, disable shared runners');
+export const I18N_CONFIRM_CANCEL = s__('Runners|No, keep shared runners enabled');
+export const I18N_CONFIRM_TITLE = s__(
+ 'Runners|Are you sure you want to disable shared runners for %{groupName}?',
+);
export const I18N_UPDATE_ERROR_MESSAGE = __('An error occurred while updating configuration.');
export const I18N_REFRESH_MESSAGE = __('Refresh the page and try again.');
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index e7e104d61b3..0767330cd54 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import UpdateSharedRunnersForm from './components/shared_runners_form.vue';
export default (containerId = 'update-shared-runners-form') => {
@@ -6,6 +7,8 @@ export default (containerId = 'update-shared-runners-form') => {
const {
groupId,
+ groupName,
+ groupIsEmpty,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
@@ -17,6 +20,8 @@ export default (containerId = 'update-shared-runners-form') => {
el: containerEl,
provide: {
groupId,
+ groupName,
+ groupIsEmpty: parseBoolean(groupIsEmpty),
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 148bf0a98ee..82eddf5603f 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -59,7 +59,7 @@ export default {
primaryProps() {
return {
text: __('Leave group'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
};
},
cancelProps() {
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/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 5f997ecc7ba..1f9fc68a612 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -18,7 +18,7 @@ import { debounce } from 'lodash';
import { s__, __ } from '~/locale';
import { getGroupPathAvailability } from '~/rest_api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { slugify } from '~/lib/utils/text_utility';
import axios from '~/lib/utils/axios_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 7afea815197..a0a775e2916 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -45,10 +45,7 @@ export default {
});
},
openModal() {
- eventHub.$emit('openModal', {
- source: this.$options.openModalSource,
- });
- this.track(this.$options.buttonClickEvent);
+ eventHub.$emit('openModal', { source: this.$options.openModalSource });
},
},
i18n: {
@@ -59,7 +56,6 @@ export default {
button_text: s__('InviteMembersBanner|Invite your colleagues'),
},
displayEvent: 'invite_members_banner_displayed',
- buttonClickEvent: 'invite_members_banner_button_clicked',
openModalSource: 'invite_members_banner',
dismissEvent: 'invite_members_banner_dismissed',
};
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 6fb12cd6270..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;
@@ -12,7 +7,6 @@ export const ACTIVE_TAB_SHARED = 'shared';
export const ACTIVE_TAB_ARCHIVED = 'archived';
export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder';
-export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form';
export const CONTENT_LIST_CLASS = '.groups-list';
export const COMMON_STR = {
@@ -31,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/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index db8e424e166..8bc5f28ebfb 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__ } from '~/locale';
import { getSubGroups } from '../api/access_dropdown_api';
import { LEVEL_TYPES } from '../constants';
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/groups/store/utils.js b/app/assets/javascripts/groups/store/utils.js
index 371b3aa9d52..d4b583483bd 100644
--- a/app/assets/javascripts/groups/store/utils.js
+++ b/app/assets/javascripts/groups/store/utils.js
@@ -1,3 +1,5 @@
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
+
export const getGroupItemMicrodata = ({ type }) => {
const defaultMicrodata = {
itemscope: true,
@@ -9,14 +11,14 @@ export const getGroupItemMicrodata = ({ type }) => {
};
switch (type) {
- case 'group':
+ case WORKSPACE_GROUP:
return {
...defaultMicrodata,
itemtype: 'https://schema.org/Organization',
itemprop: 'subOrganization',
imageItemprop: 'logo',
};
- case 'project':
+ case WORKSPACE_PROJECT:
return {
...defaultMicrodata,
itemtype: 'https://schema.org/SoftwareSourceCode',
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 6c9354b663f..25a84d17379 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -28,7 +28,7 @@ export default function initTodoToggle() {
});
}
-function initStatusTriggers() {
+export function initStatusTriggers() {
const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger');
if (setStatusModalTriggerEl) {
@@ -37,7 +37,7 @@ 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 ace0d77c431..422ec27346e 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,
@@ -12,10 +11,20 @@ import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import Tracking from '~/tracking';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import {
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ 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,
@@ -26,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';
@@ -34,28 +44,16 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
- searchGitlab: s__('GlobalSearch|Search GitLab'),
- searchInputDescribeByNoDropdown: s__(
- 'GlobalSearch|Type and press the enter key to submit search.',
- ),
- searchInputDescribeByWithDropdown: s__(
- 'GlobalSearch|Type for new suggestions to appear below.',
- ),
- searchDescribedByDefault: s__(
- 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
- ),
- searchDescribedByUpdated: s__(
- 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
- ),
- searchResultsLoading: s__('GlobalSearch|Search results are loading'),
- searchResultsScope: s__('GlobalSearch|in %{scope}'),
- kbdHelp: sprintf(
- s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
- { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
- false,
- ),
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
},
- directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
@@ -67,7 +65,6 @@ export default {
},
data() {
return {
- showDropdown: false,
isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
@@ -93,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;
@@ -110,12 +107,11 @@ export default {
}
return FIRST_DROPDOWN_INDEX;
},
-
searchInputDescribeBy() {
if (this.isLoggedIn) {
- return this.$options.i18n.searchInputDescribeByWithDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
}
- return this.$options.i18n.searchInputDescribeByNoDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
},
dropdownResultsDescription() {
if (!this.showSearchDropdown) {
@@ -123,14 +119,14 @@ export default {
}
if (this.showDefaultItems) {
- return sprintf(this.$options.i18n.searchDescribedByDefault, {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
count: this.searchOptions.length,
});
}
return this.loading
- ? this.$options.i18n.searchResultsLoading
- : sprintf(this.$options.i18n.searchDescribedByUpdated, {
+ ? this.$options.i18n.SEARCH_RESULTS_LOADING
+ : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
count: this.searchOptions.length,
});
},
@@ -154,7 +150,7 @@ export default {
return this.searchBarItem?.icon;
},
scopeTokenTitle() {
- return sprintf(this.$options.i18n.searchResultsScope, {
+ return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
scope: this.infieldHelpContent,
});
},
@@ -162,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');
@@ -192,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) {
@@ -228,9 +213,8 @@ export default {
<template>
<form
- v-outside="closeDropdown"
role="search"
- :aria-label="$options.i18n.searchGitlab"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
class="header-search gl-relative gl-rounded-base gl-w-full"
:class="searchBarClasses"
data-testid="header-search-form"
@@ -241,17 +225,16 @@ export default {
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
- data-qa-selector="search_term_field"
+ data-qa-selector="global_search_input"
autocomplete="off"
- :placeholder="$options.i18n.searchGitlab"
+ :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"
@@ -267,7 +250,7 @@ export default {
:size="16"
/>{{
getTruncatedScope(
- sprintf($options.i18n.searchResultsScope, {
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
scope: infieldHelpContent,
}),
)
@@ -277,7 +260,7 @@ export default {
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.kbdHelp"
+ :title="$options.i18n.KBD_HELP"
>/</kbd
>
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
@@ -303,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/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index c85fb4f4158..1838214def6 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -9,27 +9,23 @@ import {
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { s__ } from '~/locale';
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,
- LARGE_AVATAR_PX,
- SMALL_AVATAR_PX,
-} from '../constants';
+ AUTOCOMPLETE_ERROR_MESSAGE,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
i18n: {
- autocompleteErrorMessage: s__(
- 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
- ),
+ AUTOCOMPLETE_ERROR_MESSAGE,
},
components: {
GlDropdownItem,
@@ -165,7 +161,7 @@ export default {
:dismissible="false"
variant="danger"
>
- {{ $options.i18n.autocompleteErrorMessage }}
+ {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }}
</gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
index 04deaba7b0f..f0d398297e9 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -1,12 +1,12 @@
<script>
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __ } from '~/locale';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
export default {
name: 'HeaderSearchDefaultItems',
i18n: {
- allGitLab: __('All GitLab'),
+ ALL_GITLAB,
},
components: {
GlDropdownSectionHeader,
@@ -26,7 +26,7 @@ export default {
return (
this.searchContext?.project?.name ||
this.searchContext?.group?.name ||
- this.$options.i18n.allGitLab
+ this.$options.i18n.ALL_GITLAB
);
},
},
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index f5be1bcb786..1ef88492b23 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -3,10 +3,14 @@ import { GlDropdownItem, GlIcon, GlToken } 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,
+ },
components: {
GlDropdownItem,
GlIcon,
@@ -28,7 +32,7 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
- return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
+ return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
search: this.search,
description: option.description || option.icon,
scope: option.scope || '',
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 65e113e5084..47aeb2f9caa 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -1,45 +1,9 @@
-import { s__ } from '~/locale';
-
-export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
-
-export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
-
-export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
-
-export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
-
-export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-
-export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
-
-export const MSG_IN_GROUP = s__('GlobalSearch|group');
-
-export const MSG_IN_PROJECT = s__('GlobalSearch|project');
-
export const ICON_PROJECT = 'project';
export const ICON_GROUP = 'group';
export const ICON_SUBGROUP = 'subgroup';
-export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
-
-export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
-
-export const USERS_CATEGORY = s__('GlobalSearch|Users');
-
-export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
-
-export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
-
-export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
-
-export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
-
-export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
-
-export const HELP_CATEGORY = s__('GlobalSearch|Help');
-
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
@@ -64,18 +28,8 @@ export const IS_SEARCHING = 'is-searching';
export const IS_FOCUSED = 'is-focused';
export const IS_NOT_FOCUSED = 'is-not-focused';
-export const DROPDOWN_ORDER = [
- MERGE_REQUEST_CATEGORY,
- ISSUES_CATEGORY,
- RECENT_EPICS_CATEGORY,
- GROUPS_CATEGORY,
- PROJECTS_CATEGORY,
- USERS_CATEGORY,
- IN_THIS_PROJECT_CATEGORY,
- SETTINGS_CATEGORY,
- HELP_CATEGORY,
-];
-
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/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 3da9d2cd961..f86463b94d1 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -7,14 +7,16 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- ICON_GROUP,
- ICON_SUBGROUP,
- ICON_PROJECT,
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
- SEARCH_SHORTCUTS_MIN_CHARACTERS,
DROPDOWN_ORDER,
+} from '~/vue_shared/global_search/constants';
+import {
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
@@ -36,6 +38,10 @@ export const searchQuery = (state) => {
};
export const scopedIssuesPath = (state) => {
+ if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) {
+ return false;
+ }
+
return (
state.searchContext?.project_metadata?.issues_path ||
state.searchContext?.group_metadata?.issues_path ||
@@ -54,7 +60,7 @@ export const scopedMRPath = (state) => {
export const defaultSearchOptions = (state, getters) => {
const userName = gon.current_username;
- return [
+ const issues = [
{
html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
@@ -65,6 +71,9 @@ export const defaultSearchOptions = (state, getters) => {
title: MSG_ISSUES_IVE_CREATED,
url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
+ ];
+
+ const mergeRequests = [
{
html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
@@ -81,6 +90,7 @@ export const defaultSearchOptions = (state, getters) => {
url: `${getters.scopedMRPath}/?author_username=${userName}`,
},
];
+ return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
};
export const projectUrl = (state) => {
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/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 838debf1ceb..6bbad88715f 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -82,7 +82,7 @@ export default {
eventHub.$on('skip-beforeunload', this.handleSkipBeforeUnload);
if (this.themeName)
- document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
+ document.querySelector('.navbar-gitlab')?.classList.add(`theme-${this.themeName}`);
},
destroyed() {
eventHub.$off('skip-beforeunload', this.handleSkipBeforeUnload);
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/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index dbfaeba9708..4d728bd35d4 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal, GlButton } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
@@ -50,13 +50,13 @@ export default {
actionPrimary() {
return {
text: this.buttonLabel,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
};
},
actionCancel() {
return {
text: i18n.cancelButtonText,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
};
},
isCreatingNewFile() {
diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
index 194deb2ece0..25e1698e3f4 100644
--- a/app/assets/javascripts/ide/components/pipelines/empty_state.vue
+++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
@@ -28,6 +28,7 @@ export default {
<gl-empty-state
:title="$options.i18n.title"
:svg-path="pipelinesEmptyStateSvgPath"
+ :svg-height="150"
:description="$options.i18n.description"
:primary-button-text="$options.i18n.primaryButtonText"
:primary-button-link="ciHelpPagePath"
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b95f8bb5acb..9e29cd94a20 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -12,7 +12,7 @@ import {
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
@@ -27,6 +27,8 @@ import { performanceMarkAndMeasure } from '~/performance/utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
+
import {
leftSidebarViews,
viewerTypes,
@@ -66,7 +68,7 @@ export default {
images: {},
rules: {},
globalEditor: null,
- modelManager: new ModelManager(),
+ modelManager: markRaw(new ModelManager()),
isEditorLoading: true,
unwatchCiYaml: null,
SELivepreviewExtension: null,
@@ -212,7 +214,7 @@ export default {
},
mounted() {
if (!this.globalEditor) {
- this.globalEditor = new SourceEditor();
+ this.globalEditor = markRaw(new SourceEditor());
}
this.initEditor();
@@ -284,14 +286,16 @@ export default {
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
- this.editor = this.globalEditor[method]({
- el: this.$refs.editor,
- blobPath: this.file.path,
- blobGlobalId: this.file.key,
- blobContent: this.content || this.file.content,
- ...instanceOptions,
- ...this.editorOptions,
- });
+ this.editor = markRaw(
+ this.globalEditor[method]({
+ el: this.$refs.editor,
+ blobPath: this.file.path,
+ blobGlobalId: this.file.key,
+ blobContent: this.content || this.file.content,
+ ...instanceOptions,
+ ...this.editorOptions,
+ }),
+ );
this.editor.use([
{
definition: SourceEditorExtension,
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 64ec2cc67c7..0fe909fcce8 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -91,6 +91,7 @@ export default {
:disabled="tab.pending"
type="button"
class="multi-file-tab-close"
+ data-testid="close-button"
@click.stop.prevent="closeFile(tab)"
>
<gl-icon v-if="!showChangedIcon" :size="12" name="close" />
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/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/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
index dbb68b7facd..e131fb669ea 100644
--- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
+++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
@@ -1,17 +1,15 @@
import { cleanEndingSeparator, joinPaths } from '~/lib/utils/url_utility';
-const getBaseUrl = () => {
- const path = joinPaths(
- '/',
- window.gon.relative_url_root || '',
- process.env.GITLAB_WEB_IDE_PUBLIC_PATH,
- );
+const getGitLabUrl = (gitlabPath = '') => {
+ const path = joinPaths('/', window.gon.relative_url_root || '', gitlabPath);
const baseUrlObj = new URL(path, window.location.origin);
return cleanEndingSeparator(baseUrlObj.href);
};
export const getBaseConfig = () => ({
- baseUrl: getBaseUrl(),
- gitlabUrl: window.gon.gitlab_url,
+ // baseUrl - The URL which hosts the Web IDE static web assets
+ baseUrl: getGitLabUrl(process.env.GITLAB_WEB_IDE_PUBLIC_PATH),
+ // baseUrl - The URL for the GitLab instance
+ gitlabUrl: getGitLabUrl(''),
});
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.js b/app/assets/javascripts/ide/stores/actions.js
index b7445d3ad0a..0106eeae162 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,6 @@
import { escape } from 'lodash';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index cd8088bf667..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 '~/flash';
+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/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 7a6a267e7d0..f4fa52b2d4d 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,5 +1,5 @@
import { escape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { logError } from '~/lib/logger';
import api from '~/api';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index d490b8c5dad..79a8ccf2285 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
import { leftSidebarViews } from '../../../constants';
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
index a7085c7d04c..b5bb2c7bdf8 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
@@ -2,9 +2,3 @@ export const scopes = {
assigned: 'assigned-to-me',
created: 'created-by-me',
};
-
-export const states = {
- opened: 'opened',
- closed: 'closed',
- merged: 'merged',
-};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
index 4748ccfa2e6..0a2f778c715 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
@@ -1,7 +1,7 @@
-import { states } from './constants';
+import { STATUS_OPEN } from '~/issues/constants';
export default () => ({
isLoading: false,
mergeRequests: [],
- state: states.opened,
+ state: STATUS_OPEN,
});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
index 874cc5094d3..411ff0beaba 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import * as terminalService from '../../../../services/terminals';
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
index 4aa0768d394..463634c946d 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import * as messages from '../messages';
import * as types from '../mutation_types';
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/api.js b/app/assets/javascripts/import/details/api.js
new file mode 100644
index 00000000000..1fb3ee526d7
--- /dev/null
+++ b/app/assets/javascripts/import/details/api.js
@@ -0,0 +1,11 @@
+import axios from '~/lib/utils/axios_utils';
+
+export const fetchImportFailures = (failuresPath, { projectId, page, perPage }) => {
+ return axios.get(failuresPath, {
+ params: {
+ project_id: projectId,
+ page,
+ per_page: perPage,
+ },
+ });
+};
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..13483fa8ba2
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_app.vue
@@ -0,0 +1,18 @@
+<script>
+import { s__ } from '~/locale';
+import ImportDetailsTable from './import_details_table.vue';
+
+export default {
+ components: { ImportDetailsTable },
+ 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..813dc1f2645
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_table.vue
@@ -0,0 +1,160 @@
+<script>
+import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import { STATISTIC_ITEMS } from '../../constants';
+import { fetchImportFailures } from '../api';
+
+const DEFAULT_PAGE_SIZE = 20;
+
+export default {
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ 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: 'provider_url',
+ label: __('URL'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'details',
+ label: __('Details'),
+ },
+ ],
+
+ i18n: {
+ fetchErrorMessage: s__('Import|An error occurred while fetching import details.'),
+ emptyText: s__('Import|No import details'),
+ },
+
+ inject: {
+ failuresPath: {
+ default: undefined,
+ },
+ },
+
+ data() {
+ return {
+ items: [],
+ loading: false,
+ page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
+ totalPages: 0,
+ total: 0,
+ };
+ },
+
+ computed: {
+ hasItems() {
+ return this.items.length > 0;
+ },
+
+ pageInfo() {
+ return {
+ page: this.page,
+ perPage: this.perPage,
+ totalPages: this.totalPages,
+ total: this.total,
+ };
+ },
+ },
+
+ mounted() {
+ this.loadImportFailures();
+ },
+
+ methods: {
+ setPage(page) {
+ this.page = page;
+ this.loadImportFailures();
+ },
+
+ setPageSize(size) {
+ this.perPage = size;
+ this.page = 1;
+ this.loadImportFailures();
+ },
+
+ async loadImportFailures() {
+ if (!this.failuresPath) {
+ return;
+ }
+
+ this.loading = true;
+ try {
+ const response = await fetchImportFailures(this.failuresPath, {
+ projectId: getParameterValues('project_id')[0],
+ page: this.page,
+ perPage: this.perPage,
+ });
+
+ const { page, perPage, totalPages, total } = parseIntPagination(
+ normalizeHeaders(response.headers),
+ );
+ this.page = page;
+ this.perPage = perPage;
+ this.totalPages = totalPages;
+ this.total = total;
+ this.items = response.data;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.fetchErrorMessage });
+ }
+ this.loading = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" :busy="loading" show-empty>
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #empty>
+ <gl-empty-state :title="$options.i18n.emptyText" />
+ </template>
+
+ <template #cell(type)="{ item: { type } }">
+ {{ $options.STATISTIC_ITEMS[type] }}
+ </template>
+ <template #cell(provider_url)="{ item: { provider_url } }">
+ <gl-link v-if="provider_url" :href="provider_url" target="_blank">
+ {{ provider_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..7421846f103
--- /dev/null
+++ b/app/assets/javascripts/import/details/index.js
@@ -0,0 +1,23 @@
+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;
+ }
+
+ const { failuresPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'ImportDetailsRoot',
+ provide: {
+ failuresPath,
+ },
+ render(createElement) {
+ return createElement(ImportDetailsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index f351a9a392f..1c31c04a416 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -3,8 +3,8 @@ import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import { createAlert } from '~/alert';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
@@ -28,7 +28,7 @@ export default {
},
apollo: {
namespaces: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ query: searchNamespacesWhereUserCanImportProjectsQuery,
variables() {
return {
search: this.searchTerm,
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 6dc0b2cec24..6c84684dedc 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,31 +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 attachments'),
- issue_event: __('Issue events'),
- label: __('Labels'),
- lfs_object: __('LFS objects'),
- merge_request_attachment: s__('GithubImporter|Merge request attachments'),
- milestone: __('Milestones'),
- note: __('Notes'),
- note_attachment: s__('GithubImporter|Note attachments'),
- protected_branch: __('Protected branches'),
- 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 attachments'),
-};
+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',
@@ -77,8 +56,20 @@ export default {
GlAccordionItem,
GlBadge,
GlIcon,
+ GlLink,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ detailsPath: {
+ default: undefined,
+ },
},
props: {
+ projectId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
status: {
type: String,
required: true,
@@ -102,13 +93,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',
}
: {
@@ -120,6 +114,22 @@ export default {
return STATUS_MAP[this.status];
},
+
+ showDetails() {
+ return (
+ Boolean(this.detailsPathForProject) &&
+ this.glFeatures.importDetailsPage &&
+ this.isIncomplete
+ );
+ },
+
+ detailsPathForProject() {
+ if (!this.projectId || !this.detailsPath) {
+ return null;
+ }
+
+ return `${this.detailsPath}?project_id=${this.projectId}`;
+ },
},
methods: {
@@ -140,25 +150,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
@@ -173,6 +180,9 @@ export default {
</div>
</li>
</ul>
+ <gl-link v-if="showDetails" :href="detailsPathForProject">{{
+ $options.i18n.detailsLink
+ }}</gl-link>
</gl-accordion-item>
</gl-accordion>
</div>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index ed7c9e7abe9..d91f314a86c 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,16 +1,9 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
export default {
components: {
GlIcon,
- GlButton,
GlDropdown,
GlDropdownItem,
},
@@ -18,10 +11,6 @@ export default {
GlTooltip,
},
props: {
- isProjectsImportEnabled: {
- type: Boolean,
- required: true,
- },
isFinished: {
type: Boolean,
required: true,
@@ -46,7 +35,7 @@ export default {
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-dropdown
- v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)"
+ v-if="isAvailableForImport || isFinished"
:text="isFinished ? __('Re-import with projects') : __('Import with projects')"
:disabled="isInvalid"
variant="confirm"
@@ -59,16 +48,6 @@ export default {
isFinished ? __('Re-import without projects') : __('Import without projects')
}}</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-else-if="isAvailableForImport || isFinished"
- :disabled="isInvalid"
- variant="confirm"
- category="secondary"
- data-qa-selector="import_group_button"
- @click="$emit('import-group')"
- >
- {{ isFinished ? __('Re-import') : __('Import') }}
- </gl-button>
<gl-icon
v-if="isFinished"
v-gl-tooltip
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 7d2ddd2176b..246d27d3b94 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,7 +1,6 @@
<script>
import {
GlAlert,
- GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -15,7 +14,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, __, n__, sprintf } from '~/locale';
import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -25,7 +24,7 @@ import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUSES } from '../../constants';
@@ -50,7 +49,6 @@ const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!';
export default {
components: {
GlAlert,
- GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -106,7 +104,7 @@ export default {
reimportRequests: [],
importTargets: {},
unavailableFeaturesAlertVisible: true,
- helpUrl: helpPagePath('ee/user/group/import', {
+ helpUrl: helpPagePath('user/group/import/index', {
anchor: 'visibility-rules',
}),
};
@@ -120,7 +118,7 @@ export default {
},
},
availableNamespaces: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ query: searchNamespacesWhereUserCanImportProjectsQuery,
update(data) {
return data.currentUser.groups.nodes;
},
@@ -165,10 +163,6 @@ export default {
],
computed: {
- isProjectsImportEnabled() {
- return Boolean(this.glFeatures.bulkImportProjects);
- },
-
groups() {
return this.bulkImportSourceGroups?.nodes ?? [];
},
@@ -707,11 +701,11 @@ export default {
</gl-sprintf>
</span>
<gl-dropdown
- v-if="isProjectsImportEnabled"
:text="s__('BulkImport|Import with projects')"
:disabled="!hasSelectedGroups"
variant="confirm"
category="primary"
+ data-testid="import-selected-groups-dropdown"
class="gl-ml-4"
split
@click="importSelectedGroups({ migrateProjects: true })"
@@ -720,15 +714,6 @@ export default {
{{ s__('BulkImport|Import without projects') }}
</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-else
- category="primary"
- variant="confirm"
- class="gl-ml-4"
- :disabled="!hasSelectedGroups"
- @click="importSelectedGroups"
- >{{ s__('BulkImport|Import selected') }}</gl-button
- >
<span class="gl-ml-3">
<gl-icon name="information-o" :size="12" class="gl-text-blue-600" />
<gl-sprintf
@@ -804,7 +789,6 @@ export default {
</template>
<template #cell(actions)="{ item: group, index }">
<import-actions-cell
- :is-projects-import-enabled="isProjectsImportEnabled"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 494a845b1f9..8efc6484794 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -31,6 +31,7 @@ export function mountImportGroupsApp(mountElement) {
return new Vue({
el: mountElement,
+ name: 'ImportGroupsRoot',
apolloProvider,
render(createElement) {
return createElement(ImportTable, {
diff --git a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
index 6ad5e448a40..10496fce11b 100644
--- a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue
new file mode 100644
index 00000000000..5d5965e33da
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue
@@ -0,0 +1,73 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __, s__ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ },
+ inject: ['statusImportGithubGroupPath'],
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { organizationsLoading: true, organizations: [], organizationFilter: '' };
+ },
+ computed: {
+ toggleText() {
+ return this.value || this.$options.i18n.allOrganizations;
+ },
+ dropdownItems() {
+ return [
+ { text: this.$options.i18n.allOrganizations, value: '' },
+ ...this.organizations
+ .filter((entry) =>
+ entry.name.toLowerCase().includes(this.organizationFilter.toLowerCase()),
+ )
+ .map((entry) => ({
+ text: entry.name,
+ value: entry.name,
+ })),
+ ];
+ },
+ },
+ async mounted() {
+ try {
+ this.organizationsLoading = true;
+ const {
+ data: { provider_groups: organizations },
+ } = await axios.get(this.statusImportGithubGroupPath);
+ this.organizations = organizations;
+ } catch (e) {
+ createAlert({
+ message: __('Something went wrong on our end.'),
+ });
+ Sentry.captureException(e);
+ } finally {
+ this.organizationsLoading = false;
+ }
+ },
+ i18n: {
+ allOrganizations: s__('ImportProjects|All organizations'),
+ },
+};
+</script>
+<template>
+ <gl-collapsible-listbox
+ :loading="organizationsLoading"
+ :toggle-text="toggleText"
+ :header-text="s__('ImportProjects|Organizations')"
+ :items="dropdownItems"
+ searchable
+ role="button"
+ tabindex="0"
+ @search="organizationFilter = $event"
+ @select="$emit('input', $event)"
+ />
+</template>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
new file mode 100644
index 00000000000..20dcd0356cd
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlButton, GlSearchBoxByClick, GlTabs, GlTab } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
+import ImportProjectsTable from './import_projects_table.vue';
+import GithubOrganizationsBox from './github_organizations_box.vue';
+
+export default {
+ components: {
+ ImportProjectsTable,
+ GithubOrganizationsBox,
+ GlButton,
+ GlSearchBoxByClick,
+ GlTab,
+ GlTabs,
+ },
+ inheritAttrs: false,
+ data() {
+ return {
+ selectedRelationTypeTabIdx: 0,
+ };
+ },
+ computed: {
+ ...mapState({
+ selectedOrganization: (state) => state.filter.organization_login ?? '',
+ nameFilter: (state) => state.filter.filter ?? '',
+ }),
+ ...mapGetters(['isImportingAnyRepo', 'hasImportableRepos']),
+ isNameFilterDisabled() {
+ return (
+ this.$options.relationTypes[this.selectedRelationTypeTabIdx].showOrganizationFilter &&
+ !this.selectedOrganization
+ );
+ },
+ },
+ watch: {
+ selectedRelationTypeTabIdx: {
+ immediate: true,
+ handler(newIdx) {
+ const { backendFilter } = this.$options.relationTypes[newIdx];
+ this.setFilter({ ...backendFilter, organization_login: '', filter: '' });
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setFilter']),
+ selectOrganization(org) {
+ this.selectedOrganization = org;
+ this.setFilter();
+ },
+ },
+
+ relationTypes: [
+ { title: s__('ImportProjects|Owned'), backendFilter: { relation_type: 'owned' } },
+ { title: s__('ImportProjects|Collaborated'), backendFilter: { relation_type: 'collaborated' } },
+ {
+ title: s__('ImportProjects|Organization'),
+ backendFilter: { relation_type: 'organization' },
+ showOrganizationFilter: true,
+ },
+ ],
+};
+</script>
+<template>
+ <import-projects-table v-bind="$attrs">
+ <template #filter="{ importAllButtonText, showImportAllModal }">
+ <gl-tabs v-model="selectedRelationTypeTabIdx" content-class="gl-py-0! gl-mb-3">
+ <gl-tab v-for="tab in $options.relationTypes" :key="tab.title" :title="tab.title">
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-gap-3 gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-0 gl-border-b-gray-100 gl-border-b-1"
+ >
+ <form class="gl-display-flex gl-flex-grow-1 gl-mr-3" novalidate @submit.prevent>
+ <github-organizations-box
+ v-if="tab.showOrganizationFilter"
+ class="gl-mr-3"
+ :value="selectedOrganization"
+ @input="setFilter({ organization_login: $event })"
+ />
+ <gl-search-box-by-click
+ data-qa-selector="githubish_import_filter_field"
+ name="filter"
+ :disabled="isNameFilterDisabled"
+ :value="nameFilter"
+ :placeholder="__('Filter by name')"
+ autofocus
+ @submit="setFilter({ filter: $event })"
+ @clear="setFilter({ filter: '' })"
+ />
+ </form>
+ <gl-button
+ variant="confirm"
+ :loading="isImportingAnyRepo"
+ :disabled="!hasImportableRepos"
+ type="button"
+ @click="showImportAllModal"
+ >
+ {{ importAllButtonText }}
+ </gl-button>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ </template>
+ </import-projects-table>
+</template>
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..1c830d8c2c5 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
@@ -8,6 +8,7 @@ import {
} from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
+
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import AdvancedSettings from './advanced_settings.vue';
@@ -57,7 +58,7 @@ export default {
data() {
return {
optionalStagesSelection: Object.fromEntries(
- this.optionalStages.map(({ name }) => [name, false]),
+ this.optionalStages.map(({ name, selected }) => [name, selected]),
),
};
},
@@ -123,6 +124,10 @@ export default {
'setFilter',
'importAll',
]),
+
+ showImportAllModal() {
+ this.$refs.importAllModal.show();
+ },
},
};
</script>
@@ -135,43 +140,30 @@ export default {
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"></slot>
</template>
- <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5">
- <gl-button
- variant="confirm"
- :loading="isImportingAnyRepo"
- :disabled="!hasImportableRepos"
- type="button"
- @click="$refs.importAllModal.show()"
- >{{ importAllButtonText }}</gl-button
- >
- <gl-modal
- ref="importAllModal"
- modal-id="import-all-modal"
- :title="s__('ImportProjects|Import repositories')"
- :ok-title="__('Import')"
- @ok="importAll({ optionalStages: optionalStagesSelection })"
- >
- {{
- n__(
- 'Are you sure you want to import %d repository?',
- 'Are you sure you want to import %d repositories?',
- importAllCount,
- )
- }}
- </gl-modal>
-
- <slot name="actions"></slot>
- <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
- <gl-search-box-by-click
- data-qa-selector="githubish_import_filter_field"
- name="filter"
- :placeholder="__('Filter by name')"
- autofocus
- @submit="setFilter"
- @clear="setFilter('')"
- />
- </form>
- </div>
+ <slot name="filter" v-bind="{ showImportAllModal, importAllButtonText }">
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5">
+ <gl-button
+ variant="confirm"
+ :loading="isImportingAnyRepo"
+ :disabled="!hasImportableRepos"
+ type="button"
+ @click="showImportAllModal"
+ >{{ importAllButtonText }}</gl-button
+ >
+
+ <slot name="actions"></slot>
+ <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
+ <gl-search-box-by-click
+ data-qa-selector="githubish_import_filter_field"
+ name="filter"
+ :placeholder="__('Filter by name')"
+ autofocus
+ @submit="setFilter({ filter: $event })"
+ @clear="setFilter({ filter: '' })"
+ />
+ </form>
+ </div>
+ </slot>
<advanced-settings
v-if="optionalStages && optionalStages.length"
v-model="optionalStagesSelection"
@@ -179,19 +171,36 @@ export default {
:is-initially-expanded="isAdvancedSettingsPanelInitiallyExpanded"
class="gl-mb-5"
/>
+ <gl-modal
+ ref="importAllModal"
+ modal-id="import-all-modal"
+ :title="s__('ImportProjects|Import repositories')"
+ :ok-title="__('Import')"
+ @ok="importAll({ optionalStages: optionalStagesSelection })"
+ >
+ {{
+ n__(
+ 'Are you sure you want to import %d repository?',
+ 'Are you sure you want to import %d repositories?',
+ importAllCount,
+ )
+ }}
+ </gl-modal>
<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">
@@ -207,7 +216,7 @@ export default {
</table>
</div>
<gl-intersection-observer
- v-if="paginatable && pageInfo.hasNextPage"
+ v-if="!isLoadingRepos && paginatable && pageInfo.hasNextPage"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
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..735939f991f 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
@@ -70,7 +70,7 @@ export default {
...mapGetters(['getImportTarget']),
displayFullPath() {
- return this.repo.importedProject.fullPath.replace(/^\//, '');
+ return this.repo.importedProject?.fullPath.replace(/^\//, '');
},
isFinished() {
@@ -105,6 +105,10 @@ export default {
return this.getImportTarget(this.repo.importSource.id);
},
+ importedProjectId() {
+ return this.repo.importedProject?.id;
+ },
+
importButtonText() {
if (this.ciCdOnly) {
return __('Connect');
@@ -155,16 +159,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 +183,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">
- <import-status :status="importStatus" :stats="stats" />
+ <td data-qa-selector="import_status_indicator">
+ <import-status :project-id="importedProjectId" :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/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql
new file mode 100644
index 00000000000..8c41f7116b3
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql
@@ -0,0 +1,18 @@
+query searchNamespacesWhereUserCanImportProjects($search: String) {
+ currentUser {
+ id
+ groups(permissionScope: IMPORT_PROJECTS, search: $search) {
+ nodes {
+ id
+ fullPath
+ name
+ visibility
+ webUrl
+ }
+ }
+ namespace {
+ id
+ fullPath
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 485511510f7..6ee637b1ce8 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -61,18 +61,27 @@ export default function mountImportProjectsTable({
mountElement,
Component = ImportProjectsTable,
extraProps = () => ({}),
+ extraProvide = () => ({}),
}) {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
+ const { detailsPath } = mountElement.dataset;
return new Vue({
el: mountElement,
+ name: 'ImportProjectsRoot',
store,
apolloProvider,
+ provide: {
+ detailsPath,
+ ...extraProvide(mountElement.dataset),
+ },
render(createElement) {
- return createElement(Component, { props: { ...props, ...extraProps(mountElement.dataset) } });
+ // We are using attrs instead of props so root-level component with inheritAttrs
+ // will be able to pass them down
+ return createElement(Component, { attrs: { ...props, ...extraProps(mountElement.dataset) } });
},
});
}
diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js
index e0db585eb3e..4305f8d4db5 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js
@@ -1,6 +1,6 @@
import Visibility from 'visibilityjs';
import _ from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
@@ -83,7 +83,7 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit })
.get(
pathWithParams({
path: reposPath,
- filter: filter ?? '',
+ ...(filter ?? {}),
...paginationParams({ state }),
}),
)
@@ -141,7 +141,7 @@ const fetchImportFactory = (importPath = isRequired()) => (
})
.catch((e) => {
const serverErrorMessage = e?.response?.data?.errors;
- const flashMessage = serverErrorMessage
+ const alertMessage = serverErrorMessage
? sprintf(
s__('ImportProjects|Importing the project failed: %{reason}'),
{
@@ -152,7 +152,7 @@ const fetchImportFactory = (importPath = isRequired()) => (
: s__('ImportProjects|Importing the project failed');
createAlert({
- message: flashMessage,
+ message: alertMessage,
});
commit(types.RECEIVE_IMPORT_ERROR, repoId);
@@ -179,7 +179,7 @@ export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { r
})
.catch((e) => {
const serverErrorMessage = e?.response?.data?.errors;
- const flashMessage = serverErrorMessage
+ const alertMessage = serverErrorMessage
? sprintf(
s__('ImportProjects|Cancelling project import failed: %{reason}'),
{
@@ -190,7 +190,7 @@ export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { r
: s__('ImportProjects|Cancelling project import failed');
createAlert({
- message: flashMessage,
+ message: alertMessage,
});
});
};
@@ -203,7 +203,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({
resource: {
- fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter: state.filter })),
+ fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, ...state.filter })),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index 734e7b10a77..df529449f90 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -23,8 +23,8 @@ const processLegacyEntries = ({ newRepositories, existingRepositories, factory }
};
export default {
- [types.SET_FILTER](state, filter) {
- state.filter = filter;
+ [types.SET_FILTER](state, newFilter) {
+ state.filter = { ...state.filter, ...newFilter };
state.repositories = [];
state.pageInfo = {
page: 0,
diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js
index c384848f0a0..62dcefd3339 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/state.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/state.js
@@ -4,7 +4,7 @@ export default () => ({
customImportTargets: {},
isLoadingRepos: false,
ciCdOnly: false,
- filter: '',
+ filter: {},
pageInfo: {
page: 0,
startCursor: null,
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 ee3f30de880..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 = {
@@ -44,7 +43,6 @@ export const ESCALATION_STATUSES = {
RESOLVED: s__('AlertManagement|Resolved'),
};
-export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
export const TH_ESCALATION_STATUS_TEST_ID = { 'data-testid': 'incident-management-status-sort' };
@@ -52,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',
};
@@ -64,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',
};
@@ -72,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/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
index d3850114350..195544c746e 100644
--- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js
+++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { ERROR_MSG } from './constants';
diff --git a/app/assets/javascripts/init_deprecated_notes.js b/app/assets/javascripts/init_deprecated_notes.js
index 5f918b0d2f5..8657a1dcb67 100644
--- a/app/assets/javascripts/init_deprecated_notes.js
+++ b/app/assets/javascripts/init_deprecated_notes.js
@@ -2,9 +2,9 @@ import Notes from './deprecated_notes';
export default () => {
const dataEl = document.querySelector('.js-notes-data');
- const { notesUrl, notesIds, now, diffView, enableGFM } = JSON.parse(dataEl.innerHTML);
+ const { notesUrl, now, diffView, enableGFM } = JSON.parse(dataEl.innerHTML);
// Create a singleton so that we don't need to assign
// into the window object, we can just access the current isntance with Notes.instance
- Notes.initialize(notesUrl, notesIds, now, diffView, enableGFM);
+ Notes.initialize(notesUrl, now, diffView, enableGFM);
};
diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
index 8413fe92f89..d48741c794e 100644
--- a/app/assets/javascripts/init_diff_stats_dropdown.js
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -1,12 +1,7 @@
import Vue from 'vue';
import DiffStatsDropdown from '~/vue_shared/components/diff_stats_dropdown.vue';
-import { stickyMonitor } from './lib/utils/sticky';
-
-export const initDiffStatsDropdown = (stickyTop) => {
- if (stickyTop) {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false);
- }
+export const initDiffStatsDropdown = () => {
const el = document.querySelector('.js-diff-stats-dropdown');
if (!el) {
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 5d08520bb5c..6b5a828c009 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -32,6 +32,8 @@ export const integrationFormSections = {
JIRA_TRIGGER: 'jira_trigger',
JIRA_ISSUES: 'jira_issues',
TRIGGER: 'trigger',
+ APPLE_APP_STORE: 'apple_app_store',
+ GOOGLE_PLAY: 'google_play',
};
export const integrationFormSectionComponents = {
@@ -40,6 +42,8 @@ export const integrationFormSectionComponents = {
[integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
[integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
[integrationFormSections.TRIGGER]: 'IntegrationSectionTrigger',
+ [integrationFormSections.APPLE_APP_STORE]: 'IntegrationSectionAppleAppStore',
+ [integrationFormSections.GOOGLE_PLAY]: 'IntegrationSectionGooglePlay',
};
export const integrationTriggerEvents = {
@@ -90,7 +94,7 @@ export const billingPlanNames = {
[billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'),
};
-export const INTEGRATION_TYPE_SLACK = 'slack';
+const INTEGRATION_TYPE_SLACK = 'slack';
const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application';
const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index bc6aa231a93..024f562b71d 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -11,7 +11,7 @@ export default {
primaryProps() {
return {
text: __('Save'),
- attributes: [{ variant: 'confirm' }, { category: 'primary' }],
+ attributes: { variant: 'confirm', category: 'primary' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index d671ec33bcb..f119668048d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -59,9 +59,6 @@ export default {
return this.propsSource.editable;
},
hasSections() {
- if (this.hasSlackNotificationsDisabled) {
- return false;
- }
return this.customState.sections.length !== 0;
},
fieldsWithoutSection() {
@@ -70,17 +67,11 @@ export default {
: this.propsSource.fields;
},
hasFieldsWithoutSection() {
- if (this.hasSlackNotificationsDisabled) {
- return false;
- }
return this.fieldsWithoutSection.length;
},
isSlackIntegration() {
return this.propsSource.type === INTEGRATION_FORM_TYPE_SLACK;
},
- hasSlackNotificationsDisabled() {
- return this.isSlackIntegration && !this.glFeatures.integrationSlackAppNotifications;
- },
showHelpHtml() {
if (this.isSlackIntegration) {
return this.helpHtml;
@@ -90,7 +81,6 @@ export default {
shouldUpgradeSlack() {
return (
this.isSlackIntegration &&
- this.glFeatures.integrationSlackAppNotifications &&
this.customState.shouldUpgradeSlack &&
(this.hasFieldsWithoutSection || this.hasSections)
);
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
index ce39954735a..5335b7b6ee2 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -28,6 +28,14 @@ export default {
import(
/* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
),
+ IntegrationSectionAppleAppStore: () =>
+ import(
+ /* webpackChunkName: 'IntegrationSectionAppleAppStore' */ '~/integrations/edit/components/sections/apple_app_store.vue'
+ ),
+ IntegrationSectionGooglePlay: () =>
+ import(
+ /* webpackChunkName: 'IntegrationSectionGooglePlay' */ '~/integrations/edit/components/sections/google_play.vue'
+ ),
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
index 41cd650f932..e766064a69b 100644
--- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
@@ -9,7 +9,7 @@ export default {
},
primaryProps: {
text: __('Reset'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
new file mode 100644
index 00000000000..775600a9a62
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
@@ -0,0 +1,73 @@
+<script>
+import { mapGetters } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import UploadDropzoneField from '../upload_dropzone_field.vue';
+import Connection from './connection.vue';
+
+export default {
+ name: 'IntegrationSectionAppleAppStore',
+ components: {
+ Connection,
+ UploadDropzoneField,
+ },
+ data() {
+ return {
+ dropzoneAllowList: ['.p8'],
+ };
+ },
+ i18n: {
+ dropzoneDescription: s__(
+ 'AppleAppStore|Drag your Private Key file here or %{linkStart}click to upload%{linkEnd}.',
+ ),
+ dropzoneErrorMessage: s__(
+ 'AppleAppStore|Error: You are trying to upload something other than a Private Key file.',
+ ),
+ dropzoneConfirmMessage: s__('AppleAppStore|Drop your Private Key file to start the upload.'),
+ dropzoneEmptyInputName: s__('AppleAppStore|The Apple App Store Connect Private Key (.p8)'),
+ dropzoneNonEmptyInputName: s__(
+ 'AppleAppStore|Upload a new Apple App Store Connect Private Key (replace %{currentFileName})',
+ ),
+ dropzoneNonEmptyInputHelp: s__('AppleAppStore|Leave empty to use your current Private Key.'),
+ },
+ computed: {
+ ...mapGetters(['propsSource']),
+ dynamicFields() {
+ return this.propsSource.fields.filter(
+ (field) => field.name !== 'app_store_private_key_file_name',
+ );
+ },
+ fileNameField() {
+ return this.propsSource.fields.find(
+ (field) => field.name === 'app_store_private_key_file_name',
+ );
+ },
+ dropzoneLabel() {
+ return this.fileNameField.value
+ ? sprintf(this.$options.i18n.dropzoneNonEmptyInputName, {
+ currentFileName: this.fileNameField.value,
+ })
+ : this.$options.i18n.dropzoneEmptyInputName;
+ },
+ dropzoneHelpText() {
+ return this.fileNameField.value ? this.$options.i18n.dropzoneNonEmptyInputHelp : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <connection :fields="dynamicFields" />
+
+ <upload-dropzone-field
+ name="service[app_store_private_key]"
+ :label="dropzoneLabel"
+ :help-text="dropzoneHelpText"
+ file-input-name="service[app_store_private_key_file_name]"
+ :allow-list="dropzoneAllowList"
+ :description="$options.i18n.dropzoneDescription"
+ :error-message="$options.i18n.dropzoneErrorMessage"
+ :confirm-message="$options.i18n.dropzoneConfirmMessage"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
new file mode 100644
index 00000000000..3094e24241a
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
@@ -0,0 +1,75 @@
+<script>
+import { mapGetters } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import UploadDropzoneField from '../upload_dropzone_field.vue';
+import Connection from './connection.vue';
+
+export default {
+ name: 'IntegrationSectionGooglePlay',
+ components: {
+ Connection,
+ UploadDropzoneField,
+ },
+ data() {
+ return {
+ dropzoneAllowList: ['.json'],
+ };
+ },
+ i18n: {
+ dropzoneDescription: s__(
+ 'GooglePlay|Drag your key file here or %{linkStart}click to upload%{linkEnd}.',
+ ),
+ dropzoneErrorMessage: s__(
+ "GooglePlay|Error: The file you're trying to upload is not a service account key.",
+ ),
+ dropzoneConfirmMessage: s__('GooglePlay|Drag your key file to start the upload.'),
+ dropzoneEmptyInputName: s__('GooglePlay|Service account key (.json)'),
+ dropzoneNonEmptyInputName: s__(
+ 'GooglePlay|Upload a new service account key (replace %{currentFileName})',
+ ),
+ dropzoneNoneEmpyInputHelp: s__(
+ 'GooglePlay|Leave empty to use your current service account key.',
+ ),
+ },
+ computed: {
+ ...mapGetters(['propsSource']),
+ dynamicFields() {
+ return this.propsSource.fields.filter(
+ (field) => field.name !== 'service_account_key_file_name',
+ );
+ },
+ fileNameField() {
+ return this.propsSource.fields.find(
+ (field) => field.name === 'service_account_key_file_name',
+ );
+ },
+ dropzoneLabel() {
+ return this.fileNameField.value
+ ? sprintf(this.$options.i18n.dropzoneNonEmptyInputName, {
+ currentFileName: this.fileNameField.value,
+ })
+ : this.$options.i18n.dropzoneEmptyInputName;
+ },
+ dropzoneHelpText() {
+ return this.fileNameField.value ? this.$options.i18n.dropzoneNoneEmpyInputHelp : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <connection :fields="dynamicFields" />
+
+ <upload-dropzone-field
+ name="service[service_account_key]"
+ :label="dropzoneLabel"
+ :help-text="dropzoneHelpText"
+ file-input-name="service[service_account_key_file_name]"
+ :allow-list="dropzoneAllowList"
+ :description="$options.i18n.dropzoneDescription"
+ :error-message="$options.i18n.dropzoneErrorMessage"
+ :confirm-message="$options.i18n.dropzoneConfirmMessage"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
new file mode 100644
index 00000000000..fbed2547c05
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlLink, GlSprintf, GlAlert, GlFormGroup } from '@gitlab/ui';
+import { validateFileFromAllowList } from '~/lib/utils/file_upload';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { s__ } from '~/locale';
+
+const i18n = Object.freeze({
+ description: s__('Integrations|Drag your file here or %{linkStart}click to upload%{linkEnd}.'),
+ errorMessage: s__(
+ 'Integrations|Error: You are trying to upload something other than an allowed file.',
+ ),
+ confirmMessage: s__('Integrations|Drop your file to start the upload.'),
+});
+
+export default {
+ name: 'UploadDropzoneField',
+ components: {
+ UploadDropzone,
+ GlLink,
+ GlSprintf,
+ GlAlert,
+ GlFormGroup,
+ },
+ i18n,
+ props: {
+ name: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ label: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ fileInputName: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ allowList: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: i18n.description,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: i18n.errorMessage,
+ },
+ confirmMessage: {
+ type: String,
+ required: false,
+ default: i18n.confirmMessage,
+ },
+ },
+ data() {
+ return {
+ fileName: null,
+ fileContents: null,
+ uploadError: false,
+ inputDisabled: true,
+ };
+ },
+ computed: {
+ dropzoneDescription() {
+ return this.fileName ?? this.description;
+ },
+ },
+ methods: {
+ clearError() {
+ this.uploadError = false;
+ },
+ onChange(file) {
+ this.clearError();
+ this.inputDisabled = false;
+ this.fileName = file?.name;
+ this.readFile(file);
+ },
+ isValidFileType(file) {
+ return validateFileFromAllowList(file.name, this.allowList);
+ },
+ onError() {
+ this.uploadError = this.errorMessage;
+ },
+ readFile(file) {
+ const reader = new FileReader();
+ reader.readAsText(file);
+ reader.onload = (evt) => {
+ this.fileContents = evt.target.result;
+ };
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label" :label-for="name">
+ <upload-dropzone
+ input-field-name="service[dropzone_file_name]"
+ :is-file-valid="isValidFileType"
+ :valid-file-mimetypes="allowList"
+ :should-update-input-on-file-drop="true"
+ :single-file-selection="true"
+ :enable-drag-behavior="false"
+ :drop-to-start-message="confirmMessage"
+ @change="onChange"
+ @error="onError"
+ >
+ <template #upload-text="{ openFileUpload }">
+ <gl-sprintf :message="dropzoneDescription">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <template #invalid-drag-data-slot>
+ {{ errorMessage }}
+ </template>
+ </upload-dropzone>
+ <gl-alert v-if="uploadError" variant="danger" :dismissible="true" @dismiss="clearError">
+ {{ uploadError }}
+ </gl-alert>
+ <input :name="name" type="hidden" :disabled="inputDisabled" :value="fileContents || false" />
+ <input
+ :name="fileInputName"
+ type="hidden"
+ :disabled="inputDisabled"
+ :value="fileName || false"
+ />
+ <span>{{ helpText }}</span>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
index 62f0fe4d6bf..59a29f81727 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_table.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
-import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants';
import { sprintf, s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -59,13 +58,10 @@ export default {
];
},
filteredIntegrations() {
- if (this.glFeatures.integrationSlackAppNotifications) {
- return this.integrations.filter(
- (integration) =>
- !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false),
- );
- }
- return this.integrations;
+ return this.integrations.filter(
+ (integration) =>
+ !(integration.name === 'prometheus' && this.glFeatures.removeMonitorMetrics),
+ );
},
},
methods: {
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index e7f5211dc25..0e9781d77fe 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -114,6 +114,7 @@ export default {
defaultFetchOptions: {
exclude_internal: true,
active: true,
+ order_by: 'similarity',
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index b4e9a3a1559..10c08d63612 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -2,13 +2,14 @@
import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
} from '../utils/trigger_successful_invite_alert';
+import { PROJECT_SELECT_LABEL_ID } from '../constants';
import ProjectSelect from './project_select.vue';
export default {
@@ -80,11 +81,17 @@ export default {
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
},
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId);
+ },
resetFields() {
this.invalidFeedbackMessage = '';
this.projectToBeImported = {};
},
- submitImport() {
+ submitImport(e) {
+ // We never want to hide when submitting
+ e.preventDefault();
+
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
.then(this.onInviteSuccess)
@@ -130,7 +137,7 @@ export default {
defaultError: s__('ImportAProjectModal|Unable to import project members'),
successMessage: s__('ImportAProjectModal|Successfully imported'),
},
- projectSelectLabelId: 'project-select',
+ projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
modalId: uniqueId('import-a-project-modal-'),
};
</script>
@@ -143,6 +150,7 @@ export default {
:title="$options.i18n.modalTitle"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
+ no-focus-on-show
@primary="submitImport"
@hidden="resetFields"
>
@@ -157,10 +165,11 @@ export default {
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
data-testid="form-group"
+ label-cols="auto"
+ label-class="gl-pt-3!"
+ :label="$options.i18n.projectLabel"
+ :label-for="$options.projectSelectLabelId"
>
- <label :id="$options.projectSelectLabelId" class="col-form-label">{{
- $options.i18n.projectLabel
- }}</label>
<project-select v-model="projectToBeImported" />
</gl-form-group>
<p>{{ $options.i18n.modalHelpText }}</p>
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 607c888b85a..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,
@@ -51,6 +52,8 @@ export default {
MembersTokenSelect,
ModalConfetti,
UserLimitNotification,
+ ActiveTrialNotification: () =>
+ import('ee_component/invite_members/components/active_trial_notification.vue'),
},
mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })],
inject: ['newProjectPath'],
@@ -168,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 (
@@ -191,9 +190,6 @@ export default {
? this.selectedTaskProject.id
: '';
},
- isOnLearnGitlab() {
- return this.source === LEARN_GITLAB;
- },
showUserLimitNotification() {
return !isEmpty(this.usersLimitDataset.alertVariant);
},
@@ -246,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: {
@@ -281,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();
@@ -299,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();
+ const { error, message } = responseFromSuccess(response);
- 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);
-
- 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;
@@ -342,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 });
@@ -375,9 +367,7 @@ export default {
}
},
showSuccessMessage() {
- if (this.isOnLearnGitlab) {
- eventHub.$emit('showSuccessfulInvitationsAlert');
- } else {
+ if (!triggerExternalAlert(this.source)) {
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
@@ -421,7 +411,6 @@ export default {
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
:users-limit-dataset="usersLimitDataset"
- :active-trial-dataset="activeTrialDataset"
:full-path="fullPath"
@close="onClose"
@cancel="onCancel"
@@ -430,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 />
@@ -504,6 +495,10 @@ export default {
</div>
</template>
+ <template #active-trial-alert>
+ <active-trial-notification v-if="!isCelebration" :active-trial-dataset="activeTrialDataset" />
+ </template>
+
<template #select="{ exceptionState, inputId }">
<members-token-select
v-model="newUsersToInvite"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 42645110e48..6efb7a6cdf1 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,15 +1,17 @@
<script>
-import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlDropdownItem, GlDisclosureDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import {
TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
TRIGGER_DEFAULT_QA_SELECTOR,
+ TRIGGER_ELEMENT_WITH_EMOJI,
+ TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI,
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
} from '../constants';
export default {
- components: { GlButton, GlLink, GlIcon },
+ components: { GlButton, GlLink, GlDropdownItem, GlDisclosureDropdownItem },
props: {
displayText: {
type: String,
@@ -40,16 +42,6 @@ export default {
required: false,
default: 'button',
},
- event: {
- type: String,
- required: false,
- default: '',
- },
- label: {
- type: String,
- required: false,
- default: '',
- },
qaSelector: {
type: String,
required: false,
@@ -58,39 +50,43 @@ export default {
},
computed: {
componentAttributes() {
- const baseAttributes = {
+ return {
class: this.classes,
'data-qa-selector': this.qaSelector,
'data-test-id': 'invite-members-button',
};
-
- if (this.event && this.label) {
- return {
- ...baseAttributes,
- 'data-track-action': this.event,
- 'data-track-label': this.label,
- };
- }
-
- return baseAttributes;
+ },
+ item() {
+ return { text: this.displayText };
+ },
+ isButtonTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_BUTTON;
+ },
+ isWithEmojiTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_WITH_EMOJI;
+ },
+ isDropdownWithEmojiTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI;
+ },
+ isDisclosureTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN;
},
},
methods: {
- checkTrigger(targetTriggerElement) {
- return this.triggerElement === targetTriggerElement;
- },
openModal() {
eventHub.$emit('openModal', { source: this.triggerSource });
},
+ handleDisclosureDropdownAction() {
+ this.openModal();
+ this.$emit('modal-opened');
+ },
},
- TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
};
</script>
<template>
<gl-button
- v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)"
+ v-if="isButtonTrigger"
v-bind="componentAttributes"
:variant="variant"
:icon="icon"
@@ -98,17 +94,25 @@ export default {
>
{{ displayText }}
</gl-button>
- <gl-link
- v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)"
+ <gl-link v-else-if="isWithEmojiTrigger" v-bind="componentAttributes" @click="openModal">
+ {{ displayText }}
+ <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :data-name="icon" />
+ </gl-link>
+ <gl-dropdown-item
+ v-else-if="isDropdownWithEmojiTrigger"
v-bind="componentAttributes"
- data-is-link="true"
+ button-class="top-nav-menu-item"
@click="openModal"
>
- <span class="nav-icon-container">
- <gl-icon :name="icon" />
- </span>
- <span class="nav-item-name"> {{ displayText }} </span>
- </gl-link>
+ {{ displayText }}
+ <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :data-name="icon" />
+ </gl-dropdown-item>
+ <gl-disclosure-dropdown-item
+ v-else-if="isDisclosureTrigger"
+ v-bind="componentAttributes"
+ :item="item"
+ @action="handleDisclosureDropdownAction"
+ />
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 1e3b6093f0b..91b623821dd 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -1,5 +1,14 @@
<script>
-import { GlFormGroup, GlFormSelect, GlModal, GlDatepicker, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlFormGroup,
+ GlFormSelect,
+ GlModal,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlButton,
+} from '@gitlab/ui';
+
import Tracking from '~/tracking';
import { sprintf } from '~/locale';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
@@ -33,6 +42,7 @@ export default {
GlLink,
GlModal,
GlSprintf,
+ GlButton,
ContentTransition,
},
mixins: [Tracking.mixin()],
@@ -246,13 +256,11 @@ export default {
data-qa-selector="invite_members_modal_content"
data-testid="invite-modal"
size="sm"
+ dialog-class="gl-mx-5"
:title="modalTitle"
:header-close-label="$options.HEADER_CLOSE_LABEL"
- :action-primary="actionPrimary"
- :action-cancel="actionCancel"
+ no-focus-on-show
@shown="onShowModal"
- @primary="onSubmit"
- @cancel="onCancel"
@close="onClose"
@hidden="onReset"
>
@@ -330,5 +338,29 @@ export default {
<slot :name="key"></slot>
</template>
</content-transition>
+
+ <template #modal-footer>
+ <div
+ class="gl-m-0 gl-xs-w-full gl-display-flex gl-xs-flex-direction-column! gl-flex-direction-row-reverse"
+ >
+ <gl-button
+ class="gl-xs-w-full gl-xs-mb-3! gl-sm-ml-3!"
+ data-testid="invite-modal-submit"
+ v-bind="actionPrimary.attributes"
+ @click="onSubmit"
+ >
+ {{ actionPrimary.text }}
+ </gl-button>
+
+ <gl-button
+ class="gl-xs-w-full"
+ data-testid="invite-modal-cancel"
+ v-bind="actionCancel.attributes"
+ @click="onCancel"
+ >
+ {{ actionCancel.text }}
+ </gl-button>
+ </div>
+ </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue
index c1114c240b9..640df5cdb88 100644
--- a/app/assets/javascripts/invite_members/components/project_select.vue
+++ b/app/assets/javascripts/invite_members/components/project_select.vue
@@ -4,7 +4,7 @@ import { debounce } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { getProjects } from '~/rest_api';
-import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
+import { SEARCH_DELAY, GROUP_FILTERS, PROJECT_SELECT_LABEL_ID } from '../constants';
// We can have GlCollapsibleListbox dropdown panel with full
// width once we implement
@@ -96,6 +96,7 @@ export default {
errorFetchingProjects: s__(
'ProjectSelect|There was an error fetching the projects. Please try again.',
),
+ projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
},
defaultFetchOptions: {
exclude_internal: true,
@@ -110,6 +111,7 @@ export default {
:items="projects"
:searching="isFetching"
:toggle-text="selectedProjectName"
+ :toggle-aria-labelled-by="$options.projectSelectLabelId"
:search-placeholder="$options.i18n.searchPlaceholder"
:no-results-text="$options.i18n.emptySearchResult"
data-testid="project-select-dropdown"
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index ac0b708c55e..9afcaff6e16 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,12 +1,12 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+export const PROJECT_SELECT_LABEL_ID = 'project-select';
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';
@@ -19,7 +19,10 @@ export const GROUP_FILTERS = {
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
-export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav';
+export const TOP_NAV_INVITE_MEMBERS_COMPONENT = 'invite_members';
+export const TRIGGER_ELEMENT_WITH_EMOJI = 'text-emoji';
+export const TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI = 'dropdown-text-emoji';
+export const TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN = 'dropdown-text';
export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal';
export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button';
export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
@@ -59,9 +62,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');
@@ -76,7 +88,6 @@ export const READ_MORE_TEXT = s__(
export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage members');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
-export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
export const MEMBER_ERROR_LIST_TEXT = s__(
'InviteMembersModal|Review the invite errors and try again:',
@@ -128,16 +139,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/invite_members/utils/trigger_successful_invite_alert.js b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
index 4d3a7951265..e556582742b 100644
--- a/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
+++ b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import { TOAST_MESSAGE_LOCALSTORAGE_KEY, TOAST_MESSAGE_SUCCESSFUL } from '../constants';
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 736da92fa9f..c1de507cd80 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, n__ } from '~/locale';
-import { ISSUABLE_TYPE } from '../constants';
export default {
actionCancel: {
@@ -19,7 +19,7 @@ export default {
},
inject: {
issuableType: {
- default: ISSUABLE_TYPE.issues,
+ default: TYPE_ISSUE,
},
email: {
default: '',
@@ -47,14 +47,17 @@ export default {
href: this.exportCsvPath,
variant: 'confirm',
'data-method': 'post',
- 'data-qa-selector': `export_${this.issuableType}_button`,
+ 'data-qa-selector': `export_issues_button`,
'data-track-action': 'click_button',
- 'data-track-label': `export_${this.issuableType}_csv`,
+ 'data-track-label': this.dataTrackLabel,
},
};
},
isIssue() {
- return this.issuableType === ISSUABLE_TYPE.issues;
+ return this.issuableType === TYPE_ISSUE;
+ },
+ dataTrackLabel() {
+ return this.isIssue ? 'export_issues_csv' : 'export_merge-requests_csv';
},
exportText() {
return this.isIssue ? __('Export issues') : __('Export merge requests');
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index dadb1419649..b492194d1cf 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -1,14 +1,7 @@
<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlTooltipDirective,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
-import { ISSUABLE_TYPE } from '../constants';
import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
@@ -17,24 +10,18 @@ export default {
exportAsCsvButtonText: __('Export as CSV'),
importCsvText: __('Import CSV'),
importFromJiraText: __('Import from Jira'),
- importIssuesText: __('Import issues'),
},
- name: 'CsvImportExportButtons',
components: {
- GlButtonGroup,
- GlButton,
- GlDropdown,
GlDropdownItem,
CsvExportModal,
CsvImportModal,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: {
issuableType: {
- default: ISSUABLE_TYPE.issues,
+ default: TYPE_ISSUE,
},
showExportButton: {
default: false,
@@ -42,18 +29,12 @@ export default {
showImportButton: {
default: false,
},
- containerClass: {
- default: '',
- },
canEdit: {
default: false,
},
projectImportJiraPath: {
default: null,
},
- showLabel: {
- default: false,
- },
},
props: {
exportCsvPath: {
@@ -74,48 +55,30 @@ export default {
importModalId() {
return `${this.issuableType}-import-modal`;
},
- importButtonTooltipText() {
- return this.showLabel ? null : this.$options.i18n.importIssuesText;
- },
- importButtonIcon() {
- return this.showLabel ? null : 'import';
- },
},
};
</script>
<template>
- <div :class="containerClass">
- <gl-button-group class="gl-w-full">
- <gl-button
- v-if="showExportButton"
- v-gl-tooltip="$options.i18n.exportAsCsvButtonText"
- v-gl-modal="exportModalId"
- icon="export"
- :aria-label="$options.i18n.exportAsCsvButtonText"
- data-qa-selector="export_as_csv_button"
- />
- <gl-dropdown
- v-if="showImportButton"
- v-gl-tooltip="importButtonTooltipText"
- data-qa-selector="import_issues_dropdown"
- :text="$options.i18n.importIssuesText"
- :text-sr-only="!showLabel"
- :icon="importButtonIcon"
- class="gl-w-full gl-md-w-auto"
- >
- <gl-dropdown-item v-gl-modal="importModalId">
- {{ $options.i18n.importCsvText }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="canEdit"
- :href="projectImportJiraPath"
- data-qa-selector="import_from_jira_link"
- >
- {{ $options.i18n.importFromJiraText }}
- </gl-dropdown-item>
- </gl-dropdown>
- </gl-button-group>
+ <ul class="gl-display-contents">
+ <gl-dropdown-item
+ v-if="showExportButton"
+ v-gl-modal="exportModalId"
+ data-qa-selector="export_as_csv_button"
+ >
+ {{ $options.i18n.exportAsCsvButtonText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="showImportButton" v-gl-modal="importModalId">
+ {{ $options.i18n.importCsvText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showImportButton && canEdit"
+ :href="projectImportJiraPath"
+ data-qa-selector="import_from_jira_link"
+ >
+ {{ $options.i18n.importFromJiraText }}
+ </gl-dropdown-item>
+
<csv-export-modal
v-if="showExportButton"
:modal-id="exportModalId"
@@ -123,5 +86,5 @@ export default {
:issuable-count="issuableCount"
/>
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
- </div>
+ </ul>
</template>
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 0e58f3793bc..403997779ac 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -2,18 +2,18 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
-import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
-const NoteableTypeText = {
+const noteableTypeText = {
issue: __('issue'),
merge_request: __('merge request'),
};
export default {
TYPE_ISSUE,
- WorkspaceType,
+ WORKSPACE_PROJECT,
components: {
GlIcon,
ConfidentialityBadge,
@@ -32,7 +32,7 @@ export default {
return this.getNoteableData.confidential;
},
isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request';
+ return this.getNoteableData.targetType === TYPE_MERGE_REQUEST;
},
warningIconsMeta() {
return [
@@ -46,7 +46,7 @@ export default {
visible: this.hidden,
dataTestId: 'hidden',
tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), {
- issuable: NoteableTypeText[this.getNoteableData.targetType],
+ issuable: noteableTypeText[this.getNoteableData.targetType],
}),
},
];
@@ -60,7 +60,7 @@ export default {
<confidentiality-badge
v-if="isConfidential"
data-testid="confidential"
- :workspace-type="$options.WorkspaceType.project"
+ :workspace-type="$options.WORKSPACE_PROJECT"
:issuable-type="$options.TYPE_ISSUE"
/>
<template v-for="meta in warningIconsMeta">
diff --git a/app/assets/javascripts/issuable/components/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue
index 21f35690f6d..d8cbc45684b 100644
--- a/app/assets/javascripts/issuable/components/issue_assignees.vue
+++ b/app/assets/javascripts/issuable/components/issue_assignees.vue
@@ -84,6 +84,7 @@ export default {
:link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-css-classes="imgCssClasses"
+ img-css-wrapper-classes="gl-display-inline-flex"
:img-src="avatarUrl(assignee)"
:img-size="iconSize"
class="js-no-trigger author-link"
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 608c1deac64..df50a30abb7 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -4,12 +4,13 @@ import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab
import SafeHtml from '~/vue_shared/directives/safe_html';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
@@ -26,12 +27,18 @@ export default {
IssueDueDate,
GlButton,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
mixins: [relatedIssuableMixin],
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ },
props: {
canReorder: {
type: Boolean,
@@ -54,6 +61,13 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
+ };
+ },
computed: {
stateTitle() {
return sprintf(
@@ -71,6 +85,9 @@ export default {
workItemId() {
return convertToGraphQLId(TYPENAME_WORK_ITEM, this.idKey);
},
+ workItemIid() {
+ return String(this.iid);
+ },
},
methods: {
handleTitleClick(event) {
@@ -80,18 +97,26 @@ export default {
}
event.preventDefault();
this.$refs.modal.show();
- this.updateWorkItemIdUrlQuery(this.idKey);
+ this.updateWorkItemIidUrlQuery(this.iid);
}
},
handleWorkItemDeleted(workItemId) {
this.$emit('relatedIssueRemoveRequest', workItemId);
},
- updateWorkItemIdUrlQuery(workItemId) {
+ updateWorkItemIidUrlQuery(iid) {
updateHistory({
- url: setUrlParams({ work_item_id: workItemId }),
+ url: setUrlParams({ work_item_iid: iid }),
replace: true,
});
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
};
</script>
@@ -101,27 +126,24 @@ export default {
:class="{
'issuable-info-container': !canReorder,
'card-body': canReorder,
- 'gl-pr-2': canRemove,
}"
- class="item-body d-flex align-items-center gl-py-3 gl-px-5"
+ class="item-body gl-display-flex gl-align-items-center gl-gap-3"
>
<div
- class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
+ class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 gl-gap-2 gl-px-3 gl-py-2 py-xl-0 flex-xl-nowrap gl-min-h-7"
>
<!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0">
- <div ref="iconElementXL">
- <gl-icon
- v-if="hasState"
- ref="iconElementXL"
- class="gl-mr-3"
- :class="iconClasses"
- :name="iconName"
- :title="stateTitle"
- :aria-label="state"
- />
- </div>
- <gl-tooltip :target="() => $refs.iconElementXL">
+ <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="`iconElementXL-${itemId}`">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
@@ -129,42 +151,46 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
+ class="confidential-icon"
:aria-label="__('Confidential')"
/>
- <gl-link
- :href="computedPath"
- class="sortable-link gl-font-weight-normal"
- @click="handleTitleClick"
- >
+ <gl-link :href="computedPath" class="sortable-link" @click="handleTitleClick">
{{ title }}
</gl-link>
</div>
<!-- Info area: meta, path, and assignees -->
- <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0">
+ <div
+ class="item-info-area gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-gap-3 gl-ml-6 ml-xl-0"
+ >
<!-- Meta area: path and attributes -->
<!-- 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 d-flex flex-wrap-reverse justify-content-start justify-content-md-between"
+ 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
- class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
+ class="item-path-area item-path-id gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3"
>
<gl-tooltip :target="() => $refs.iconElement">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
- <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
- itemPath
- }}</span>
+ <span
+ v-if="itemPath"
+ v-gl-tooltip
+ :title="itemPath"
+ class="path-id-text d-inline-block"
+ >{{ itemPath }}</span
+ >
<span>{{ pathIdSeparator }}{{ itemId }}</span>
</div>
<!-- Attributes area: CI, epic count, weight, milestone -->
<!-- They have a different order on large screen sizes -->
- <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0">
+ <div
+ class="item-attributes-area gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3"
+ >
<span v-if="hasPipeline" class="mr-ci-status order-md-last">
<a :href="pipelineStatus.details_path">
<ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
@@ -174,7 +200,7 @@ export default {
<issue-milestone
v-if="hasMilestone"
:milestone="milestone"
- class="d-flex align-items-center item-milestone order-md-first ml-md-0"
+ class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first"
/>
<!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue -->
@@ -198,24 +224,17 @@ export default {
<issue-assignees
v-if="hasAssignees"
:assignees="assignees"
- class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex"
+ class="item-assignees gl-display-flex gl-align-items-center gl-align-self-end gl-flex-shrink-0 order-md-2"
/>
</div>
</div>
-
- <!-- Assignees. On small layouts, these are put here, at the end of the card. -->
- <issue-assignees
- v-if="assignees.length !== 0"
- :assignees="assignees"
- class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none gl-ml-3"
- />
</div>
</div>
<span
v-if="isLocked"
v-gl-tooltip
- class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
+ class="gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
data-testid="lockIcon"
>
@@ -226,8 +245,9 @@ export default {
v-gl-tooltip
icon="close"
category="tertiary"
+ size="small"
:disabled="removeDisabled"
- class="js-issue-item-remove-button gl-ml-3"
+ class="js-issue-item-remove-button gl-mr-2"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
@@ -236,8 +256,17 @@ export default {
<work-item-detail-modal
ref="modal"
:work-item-id="workItemId"
- @close="updateWorkItemIdUrlQuery"
+ :work-item-iid="workItemIid"
+ @close="updateWorkItemIidUrlQuery"
@workItemDeleted="handleWorkItemDeleted"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 0c75e44443d..799c0a18444 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -4,8 +4,7 @@ import Vue from 'vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import { __ } from '~/locale';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { STATUS_CLOSED, STATUS_OPEN, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
export const badgeState = Vue.observable({
state: '',
@@ -76,15 +75,15 @@ export default {
return [
CLASSES[this.state],
{
- 'gl-vertical-align-bottom': this.issuableType === IssuableType.MergeRequest,
+ 'gl-vertical-align-bottom': this.issuableType === TYPE_MERGE_REQUEST,
},
];
},
badgeVariant() {
- if (this.state === IssuableStates.Opened) {
+ if (this.state === STATUS_OPEN) {
return 'success';
- } else if (this.state === IssuableStates.Closed) {
- return this.issuableType === IssuableType.MergeRequest ? 'danger' : 'info';
+ } else if (this.state === STATUS_CLOSED) {
+ return this.issuableType === TYPE_MERGE_REQUEST ? 'danger' : 'info';
}
return 'info';
},
@@ -126,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/constants.js b/app/assets/javascripts/issuable/constants.js
index 5327f251fda..88fc6859acd 100644
--- a/app/assets/javascripts/issuable/constants.js
+++ b/app/assets/javascripts/issuable/constants.js
@@ -1,11 +1 @@
export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
-
-export const ISSUABLE_TYPE = {
- issues: 'issues',
- mergeRequests: 'merge-requests',
-};
-
-export const ISSUABLE_INDEX = {
- ISSUE: 'issue_',
- MERGE_REQUEST: 'merge_request_',
-};
diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js
index ed336deb2ed..acc0161bf6a 100644
--- a/app/assets/javascripts/issuable/index.js
+++ b/app/assets/javascripts/issuable/index.js
@@ -36,11 +36,9 @@ export function initCsvImportExportButtons() {
email,
exportCsvPath,
importCsvIssuesPath,
- containerClass,
canEdit,
projectImportJiraPath,
maxAttachmentSize,
- showLabel,
} = el.dataset;
return new Vue({
@@ -52,11 +50,9 @@ export function initCsvImportExportButtons() {
issuableType,
email,
importCsvIssuesPath,
- containerClass,
canEdit: parseBoolean(canEdit),
projectImportJiraPath,
maxAttachmentSize,
- showLabel,
},
render: (createElement) =>
createElement(CsvImportExportButtons, {
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
index 201782a201a..e5a2388580b 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { difference, intersection, union } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
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/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js
index ad8bbf04d6f..b4e277a0b31 100644
--- a/app/assets/javascripts/issuable/issuable_label_selector.js
+++ b/app/assets/javascripts/issuable/issuable_label_selector.js
@@ -1,11 +1,8 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import {
- DropdownVariant,
- LabelType,
-} from '~/sidebar/components/labels/labels_select_widget/constants';
-import { WorkspaceType } from '~/issues/constants';
+import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue';
Vue.use(VueApollo);
@@ -43,11 +40,11 @@ export default () => {
fullPath,
initialLabels: JSON.parse(initialLabels),
issuableType,
- labelType: LabelType.project,
+ labelType: WORKSPACE_PROJECT,
labelsFilterBasePath,
labelsManagePath,
- variant: DropdownVariant.Embedded,
- workspaceType: WorkspaceType.project,
+ variant: VARIANT_EMBEDDED,
+ workspaceType: WORKSPACE_PROJECT,
},
render(createElement) {
return createElement(IssuableLabelSelector);
diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
index 4a6edae0c06..289d6fdf372 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';
@@ -14,6 +15,11 @@ const mixins = {
type: Number,
required: true,
},
+ iid: {
+ type: Number,
+ required: false,
+ default: undefined,
+ },
displayReference: {
type: String,
required: true,
@@ -107,13 +113,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/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
index 92994809362..af93430963e 100644
--- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
@@ -1,14 +1,12 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlBadge, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
+import { __ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { mrStates, humanMRStates } from '../constants';
import query from '../queries/merge_request.query.graphql';
export default {
- // name: 'MRPopover' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
- name: 'MRPopover', // eslint-disable-line @gitlab/require-i18n-strings
components: {
GlBadge,
GlPopover,
@@ -48,9 +46,9 @@ export default {
},
badgeVariant() {
switch (this.mergeRequest.state) {
- case mrStates.merged:
+ case STATUS_MERGED:
return 'info';
- case mrStates.closed:
+ case STATUS_CLOSED:
return 'danger';
default:
return 'success';
@@ -58,12 +56,12 @@ export default {
},
stateHumanName() {
switch (this.mergeRequest.state) {
- case mrStates.merged:
- return humanMRStates.merged;
- case mrStates.closed:
- return humanMRStates.closed;
+ case STATUS_MERGED:
+ return __('Merged');
+ case STATUS_CLOSED:
+ return __('Closed');
default:
- return humanMRStates.open;
+ return __('Open');
}
},
title() {
@@ -101,7 +99,9 @@ export default {
<gl-badge class="gl-mr-3" :variant="badgeVariant">
{{ stateHumanName }}
</gl-badge>
- <span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span>
+ <span class="gl-text-secondary">
+ {{ __('Opened') }} <time v-text="formattedTime"></time
+ ></span>
</div>
<ci-icon v-if="detailedStatus" :status="detailedStatus" />
</div>
diff --git a/app/assets/javascripts/issuable/popover/constants.js b/app/assets/javascripts/issuable/popover/constants.js
deleted file mode 100644
index 352bc635293..00000000000
--- a/app/assets/javascripts/issuable/popover/constants.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { __ } from '~/locale';
-
-export const mrStates = {
- merged: 'merged',
- closed: 'closed',
- open: 'open',
-};
-
-export const humanMRStates = {
- merged: __('Merged'),
- closed: __('Closed'),
- open: __('Open'),
-};
diff --git a/app/assets/javascripts/issuable/popover/index.js b/app/assets/javascripts/issuable/popover/index.js
index de3c8160b7a..9430419685b 100644
--- a/app/assets/javascripts/issuable/popover/index.js
+++ b/app/assets/javascripts/issuable/popover/index.js
@@ -6,6 +6,7 @@ import MRPopover from './components/mr_popover.vue';
const componentsByReferenceType = {
issue: IssuePopover,
+ work_item: IssuePopover,
merge_request: MRPopover,
};
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index ba05dd731f7..c79612ad5d0 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -1,36 +1,33 @@
import { __ } from '~/locale';
+export const STATUS_ALL = 'all';
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;
+export const TYPE_ALERT = 'alert';
export const TYPE_EPIC = 'epic';
+export const TYPE_INCIDENT = 'incident';
export const TYPE_ISSUE = 'issue';
+export const TYPE_MERGE_REQUEST = 'merge_request';
+export const TYPE_TEST_CASE = 'test_case';
-export const IssuableStatusText = {
+export const WORKSPACE_GROUP = 'group';
+export const WORKSPACE_PROJECT = 'project';
+
+export const issuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
+ [STATUS_MERGED]: __('Merged'),
+ [STATUS_LOCKED]: __('Open'),
};
-// Deprecated - use individual constants instead like `TYPE_ISSUE` above
-export const IssuableType = {
- Issue: 'issue',
- Epic: 'epic',
- MergeRequest: 'merge_request',
- Alert: 'alert',
- TestCase: 'test_case',
-};
-
-export const IssueType = {
- Issue: 'issue',
- Incident: 'incident',
- TestCase: 'test_case',
-};
-
-export const WorkspaceType = {
- project: 'project',
- group: 'group',
+export const IssuableTypeText = {
+ [TYPE_ISSUE]: __('issue'),
+ [TYPE_MERGE_REQUEST]: __('merge request'),
};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 977a505437d..de0334b4ffe 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -7,10 +7,14 @@ import {
import confidentialMergeRequestState from '~/confidential_merge_request/state';
import DropLab from '~/filtered_search/droplab/drop_lab_deprecated';
import ISetter from '~/filtered_search/droplab/plugins/input_setter';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+import {
+ findInvalidBranchNameCharacters,
+ humanizeBranchValidationErrors,
+} from '~/lib/utils/text_utility';
import api from '~/api';
// Todo: Remove this when fixing issue in input_setter plugin
@@ -19,6 +23,12 @@ const InputSetter = { ...ISetter };
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
+const VALIDATION_TYPE_BRANCH_UNAVAILABLE = 'branch_unavailable';
+const VALIDATION_TYPE_INVALID_CHARS = 'invalid_chars';
+
+const INPUT_TARGET_BRANCH = 'branch';
+const INPUT_TARGET_REF = 'ref';
+
function createEndpoint(projectPath, endpoint) {
if (canCreateConfidentialMergeRequest()) {
return endpoint.replace(
@@ -30,6 +40,23 @@ function createEndpoint(projectPath, endpoint) {
return endpoint;
}
+function getValidationError(target, inputValue, validationType) {
+ const invalidChars = findInvalidBranchNameCharacters(inputValue.value);
+ let text;
+
+ if (invalidChars && validationType === VALIDATION_TYPE_INVALID_CHARS) {
+ text = humanizeBranchValidationErrors(invalidChars);
+ }
+
+ if (validationType === VALIDATION_TYPE_BRANCH_UNAVAILABLE) {
+ text =
+ target === INPUT_TARGET_BRANCH
+ ? __('Branch is already taken')
+ : __('Source is not available');
+ }
+
+ return text;
+}
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
@@ -124,18 +151,19 @@ export default class CreateMergeRequestDropdown {
.then(({ data }) => {
this.setUnavailableButtonState(false);
- if (data.can_create_branch) {
- this.available();
- this.enable();
- this.updateBranchName(data.suggested_branch_name);
-
- if (!this.droplabInitialized) {
- this.droplabInitialized = true;
- this.initDroplab();
- this.bindEvents();
- }
- } else {
+ if (!data.can_create_branch) {
this.hide();
+ return;
+ }
+
+ this.available();
+ this.enable();
+ this.updateBranchName(data.suggested_branch_name);
+
+ if (!this.droplabInitialized) {
+ this.droplabInitialized = true;
+ this.initDroplab();
+ this.bindEvents();
}
})
.catch(() => {
@@ -274,7 +302,7 @@ export default class CreateMergeRequestDropdown {
const tags = data[Object.keys(data)[1]];
let result;
- if (target === 'branch') {
+ if (target === INPUT_TARGET_BRANCH) {
result = CreateMergeRequestDropdown.findByValue(branches, ref);
} else {
result =
@@ -354,10 +382,10 @@ export default class CreateMergeRequestDropdown {
}
if (event.target === this.branchInput) {
- target = 'branch';
+ target = INPUT_TARGET_BRANCH;
({ value } = this.branchInput);
} else if (event.target === this.refInput) {
- target = 'ref';
+ target = INPUT_TARGET_REF;
if (event.target === document.activeElement) {
value =
event.target.value.slice(0, event.target.selectionStart) +
@@ -382,7 +410,7 @@ export default class CreateMergeRequestDropdown {
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
- if (target === 'branch') {
+ if (target === INPUT_TARGET_BRANCH) {
this.branchIsValid = true;
} else {
this.refIsValid = true;
@@ -404,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;
@@ -414,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();
}
@@ -473,7 +501,7 @@ export default class CreateMergeRequestDropdown {
showAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
- const text = target === 'branch' ? __('Branch name') : __('Source');
+ const text = target === INPUT_TARGET_BRANCH ? __('Branch name') : __('Source');
this.removeMessage(target);
input.classList.add('gl-field-success-outline');
@@ -484,7 +512,7 @@ export default class CreateMergeRequestDropdown {
showCheckingMessage(target) {
const { message } = this.getTargetData(target);
- const text = target === 'branch' ? __('branch name') : __('source');
+ const text = target === INPUT_TARGET_BRANCH ? __('branch name') : __('source');
this.removeMessage(target);
message.classList.add('gl-text-gray-600');
@@ -492,10 +520,9 @@ export default class CreateMergeRequestDropdown {
message.style.display = 'inline-block';
}
- showNotAvailableMessage(target) {
+ showNotAvailableMessage(target, validationType = VALIDATION_TYPE_BRANCH_UNAVAILABLE) {
const { input, message } = this.getTargetData(target);
- const text =
- target === 'branch' ? __('Branch is already taken') : __('Source is not available');
+ const text = getValidationError(target, input, validationType);
this.removeMessage(target);
input.classList.add('gl-field-error-outline');
@@ -511,35 +538,35 @@ export default class CreateMergeRequestDropdown {
updateBranchName(suggestedBranchName) {
this.branchInput.value = suggestedBranchName;
- this.updateCreatePaths('branch', suggestedBranchName);
+ this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, '');
+ this.updateCreatePaths(INPUT_TARGET_BRANCH, suggestedBranchName);
}
updateInputState(target, ref, result) {
// target - 'branch' or 'ref' - which the input field we are searching a ref for.
// ref - string - what a user typed.
// result - string - what has been found on backend.
+ if (target === INPUT_TARGET_BRANCH) this.updateTargetBranchInput(ref, result);
+ if (target === INPUT_TARGET_REF) this.updateRefInput(ref, result);
+
+ if (this.inputsAreValid()) {
+ this.enable();
+ } else {
+ this.disableCreateAction();
+ }
+ }
- // If a found branch equals exact the same text a user typed,
- // that means a new branch cannot be created as it already exists.
+ updateRefInput(ref, result) {
+ this.refInput.dataset.value = ref;
if (ref === result) {
- if (target === 'branch') {
- this.branchIsValid = false;
- this.showNotAvailableMessage('branch');
- } else {
- this.refIsValid = true;
- this.refInput.dataset.value = ref;
- this.showAvailableMessage('ref');
- this.updateCreatePaths(target, ref);
- }
- } else if (target === 'branch') {
- this.branchIsValid = true;
- this.showAvailableMessage('branch');
- this.updateCreatePaths(target, ref);
+ this.refIsValid = true;
+ this.showAvailableMessage(INPUT_TARGET_REF);
+ this.updateCreatePaths(INPUT_TARGET_REF, ref);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
this.disableCreateAction();
- this.showNotAvailableMessage('ref');
+ this.showNotAvailableMessage(INPUT_TARGET_REF);
// Show ref hint.
if (result) {
@@ -547,11 +574,24 @@ export default class CreateMergeRequestDropdown {
this.refInput.setSelectionRange(ref.length, result.length);
}
}
+ }
- if (this.inputsAreValid()) {
- this.enable();
+ updateTargetBranchInput(ref, result) {
+ const branchNameErrors = findInvalidBranchNameCharacters(ref);
+ const isInvalidString = branchNameErrors.length;
+ if (ref !== result && !isInvalidString) {
+ this.branchIsValid = true;
+ // If a found branch equals exact the same text a user typed,
+ // Or user typed input contains invalid chars,
+ // that means a new branch cannot be created as it already exists.
+ this.showAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_BRANCH_UNAVAILABLE);
+ this.updateCreatePaths(INPUT_TARGET_BRANCH, ref);
+ } else if (isInvalidString) {
+ this.branchIsValid = false;
+ this.showNotAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_INVALID_CHARS);
} else {
- this.disableCreateAction();
+ this.branchIsValid = false;
+ this.showNotAvailableMessage(INPUT_TARGET_BRANCH);
}
}
@@ -569,6 +609,7 @@ export default class CreateMergeRequestDropdown {
pathReplacement,
);
+ this.wrapperEl.dataset.createBranchPath = this.createBranchPath;
this.wrapperEl.dataset.createMrPath = this.createMrPath;
}
}
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index a4a2feba716..b9e4d0df3f2 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -1,15 +1,19 @@
<script>
-import { GlButton, GlEmptyState, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlEmptyState,
+ GlFilteredSearchToken,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
-import { STATUS_CLOSED } from '~/issues/constants';
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import {
CREATED_DESC,
defaultTypeTokenOptions,
i18n,
- PAGE_SIZE,
PARAM_STATE,
UPDATED_DESC,
urlSortParams,
@@ -49,7 +53,7 @@ import {
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
import getIssuesCountsQuery from '../queries/get_issues_counts.query.graphql';
import { AutocompleteCache } from '../utils';
@@ -63,9 +67,9 @@ const MilestoneToken = () =>
export default {
i18n,
- IssuableListTabs,
+ issuableListTabs,
components: {
- GlButton,
+ GlDisclosureDropdown,
GlEmptyState,
IssuableList,
IssueCardStatistics,
@@ -93,7 +97,7 @@ export default {
data() {
const state = getParameterByName(PARAM_STATE);
- const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(this.initialSort);
const graphQLSortKey =
isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase();
@@ -110,7 +114,7 @@ export default {
pageInfo: {},
pageParams: getInitialPageParams(),
sortKey,
- state: state || IssuableStates.Opened,
+ state: state || STATUS_OPEN,
};
},
apollo: {
@@ -132,7 +136,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -149,7 +152,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -159,6 +161,12 @@ export default {
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
+ dropdownItems() {
+ return [
+ { href: this.rssPath, text: i18n.rssLabel },
+ { href: this.calendarPath, text: i18n.calendarLabel },
+ ];
+ },
emptyStateDescription() {
return this.hasSearch ? this.$options.i18n.noSearchResultsDescription : undefined;
},
@@ -179,7 +187,6 @@ export default {
return {
hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
isSignedIn: this.isSignedIn,
- search: this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
@@ -314,9 +321,9 @@ export default {
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
- [IssuableStates.Opened]: openedIssues?.count,
- [IssuableStates.Closed]: closedIssues?.count,
- [IssuableStates.All]: allIssues?.count,
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
};
},
urlFilterParams() {
@@ -324,7 +331,6 @@ export default {
},
urlParams() {
return {
- search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
@@ -388,14 +394,14 @@ export default {
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
- firstPageSize: PAGE_SIZE,
+ firstPageSize: DEFAULT_PAGE_SIZE,
};
scrollUp();
},
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
- lastPageSize: PAGE_SIZE,
+ lastPageSize: DEFAULT_PAGE_SIZE,
};
scrollUp();
},
@@ -449,7 +455,7 @@ export default {
show-work-item-type-icon
:sort-options="sortOptions"
:tab-counts="tabCounts"
- :tabs="$options.IssuableListTabs"
+ :tabs="$options.issuableListTabs"
truncate-counts
:url-params="urlParams"
use-keyset-pagination
@@ -461,12 +467,15 @@ export default {
@sort="handleSort"
>
<template #nav-actions>
- <gl-button :href="rssPath" icon="rss">
- {{ $options.i18n.rssLabel }}
- </gl-button>
- <gl-button :href="calendarPath" icon="calendar">
- {{ $options.i18n.calendarLabel }}
- </gl-button>
+ <gl-disclosure-dropdown
+ v-gl-tooltip="$options.i18n.actionsLabel"
+ category="tertiary"
+ icon="ellipsis_v"
+ :items="dropdownItems"
+ no-caret
+ text-sr-only
+ :toggle-text="$options.i18n.actionsLabel"
+ />
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 5b5f1d273d0..4d2df9e3602 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 { IssueType } from '~/issues/constants';
+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,19 +36,18 @@ 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();
}
-export function initShow() {
+export function initShow({ notesParams } = {}) {
const el = document.getElementById('js-issuable-app');
if (!el) {
@@ -59,11 +56,11 @@ export function initShow() {
const { issueType, ...issuableData } = parseIssuableData(el);
- if (issueType === IssueType.Incident) {
+ if (issueType === TYPE_INCIDENT) {
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store);
- initHeaderActions(store, IssueType.Incident);
+ initHeaderActions(store, TYPE_INCIDENT);
initLinkedResources();
- initRelatedIssues(IssueType.Incident);
+ initRelatedIssues(TYPE_INCIDENT);
} else {
initIssueApp(issuableData, store);
initHeaderActions(store);
@@ -74,7 +71,7 @@ export function initShow() {
new ZenMode(); // eslint-disable-line no-new
initIssuableHeaderWarnings(store);
initIssuableSidebar();
- initNotesApp();
+ initNotesApp(notesParams);
initRelatedMergeRequests();
initSentryErrorStackTrace();
diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js
index de1c689e590..b7fd99d8042 100644
--- a/app/assets/javascripts/issues/issue.js
+++ b/app/assets/javascripts/issues/issue.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 652d4e0fb42..98429f3ffd1 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
@@ -12,6 +12,7 @@ export default {
components: {
CsvImportExportButtons,
GlButton,
+ GlDropdown,
GlEmptyState,
GlLink,
GlSprintf,
@@ -71,12 +72,19 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
- <csv-import-export-buttons
+
+ <gl-dropdown
v-if="showCsvButtons"
class="gl-w-full gl-sm-w-auto gl-sm-mr-3"
- :export-csv-path="exportCsvPathWithQuery"
- :issuable-count="currentTabCount"
- />
+ :text="$options.i18n.importIssues"
+ data-qa-selector="import_issues_dropdown"
+ >
+ <csv-import-export-buttons
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="currentTabCount"
+ />
+ </gl-dropdown>
+
<new-resource-dropdown
v-if="showNewIssueDropdown"
class="gl-align-self-center"
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..dde1a4fd2d6 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
@@ -74,11 +74,16 @@ export default {
<span>
<span
v-if="issue.milestone"
- class="issuable-milestone gl-mr-3"
+ class="issuable-milestone gl-mr-3 gl-text-truncate gl-max-w-26 gl-display-inline-block gl-vertical-align-bottom"
data-testid="issuable-milestone"
>
- <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
- <gl-icon name="clock" />
+ <gl-link
+ v-gl-tooltip
+ :href="milestoneLink"
+ :title="milestoneDate"
+ class="gl-font-sm gl-text-gray-500!"
+ >
+ <gl-icon name="clock" :size="12" />
{{ issue.milestone.title }}
</gl-link>
</span>
@@ -90,7 +95,7 @@ export default {
:title="__('Due date')"
data-testid="issuable-due-date"
>
- <gl-icon name="calendar" />
+ <gl-icon name="calendar" :size="12" />
{{ dueDate }}
</span>
<span
@@ -100,7 +105,7 @@ export default {
:title="__('Estimate')"
data-testid="time-estimate"
>
- <gl-icon name="timer" />
+ <gl-icon name="timer" :size="12" />
{{ timeEstimate }}
</span>
<slot></slot>
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index 6c46013e4f9..5fb83dfd1ab 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -1,18 +1,31 @@
<script>
-import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFilteredSearchToken,
+ GlTooltipDirective,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { isEmpty } from 'lodash';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
-import { STATUS_CLOSED } from '~/issues/constants';
+import {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_OPEN,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { fetchPolicies } from '~/lib/graphql';
import { isPositiveInteger } from '~/lib/utils/number_utils';
@@ -46,7 +59,7 @@ import {
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
@@ -56,7 +69,6 @@ import {
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
- PAGE_SIZE,
PARAM_FIRST_PAGE_SIZE,
PARAM_LAST_PAGE_SIZE,
PARAM_PAGE_AFTER,
@@ -103,12 +115,15 @@ const CrmOrganizationToken = () =>
export default {
i18n,
- IssuableListTabs,
+ issuableListTabs,
components: {
CsvImportExportButtons,
EmptyStateWithAnyIssues,
EmptyStateWithoutAnyIssues,
GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
IssuableByEmail,
IssuableList,
IssueCardStatistics,
@@ -177,8 +192,8 @@ export default {
pageParams: {},
showBulkEditSidebar: false,
sortKey: CREATED_DESC,
- state: IssuableStates.Opened,
- pageSize: PAGE_SIZE,
+ state: STATUS_OPEN,
+ pageSize: DEFAULT_PAGE_SIZE,
};
},
apollo: {
@@ -206,9 +221,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -223,9 +237,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -240,16 +253,16 @@ export default {
iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
- search: isIidSearch ? undefined : this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
...this.apiFilterParams,
+ search: isIidSearch ? undefined : this.searchQuery,
types: this.apiFilterParams.types || this.defaultWorkItemTypes,
};
},
namespace() {
- return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
defaultWorkItemTypes() {
return [...defaultWorkItemTypes, ...this.eeWorkItemTypes];
@@ -275,7 +288,7 @@ export default {
return this.sortKey === RELATIVE_POSITION_ASC;
},
isOpenTab() {
- return this.state === IssuableStates.Opened;
+ return this.state === STATUS_OPEN;
},
showCsvButtons() {
return this.isProject && this.isSignedIn;
@@ -449,7 +462,7 @@ export default {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
showPageSizeControls() {
- return this.currentTabCount > PAGE_SIZE;
+ return this.currentTabCount > DEFAULT_PAGE_SIZE;
},
sortOptions() {
return getSortOptions({
@@ -461,9 +474,9 @@ export default {
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
- [IssuableStates.Opened]: openedIssues?.count,
- [IssuableStates.Closed]: closedIssues?.count,
- [IssuableStates.All]: allIssues?.count,
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
};
},
currentTabCount() {
@@ -471,7 +484,6 @@ export default {
},
urlParams() {
return {
- search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
@@ -726,7 +738,7 @@ export default {
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
const state = getParameterByName(PARAM_STATE);
- const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(sortValue);
const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase();
@@ -750,7 +762,7 @@ export default {
getParameterByName(PARAM_PAGE_BEFORE),
);
this.sortKey = sortKey;
- this.state = state || IssuableStates.Opened;
+ this.state = state || STATUS_OPEN;
},
},
};
@@ -771,7 +783,7 @@ export default {
:issuables="issues"
:error="issuesError"
label-filter-param="label_name"
- :tabs="$options.IssuableListTabs"
+ :tabs="$options.issuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:truncate-counts="!isProject"
@@ -799,26 +811,6 @@ export default {
>
<template #nav-actions>
<gl-button
- v-gl-tooltip
- :href="rssPath"
- icon="rss"
- :title="$options.i18n.rssLabel"
- :aria-label="$options.i18n.rssLabel"
- />
- <gl-button
- v-gl-tooltip
- :href="calendarPath"
- icon="calendar"
- :title="$options.i18n.calendarLabel"
- :aria-label="$options.i18n.calendarLabel"
- />
- <csv-import-export-buttons
- v-if="showCsvButtons"
- class="gl-md-mr-3"
- :export-csv-path="exportCsvPathWithQuery"
- :issuable-count="currentTabCount"
- />
- <gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
@click="handleBulkUpdateClick"
@@ -839,6 +831,30 @@ export default {
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
/>
+ <gl-dropdown
+ v-gl-tooltip.hover="$options.i18n.actionsLabel"
+ category="tertiary"
+ icon="ellipsis_v"
+ no-caret
+ :text="$options.i18n.actionsLabel"
+ text-sr-only
+ data-qa-selector="issues_list_more_actions_dropdown"
+ >
+ <csv-import-export-buttons
+ v-if="showCsvButtons"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="currentTabCount"
+ />
+
+ <gl-dropdown-divider v-if="showCsvButtons" />
+
+ <gl-dropdown-item :href="rssPath">
+ {{ $options.i18n.rssLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="calendarPath">
+ {{ $options.i18n.calendarLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 31a43c95f5e..56d3a57457b 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -5,6 +5,7 @@ import {
FILTER_NONE,
FILTER_STARTED,
FILTER_UPCOMING,
+ FILTERED_SEARCH_TERM,
OPERATOR_IS,
OPERATOR_NOT,
OPERATOR_OR,
@@ -33,7 +34,6 @@ import {
export const ISSUE_REFERENCE = /^#\d+$/;
export const MAX_LIST_SIZE = 10;
-export const PAGE_SIZE = 20;
export const PARAM_ASSIGNEE_ID = 'assignee_id';
export const PARAM_FIRST_PAGE_SIZE = 'first_page_size';
export const PARAM_LAST_PAGE_SIZE = 'last_page_size';
@@ -76,15 +76,17 @@ export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const i18n = {
+ actionsLabel: __('Actions'),
calendarLabel: __('Subscribe to calendar'),
- closed: __('CLOSED'),
- closedMoved: __('CLOSED (MOVED)'),
+ closed: __('Closed'),
+ closedMoved: __('Closed (moved)'),
confidentialNo: __('No'),
confidentialYes: __('Yes'),
downvotes: __('Downvotes'),
- editIssues: __('Edit issues'),
+ editIssues: __('Bulk edit'),
errorFetchingCounts: __('An error occurred while getting issue counts'),
errorFetchingIssues: __('An error occurred while loading issues'),
+ importIssues: __('Import issues'),
issueRepositioningMessage: __(
'Issues are being rebalanced at the moment, so manual reordering is disabled.',
),
@@ -154,13 +156,13 @@ export const specialFilterValues = [
export const TYPE_TOKEN_OBJECTIVE_OPTION = {
icon: 'issue-type-objective',
- title: 'objective',
+ title: s__('WorkItem|Objective'),
value: 'objective',
};
export const TYPE_TOKEN_KEY_RESULT_OPTION = {
icon: 'issue-type-keyresult',
- title: 'key_result',
+ title: s__('WorkItem|Key Result'),
value: 'key_result',
};
@@ -174,13 +176,23 @@ export const defaultWorkItemTypes = [
];
export const defaultTypeTokenOptions = [
- { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
- { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
- { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
- { icon: 'issue-type-task', title: 'task', value: 'task' },
+ { icon: 'issue-type-issue', title: s__('WorkItem|Issue'), value: 'issue' },
+ { icon: 'issue-type-incident', title: s__('WorkItem|Incident'), value: 'incident' },
+ { icon: 'issue-type-test-case', title: s__('WorkItem|Test case'), value: 'test_case' },
+ { icon: 'issue-type-task', title: s__('WorkItem|Task'), value: 'task' },
];
-export const filters = {
+export const filtersMap = {
+ [FILTERED_SEARCH_TERM]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ [URL_PARAM]: {
+ [undefined]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ },
+ },
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'authorUsername',
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/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index bbd081843ca..d053400dd03 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -1,4 +1,3 @@
-import { createTerm } from '@gitlab/ui/src/components/base/filtered_search/filtered_search_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -16,6 +15,7 @@ import {
TOKEN_TYPE_HEALTH,
TOKEN_TYPE_LABEL,
} from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import {
ALTERNATIVE_FILTER,
API_PARAM,
@@ -27,7 +27,7 @@ import {
CREATED_DESC,
DUE_DATE_ASC,
DUE_DATE_DESC,
- filters,
+ filtersMap,
HEALTH_STATUS_ASC,
HEALTH_STATUS_DESC,
LABEL_PRIORITY_ASC,
@@ -35,7 +35,6 @@ import {
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
- PAGE_SIZE,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@@ -56,7 +55,7 @@ import {
export const getInitialPageParams = (
pageSize,
- firstPageSize = pageSize ?? PAGE_SIZE,
+ firstPageSize = pageSize ?? DEFAULT_PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,
@@ -196,10 +195,10 @@ export const getSortOptions = ({
return sortOptions;
};
-const tokenTypes = Object.keys(filters);
+const tokenTypes = Object.keys(filtersMap);
const getUrlParams = (tokenType) =>
- Object.values(filters[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj));
+ Object.values(filtersMap[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj));
const urlParamKeys = tokenTypes.flatMap(getUrlParams);
@@ -207,11 +206,11 @@ const getTokenTypeFromUrlParamKey = (urlParamKey) =>
tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey));
const getOperatorFromUrlParamKey = (tokenType, urlParamKey) =>
- Object.entries(filters[tokenType][URL_PARAM]).find(([, filterObj]) =>
+ Object.entries(filtersMap[tokenType][URL_PARAM]).find(([, filterObj]) =>
Object.values(filterObj).includes(urlParamKey),
)[0];
-const convertToFilteredTokens = (locationSearch) =>
+export const getFilterTokens = (locationSearch) =>
Array.from(new URLSearchParams(locationSearch).entries())
.filter(([key]) => urlParamKeys.includes(key))
.map(([key, data]) => {
@@ -223,26 +222,8 @@ const convertToFilteredTokens = (locationSearch) =>
};
});
-const convertToFilteredSearchTerms = (locationSearch) =>
- new URLSearchParams(locationSearch)
- .get('search')
- ?.split(' ')
- .map((word) => ({
- type: FILTERED_SEARCH_TERM,
- value: {
- data: word,
- },
- })) || [];
-
-export const getFilterTokens = (locationSearch) => {
- if (!locationSearch) {
- return [createTerm()];
- }
- const filterTokens = convertToFilteredTokens(locationSearch);
- const searchTokens = convertToFilteredSearchTerms(locationSearch);
- const tokens = filterTokens.concat(searchTokens);
- return tokens.length ? tokens : [createTerm()];
-};
+const isNotEmptySearchToken = (token) =>
+ !(token.type === FILTERED_SEARCH_TERM && !token.value.data);
const isSpecialFilter = (type, data) => {
const isAssigneeIdParam =
@@ -289,50 +270,48 @@ const formatData = (token) => {
};
export const convertToApiParams = (filterTokens) => {
- const params = {};
- const not = {};
- const or = {};
+ const params = new Map();
+ const not = new Map();
+ const or = new Map();
- filterTokens
- .filter((token) => token.type !== FILTERED_SEARCH_TERM)
- .forEach((token) => {
- const filterType = getFilterType(token);
- const apiField = filters[token.type][API_PARAM][filterType];
- let obj;
- if (token.value.operator === OPERATOR_NOT) {
- obj = not;
- } else if (token.value.operator === OPERATOR_OR) {
- obj = or;
- } else {
- obj = params;
- }
- const data = formatData(token);
- Object.assign(obj, {
- [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data,
- });
- });
+ filterTokens.filter(isNotEmptySearchToken).forEach((token) => {
+ const filterType = getFilterType(token);
+ const apiField = filtersMap[token.type][API_PARAM][filterType];
+ let obj;
+ if (token.value.operator === OPERATOR_NOT) {
+ obj = not;
+ } else if (token.value.operator === OPERATOR_OR) {
+ obj = or;
+ } else {
+ obj = params;
+ }
+ const data = formatData(token);
+ obj.set(apiField, obj.has(apiField) ? [obj.get(apiField), data].flat() : data);
+ });
- if (Object.keys(not).length) {
- Object.assign(params, { not });
+ if (not.size) {
+ params.set('not', Object.fromEntries(not));
}
- if (Object.keys(or).length) {
- Object.assign(params, { or });
+ if (or.size) {
+ params.set('or', Object.fromEntries(or));
}
- return params;
+ return Object.fromEntries(params);
};
-export const convertToUrlParams = (filterTokens) =>
- filterTokens
- .filter((token) => token.type !== FILTERED_SEARCH_TERM)
- .reduce((acc, token) => {
- const filterType = getFilterType(token);
- const urlParam = filters[token.type][URL_PARAM][token.value.operator]?.[filterType];
- return Object.assign(acc, {
- [urlParam]: acc[urlParam] ? [acc[urlParam], token.value.data].flat() : token.value.data,
- });
- }, {});
+export const convertToUrlParams = (filterTokens) => {
+ const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => {
+ const filterType = getFilterType(token);
+ const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType];
+ return acc.set(
+ urlParam,
+ acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data,
+ );
+ }, new Map());
+
+ return Object.fromEntries(urlParamsMap);
+};
export const convertToSearchQuery = (filterTokens) =>
filterTokens
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index 1bb53dfd50d..f22062cf048 100644
--- a/app/assets/javascripts/issues/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -1,5 +1,5 @@
import Sortable from 'sortablejs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils';
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/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 149049247fb..8490ffd33cd 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -65,10 +65,10 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div class="card card-slim gl-mt-5 gl-mb-0">
- <div class="card-header gl-bg-gray-10">
+ <div class="card card-slim gl-mt-5 gl-mb-0 gl-bg-gray-10">
+ <div class="card-header gl-px-5 gl-py-4 gl-bg-white">
<div
- class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
+ class="card-title gl-relative gl-display-flex gl-flex-wrap gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
>
<gl-link
class="anchor gl-absolute gl-text-decoration-none"
@@ -79,19 +79,29 @@ export default {
{{ __('Related merge requests') }}
</h3>
<template v-if="totalCount">
- <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" />
- <span data-testid="count">{{ totalCount }}</span>
+ <gl-icon name="merge-request" class="gl-ml-3 gl-mr-2 gl-text-gray-500" />
+ <span data-testid="count" class="gl-text-gray-500">{{ totalCount }}</span>
</template>
+ <p
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="gl-font-sm gl-font-weight-normal gl-flex-basis-full gl-mb-0 gl-text-gray-500"
+ >
+ {{ closingMergeRequestsText }}
+ </p>
</div>
</div>
<gl-loading-icon
v-if="isFetchingMergeRequests"
size="sm"
label="Fetching related merge requests"
- class="gl-py-3"
+ class="gl-py-4"
/>
- <ul v-else class="content-list related-items-list">
- <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-0!">
+ <ul v-else class="content-list related-items-list gl-px-4! gl-py-3!">
+ <li
+ v-for="mr in mergeRequests"
+ :key="mr.id"
+ class="list-item gl-m-0! gl-p-0! gl-border-b-0!"
+ >
<related-issuable-item
:id-key="mr.id"
:display-reference="mr.reference"
@@ -106,15 +116,10 @@ export default {
:is-merge-request="true"
:pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
path-id-separator="!"
+ class="gl-mx-n2"
/>
</li>
</ul>
</div>
- <div
- v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
- class="issue-closed-by-widget second-block gl-mt-3"
- >
- {{ closingMergeRequestsText }}
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
index 4c81f1d9bc1..ad5b61424dc 100644
--- a/app/assets/javascripts/issues/related_merge_requests/store/actions.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index decb559ee81..86311b99f7c 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -1,19 +1,21 @@
<script>
import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
- IssuableStatusText,
+ issuableStatusText,
STATUS_CLOSED,
TYPE_EPIC,
+ TYPE_INCIDENT,
TYPE_ISSUE,
- WorkspaceType,
+ WORKSPACE_PROJECT,
} from '~/issues/constants';
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 { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, INCIDENT_TYPE, POLLING_DELAY } from '../constants';
+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';
import Service from '../services/index';
@@ -25,7 +27,7 @@ import PinnedLinks from './pinned_links.vue';
import TitleComponent from './title.vue';
export default {
- WorkspaceType,
+ WORKSPACE_PROJECT,
components: {
GlIcon,
GlBadge,
@@ -52,11 +54,6 @@ export default {
required: true,
type: Boolean,
},
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: true,
- },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -99,10 +96,10 @@ export default {
required: false,
default: '',
},
- initialTaskStatus: {
- type: String,
+ initialTaskCompletionStatus: {
+ type: Object,
required: false,
- default: '',
+ default: () => ({}),
},
updatedAt: {
type: String,
@@ -191,11 +188,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
},
data() {
const store = new Store({
@@ -206,7 +198,7 @@ export default {
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
- taskStatus: this.initialTaskStatus,
+ taskCompletionStatus: this.initialTaskCompletionStatus,
lock_version: this.lockVersion,
});
@@ -231,9 +223,6 @@ export default {
formState() {
return this.store.formState;
},
- hasUpdated() {
- return Boolean(this.state.updatedAt);
- },
issueChanged() {
const {
store: {
@@ -274,14 +263,14 @@ export default {
return this.isClosed ? 'info' : 'success';
},
statusText() {
- return IssuableStatusText[this.issuableStatus];
+ return issuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
},
},
created() {
- this.flashContainer = null;
+ this.alert = null;
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
@@ -388,7 +377,7 @@ export default {
this.showForm = false;
},
- updateIssuable() {
+ async updateIssuable() {
this.setFormState({ updateLoading: true });
const {
@@ -399,7 +388,15 @@ export default {
? { ...formState, issue_type: issueState.issueType }
: formState;
- this.clearFlash();
+ 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)
@@ -407,14 +404,14 @@ export default {
.then((data) => {
if (
!window.location.pathname.includes(data.web_url) &&
- issueState.issueType !== INCIDENT_TYPE
+ issueState.issueType !== TYPE_INCIDENT
) {
visitUrl(data.web_url);
}
if (issueState.isDirty) {
const URI =
- issueState.issueType === INCIDENT_TYPE
+ issueState.issueType === TYPE_INCIDENT
? data.web_url.replace(ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH)
: data.web_url;
visitUrl(URI);
@@ -435,7 +432,7 @@ export default {
errMsg += `. ${message}`;
}
- this.flashContainer = createAlert({
+ this.alert = createAlert({
message: errMsg,
});
})
@@ -452,13 +449,6 @@ export default {
this.isStickyHeaderShowing = true;
},
- clearFlash() {
- if (this.flashContainer) {
- this.flashContainer.close();
- this.flashContainer = null;
- }
- },
-
handleSaveDescription(description) {
this.updateFormState();
this.setFormState({ description });
@@ -499,6 +489,7 @@ export default {
:project-namespace="projectNamespace"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
+ :issue-id="issueId"
:issuable-type="issuableType"
@updateForm="setFormState"
/>
@@ -509,7 +500,6 @@ export default {
:can-update="canUpdate"
:title-html="state.titleHtml"
:title-text="state.titleText"
- :show-inline-edit-button="showInlineEditButton"
/>
<gl-intersection-observer
@@ -538,7 +528,7 @@ export default {
<confidentiality-badge
v-if="isConfidential"
data-testid="confidential"
- :workspace-type="$options.WorkspaceType.project"
+ :workspace-type="$options.WORKSPACE_PROJECT"
:issuable-type="issuableType"
/>
<span
@@ -570,12 +560,10 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
- :issue-iid="issueIid"
:can-update="canUpdate"
: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"
@@ -588,7 +576,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/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
index f86ee11e64b..26e82f10c3d 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
+import { TYPE_EPIC } from '~/issues/constants';
import csrf from '~/lib/utils/csrf';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
@@ -40,7 +41,7 @@ export default {
};
},
bodyText() {
- return this.issueType.toLowerCase() === 'epic'
+ return this.issueType.toLowerCase() === TYPE_EPIC
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: capitalizeFirstCharacter(this.issueType),
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index bca895bf764..3721f224d5e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,35 +1,25 @@
<script>
-import { GlModalDirective, GlToast } from '@gitlab/ui';
-import $ from 'jquery';
-import { uniqueId } from 'lodash';
+import { GlToast } from '@gitlab/ui';
import Sortable from 'sortablejs';
import Vue from 'vue';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { createAlert } from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
-import { isMetaKey } from '~/lib/utils/common_utils';
-import { isPositiveInteger } from '~/lib/utils/number_utils';
-import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
-import Tracking from '~/tracking';
import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
I18N_WORK_ITEM_ERROR_DELETING,
- TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
} from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
@@ -51,12 +41,8 @@ const workItemTypes = {
export default {
directives: {
SafeHtml,
- GlModal: GlModalDirective,
},
- components: {
- WorkItemDetailModal,
- },
- mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [animateMixin],
inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
@@ -72,11 +58,6 @@ export default {
required: false,
default: '',
},
- taskStatus: {
- type: String,
- required: false,
- default: '',
- },
issuableType: {
type: String,
required: false,
@@ -97,11 +78,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
isUpdating: {
type: Boolean,
required: false,
@@ -109,18 +85,12 @@ export default {
},
},
data() {
- const workItemId = getParameterByName('work_item_id');
-
return {
hasTaskListItemActions: false,
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
issueDetails: {},
- activeTask: {},
- workItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
- : undefined,
workItemTypes: [],
};
},
@@ -129,21 +99,12 @@ export default {
query: getIssueDetailsQuery,
variables() {
return {
- fullPath: this.fullPath,
- iid: String(this.issueIid),
- };
- },
- update: (data) => data.workspace?.issuable,
- },
- workItem: {
- query: workItemQuery,
- variables() {
- return {
- id: this.workItemId,
+ id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId),
};
},
+ update: (data) => data.issue,
skip() {
- return !this.workItemId;
+ return !this.canUpdate || !this.issueId;
},
},
workItemTypes: {
@@ -156,10 +117,13 @@ export default {
update(data) {
return data.workspace?.workItemTypes?.nodes;
},
+ skip() {
+ return !this.canUpdate;
+ },
},
},
computed: {
- taskWorkItemType() {
+ taskWorkItemTypeId() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
issueGid() {
@@ -168,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;
@@ -178,22 +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();
- if (this.workItemId) {
- const taskLink = this.$el.querySelector(
- `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`,
- );
- this.openWorkItemDetailModal(taskLink);
- }
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
@@ -226,10 +183,10 @@ export default {
},
renderSortableLists() {
// We exclude GLFM table of contents which have a `section-nav` class on the root `ul`.
- const lists = document.querySelectorAll(
+ const lists = this.$el.querySelectorAll?.(
'.description .md > ul:not(.section-nav), .description .md > ul:not(.section-nav) ul, .description ol',
);
- lists.forEach((list) => {
+ lists?.forEach((list) => {
if (list.children.length <= 1) {
return;
}
@@ -318,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'),
@@ -358,59 +297,17 @@ export default {
this.$emit('saveDescription', newDescription);
},
renderTaskListItemActions() {
- if (!this.$el?.querySelectorAll) {
- return;
- }
-
- const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
-
- taskListFields.forEach((item) => {
- const taskLink = item.querySelector('.gfm-issue');
- if (taskLink) {
- const { issue, referenceType, issueType } = taskLink.dataset;
- if (issueType !== workItemTypes.TASK) {
- return;
- }
- const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue);
- this.addHoverListeners(taskLink, workItemId);
- taskLink.classList.add('gl-link');
- taskLink.addEventListener('click', (e) => {
- if (isMetaKey(e)) {
- return;
- }
- e.preventDefault();
- this.openWorkItemDetailModal(taskLink);
- this.workItemId = workItemId;
- this.updateWorkItemIdUrlQuery(issue);
- this.track('viewed_work_item_from_modal', {
- category: TRACKING_CATEGORY_SHOW,
- label: 'work_item_view',
- property: `type_${referenceType}`,
- });
- });
- return;
- }
+ const taskListItems = this.$el.querySelectorAll?.(
+ '.task-list-item:not(.inapplicable, table .task-list-item)',
+ );
- const toggleClass = uniqueId('task-list-item-actions-');
- const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass });
- this.addPointerEventListeners(item, `.${toggleClass}`);
+ taskListItems?.forEach((item) => {
+ const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate });
this.insertNextToTaskListItemText(dropdown, item);
+ this.addPointerEventListeners(item, '.task-list-item-actions');
this.hasTaskListItemActions = true;
});
},
- addHoverListeners(taskLink, id) {
- let workItemPrefetch;
- taskLink.addEventListener('mouseover', () => {
- workItemPrefetch = setTimeout(() => {
- this.workItemId = id;
- }, 150);
- });
- taskLink.addEventListener('mouseout', () => {
- if (workItemPrefetch) {
- clearTimeout(workItemPrefetch);
- }
- });
- },
insertNextToTaskListItemText(element, listItem) {
const children = Array.from(listItem.children);
const paragraph = children.find((el) => el.tagName === 'P');
@@ -427,26 +324,8 @@ export default {
listItem.append(element);
}
},
- setActiveTask(el) {
- const { parentElement } = el;
- const lineNumbers = parentElement.dataset.sourcepos.match(/\b\d+(?=:)/g);
- this.activeTask = {
- title: parentElement.innerText,
- lineNumberStart: lineNumbers[0],
- lineNumberEnd: lineNumbers[1],
- };
- },
- openWorkItemDetailModal(el) {
- if (!el) {
- return;
- }
-
- this.setActiveTask(el);
- this.$refs.detailsModal.show();
- },
- closeWorkItemDetailModal() {
- this.workItemId = undefined;
- this.updateWorkItemIdUrlQuery(undefined);
+ stripClientState(description) {
+ return description.replaceAll('<details open="true">', '<details>');
},
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
@@ -468,7 +347,7 @@ export default {
},
projectPath: this.fullPath,
title,
- workItemTypeId: this.taskWorkItemType,
+ workItemTypeId: this.taskWorkItemTypeId,
};
const { data } = await this.$apollo.mutate({
@@ -532,16 +411,6 @@ export default {
captureError: true,
});
},
- handleDeleteTask(description) {
- this.$emit('updateDescription', description);
- this.$toast.show(s__('WorkItem|Task deleted'));
- },
- updateWorkItemIdUrlQuery(workItemId) {
- updateHistory({
- url: setUrlParams({ work_item_id: workItemId }),
- replace: true,
- });
- },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
@@ -569,16 +438,5 @@ export default {
data-testid="textarea"
>
</textarea>
- <work-item-detail-modal
- ref="detailsModal"
- :can-update="canUpdate"
- :work-item-id="workItemId"
- :issue-gid="issueGid"
- :lock-version="lockVersion"
- :line-number-start="activeTask.lineNumberStart"
- :line-number-end="activeTask.lineNumberEnd"
- @workItemDeleted="handleDeleteTask"
- @close="closeWorkItemDetailModal"
- />
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 120034b8d67..608e9aec1d7 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -1,17 +1,10 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
-const issuableTypes = {
- issue: __('Issue'),
- epic: __('Epic'),
- incident: __('Incident'),
-};
-
const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default {
@@ -55,11 +48,6 @@ export default {
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
- typeToShow() {
- const { issueState, issuableType } = this;
- const type = issueState.issueType ?? issuableType;
- return issuableTypes[type];
- },
},
methods: {
closeForm() {
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/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 3bc24e8ce01..c8ea8fb7ab2 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -75,7 +75,6 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
supports-quick-actions
- use-bottom-toolbar
autofocus
@input="$emit('input', $event)"
@keydown.meta.enter="updateIssuable"
@@ -83,6 +82,7 @@ export default {
/>
<markdown-field
v-else
+ class="gl-mt-3"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index 98f92c97f77..380b676cbbb 100644
--- a/app/assets/javascripts/issues/show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -60,7 +60,7 @@ export default {
:data-project-path="projectPath"
:data-project-id="projectId"
:data-data="issuableTemplatesJson"
- class="dropdown-menu-toggle js-issuable-selector gl-button"
+ class="dropdown-menu-toggle js-issuable-selector gl-button gl-py-3!"
type="button"
data-field-name="issuable_template"
data-selected="null"
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 5ade1a86d30..775f25bdbc0 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -1,8 +1,8 @@
<script>
-import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { GlFormGroup, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
+import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
-import { issuableTypes, INCIDENT_TYPE } from '../../constants';
+import { issuableTypes } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
@@ -16,7 +16,7 @@ export default {
components: {
GlFormGroup,
GlIcon,
- GlListbox,
+ GlCollapsibleListbox,
},
inject: {
canCreateIncident: {
@@ -46,7 +46,7 @@ export default {
},
computed: {
shouldShowIncident() {
- return this.issueType === INCIDENT_TYPE || this.canCreateIncident;
+ return this.issueType === TYPE_INCIDENT || this.canCreateIncident;
},
},
methods: {
@@ -60,7 +60,7 @@ export default {
});
},
isShown(type) {
- return type.value !== INCIDENT_TYPE || this.shouldShowIncident;
+ return type.value !== TYPE_INCIDENT || this.shouldShowIncident;
},
},
};
@@ -73,7 +73,7 @@ export default {
label-for="issuable-type"
class="mb-2 mb-md-0"
>
- <gl-listbox
+ <gl-collapsible-listbox
v-model="selectedIssueType"
toggle-class="gl-mb-0"
:items="$options.issuableTypes"
@@ -88,6 +88,6 @@ export default {
{{ item.text }}
</span>
</template>
- </gl-listbox>
+ </gl-collapsible-listbox>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index bcea9cf57a7..2e99c03d250 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,7 +1,11 @@
<script>
import { GlAlert } from '@gitlab/ui';
+import ConvertDescriptionModal from 'ee_component/issues/show/components/convert_description_modal.vue';
import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants';
import { TYPE_ISSUE } from '~/issues/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
@@ -12,6 +16,7 @@ import LockedWarning from './locked_warning.vue';
export default {
components: {
+ ConvertDescriptionModal,
DescriptionField,
DescriptionTemplateField,
EditActions,
@@ -20,6 +25,7 @@ export default {
IssuableTypeField,
LockedWarning,
},
+ mixins: [glFeatureFlagMixin()],
props: {
endpoint: {
type: String,
@@ -73,6 +79,11 @@ export default {
required: false,
default: '',
},
+ issueId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
const autosaveKey = [document.location.pathname, document.location.search];
@@ -100,6 +111,12 @@ export default {
isIssueType() {
return this.issuableType === TYPE_ISSUE;
},
+ resourceId() {
+ return this.issueId && convertToGraphQLId(TYPENAME_ISSUE, this.issueId);
+ },
+ userId() {
+ return convertToGraphQLId(TYPENAME_USER, gon.current_user_id);
+ },
},
watch: {
formData: {
@@ -158,6 +175,9 @@ export default {
updateDraft(this.descriptionAutosaveKey, description, this.formState.lock_version);
}
},
+ setDescription(desc) {
+ this.formData.description = desc;
+ },
},
};
</script>
@@ -185,12 +205,12 @@ export default {
<issuable-title-field ref="title" v-model="formData.title" @input="updateTitleDraft" />
</div>
</div>
- <div class="row">
+ <div class="row gl-gap-3">
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<issuable-type-field ref="issue-type" />
</div>
- <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
+ <div v-if="hasIssuableTemplates" class="col-12 col-md-4 gl-md-pl-0 gl-md-pr-0">
<description-template-field
v-model="formData.description"
:issuable-templates="issuableTemplates"
@@ -199,6 +219,14 @@ export default {
:project-namespace="projectNamespace"
/>
</div>
+
+ <convert-description-modal
+ v-if="issueId && glFeatures.generateDescriptionAi"
+ class="gl-pl-5 gl-sm-pl-0"
+ :resource-id="resourceId"
+ :user-id="userId"
+ @contentGenerated="setDescription"
+ />
</div>
<description-field
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 9d92b5cf954..4d9b69ddf99 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -9,17 +9,30 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { IssueType, STATUS_CLOSED } from '~/issues/constants';
-import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
+import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants';
+import {
+ ISSUE_STATE_EVENT_CLOSE,
+ ISSUE_STATE_EVENT_REOPEN,
+ NEW_ACTIONS_POPOVER_KEY,
+} from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
+import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import issuesEventHub from '../event_hub';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
import DeleteIssueModal from './delete_issue_modal.vue';
@@ -35,6 +48,8 @@ export default {
},
deleteModalId: 'delete-modal-id',
i18n: {
+ edit: __('Edit'),
+ editTitleAndDescription: __('Edit title and description'),
promoteErrorMessage: __(
'Something went wrong while promoting the issue to an epic. Please try again.',
),
@@ -42,6 +57,8 @@ export default {
'The issue was successfully promoted to an epic. Redirecting to epic...',
),
reportAbuse: __('Report abuse to administrator'),
+ referenceFetchError: __('An error occurred while fetching reference'),
+ copyReferenceText: __('Copy reference'),
},
components: {
DeleteIssueModal,
@@ -52,12 +69,15 @@ export default {
GlLink,
GlModal,
AbuseCategorySelector,
+ NewHeaderActionsPopover,
+ SidebarSubscriptionsWidget,
+ IssuableLockForm,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- mixins: [trackingMixin],
+ mixins: [trackingMixin, glFeatureFlagMixin()],
inject: {
canCreateIssue: {
default: false,
@@ -80,6 +100,9 @@ export default {
iid: {
default: '',
},
+ issuableId: {
+ default: '',
+ },
isIssueAuthor: {
default: false,
},
@@ -87,7 +110,7 @@ export default {
default: '',
},
issueType: {
- default: IssueType.Issue,
+ default: TYPE_ISSUE,
},
newIssuePath: {
default: '',
@@ -104,22 +127,53 @@ export default {
reportedFromUrl: {
default: '',
},
+ issuableEmailAddress: {
+ default: '',
+ },
+ fullPath: {
+ default: '',
+ },
},
data() {
return {
isReportAbuseDrawerOpen: false,
};
},
+ apollo: {
+ issuableReference: {
+ query: issueReferenceQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.reference || '';
+ },
+ skip() {
+ return !this.isMrSidebarMoved;
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.referenceFetchError });
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
+ ...mapGetters(['getNoteableData']),
+ isLocked() {
+ return this.getNoteableData.discussion_locked;
+ },
isClosed() {
return this.openState === STATUS_CLOSED;
},
issueTypeText() {
const issueTypeTexts = {
- [IssueType.Issue]: s__('HeaderAction|issue'),
- [IssueType.Incident]: s__('HeaderAction|incident'),
+ [TYPE_ISSUE]: s__('HeaderAction|issue'),
+ [TYPE_INCIDENT]: s__('HeaderAction|incident'),
};
return issueTypeTexts[this.issueType] ?? this.issueType;
@@ -156,6 +210,17 @@ export default {
hasMobileDropdown() {
return this.hasDesktopDropdown || this.showToggleIssueStateButton;
},
+ copyMailAddressText() {
+ return sprintf(__('Copy %{issueType} email address'), {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ showLockIssueOption() {
+ return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -165,6 +230,7 @@ export default {
},
methods: {
...mapActions(['toggleStateButtonLoading']),
+ ...mapActions(['updateLockedAttribute']),
toggleIssueState() {
if (!this.isClosed && this.getBlockedByIssues?.length) {
this.$refs.blockedByIssuesModal.show();
@@ -240,12 +306,27 @@ export default {
this.toggleStateButtonLoading(false);
});
},
+ edit() {
+ issuesEventHub.$emit('open.form');
+ },
+ dismissPopover() {
+ if (this.isMrSidebarMoved && !parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`))) {
+ setCookie(NEW_ACTIONS_POPOVER_KEY, true);
+ }
+ },
+ copyReference() {
+ toast(__('Reference copied'));
+ },
+ copyEmailAddress() {
+ toast(__('Email address copied'));
+ },
},
+ TYPE_ISSUE,
};
</script>
<template>
- <div class="detail-page-header-actions gl-display-flex gl-align-self-start">
+ <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3">
<gl-dropdown
v-if="hasMobileDropdown"
class="gl-sm-display-none! w-100"
@@ -255,6 +336,24 @@ export default {
data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+
+ <gl-dropdown-item v-if="canUpdateIssue" @click="edit">
+ {{ $options.i18n.edit }}
+ </gl-dropdown-item>
<gl-dropdown-item
v-if="showToggleIssueStateButton"
:data-qa-selector="`mobile_${qaSelector}`"
@@ -268,9 +367,21 @@ export default {
<gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -289,13 +400,33 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</gl-dropdown>
<gl-button
+ v-if="canUpdateIssue"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
+ class="js-issuable-edit gl-display-none gl-sm-display-block"
+ data-testid="edit-button"
+ @click="edit"
+ >
+ {{ $options.i18n.edit }}
+ </gl-button>
+
+ <gl-button
v-if="showToggleIssueStateButton"
class="gl-display-none gl-sm-display-inline-flex!"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
+ data-testid="toggle-button"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -303,8 +434,9 @@ export default {
<gl-dropdown
v-if="hasDesktopDropdown"
+ id="new-actions-header-dropdown"
v-gl-tooltip.hover
- class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ class="gl-display-none gl-sm-display-inline-flex!"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="issue_actions_ellipsis_dropdown"
@@ -315,7 +447,19 @@ export default {
data-testid="desktop-dropdown"
no-caret
right
+ @shown="dismissPopover"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
@@ -327,9 +471,24 @@ export default {
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -349,8 +508,16 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</gl-dropdown>
+ <new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" />
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index 40cb7fbb0ff..ac64c35bf15 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -3,7 +3,7 @@ import { produce } from 'immer';
import { sortBy } from 'lodash';
import { GlIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import { timelineFormI18n } from './constants';
@@ -113,7 +113,7 @@ export default {
>
<div
v-if="hasTimelineEvents"
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1"
>
<gl-icon name="comment" class="note-icon" />
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 997fadec602..4ec64ef838d 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -35,7 +35,7 @@ export default {
IncidentMetricTab: () =>
import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
},
- inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
+ inject: ['fullPath', 'iid', 'hasLinkedAlerts', 'uploadMetricsFeatureAvailable'],
i18n: incidentTabsI18n,
apollo: {
alert: {
@@ -59,20 +59,23 @@ export default {
data() {
return {
alert: null,
- activeTabIndex: 0,
};
},
computed: {
loading() {
return this.$apollo.queries.alert.loading;
},
+ activeTabIndex() {
+ const { tabId } = this.$route.params;
+ return tabId ? this.tabMapping.tabNamesToIndex[tabId] : 0;
+ },
tabMapping() {
const availableTabs = [TAB_NAMES.SUMMARY];
if (this.uploadMetricsFeatureAvailable) {
availableTabs.push(TAB_NAMES.METRICS);
}
- if (this.alert) {
+ if (this.hasLinkedAlerts) {
availableTabs.push(TAB_NAMES.ALERTS);
}
@@ -93,20 +96,25 @@ export default {
return this.activeTabIndex;
},
set(index) {
- this.handleTabChange(index);
- this.activeTabIndex = index;
+ const newPath = `/${this.tabMapping.tabIndexToName[index]}`;
+ // Only push if the new path differs from the old path.
+ if (newPath !== this.$route.path) {
+ this.$router.push(newPath);
+ this.updateJsIssueWidgets(index);
+ }
},
},
},
mounted() {
this.trackPageViews();
+ this.updateJsIssueWidgets(this.activeTabIndex);
},
methods: {
trackPageViews() {
const { category, action } = trackIncidentDetailsViewsOptions;
Tracking.event(category, action);
},
- handleTabChange(tabIndex) {
+ updateJsIssueWidgets(tabIndex) {
/**
* TODO: Implement a solution that does not violate Vue principles in using
* DOM manipulation directly (#361618)
@@ -153,7 +161,7 @@ export default {
<incident-metric-tab />
</gl-tab>
<gl-tab
- v-if="alert"
+ v-if="hasLinkedAlerts"
class="alert-management-details"
:title="$options.i18n.alertsTitle"
data-testid="alert-details-tab"
diff --git a/app/assets/javascripts/issues/show/components/incidents/router.js b/app/assets/javascripts/issues/show/components/incidents/router.js
new file mode 100644
index 00000000000..01326f3b5de
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/router.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+
+Vue.use(VueRouter);
+
+export default (currentPath, currentTab = null) => {
+ // If navigating directly to a tab, determine the base
+ // path to initialize router, then set the current route.
+ const base = currentPath.replace(new RegExp(`/${currentTab}$`), '');
+
+ const router = new VueRouter({
+ mode: 'history',
+ base,
+ routes: [{ path: '/:tabId', name: 'tab' }],
+ });
+
+ if (currentTab) router.push(`/${currentTab}`);
+
+ return router;
+};
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 7944362a40f..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 />
@@ -255,11 +255,10 @@ export default {
</gl-form-group>
</div>
<gl-form-group class="gl-mb-0">
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-wrap gl-gap-3">
<gl-button
variant="confirm"
category="primary"
- class="gl-mr-3"
data-testid="save-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@@ -271,7 +270,6 @@ export default {
v-if="showSaveAndAdd"
variant="confirm"
category="secondary"
- class="gl-mr-3 gl-ml-n2"
data-testid="save-and-add-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@@ -279,7 +277,7 @@ export default {
>
{{ $options.i18n.saveAndAdd }}
</gl-button>
- <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ <gl-button :disabled="isEventProcessed" @click="$emit('cancel')">
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 10b80529a66..5aef4b1b809 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -1,6 +1,6 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index ce33e91c3b8..2072961ce29 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
export const displayAndLogError = (error) =>
diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue
index 4414e693ed0..482aad32daa 100644
--- a/app/assets/javascripts/issues/show/components/locked_warning.vue
+++ b/app/assets/javascripts/issues/show/components/locked_warning.vue
@@ -1,7 +1,13 @@
<script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import { IssuableType } from '~/issues/constants';
+import {
+ TYPE_ALERT,
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ TYPE_TEST_CASE,
+} from '~/issues/constants';
export const i18n = Object.freeze({
alertMessage: __(
@@ -20,7 +26,9 @@ export default {
type: String,
required: true,
validator(value) {
- return Object.values(IssuableType).includes(value);
+ return [TYPE_ALERT, TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE].includes(
+ value,
+ );
},
},
},
diff --git a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
new file mode 100644
index 00000000000..8262b3ac0ff
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlPopover, GlButton } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants';
+import { IssuableTypeText } from '~/issues/constants';
+
+export default {
+ name: 'NewHeaderActionsPopover',
+ i18n: {
+ popoverText: s__(
+ 'HeaderAction|Notifications and other %{issueType} actions have moved to this menu.',
+ ),
+ confirmButtonText: s__('HeaderAction|Okay!'),
+ },
+ components: {
+ GlPopover,
+ GlButton,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ issueType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dismissKey: NEW_ACTIONS_POPOVER_KEY,
+ popoverDismissed: parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`)),
+ };
+ },
+ computed: {
+ popoverText() {
+ return sprintf(this.$options.i18n.popoverText, {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ showPopover() {
+ return !this.popoverDismissed && this.isMrSidebarMoved;
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ },
+ methods: {
+ dismissPopover() {
+ this.popoverDismissed = true;
+ setCookie(this.dismissKey, this.popoverDismissed);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-popover
+ v-if="showPopover"
+ target="new-actions-header-dropdown"
+ container="viewport"
+ placement="left"
+ :show="showPopover"
+ triggers="manual"
+ content="text"
+ :css-classes="['gl-p-2 new-header-popover']"
+ >
+ <template #title>
+ <div class="gl-font-base gl-font-weight-normal">
+ {{ popoverText }}
+ </div>
+ </template>
+ <gl-button
+ data-testid="confirm-button"
+ variant="confirm"
+ type="submit"
+ @click="dismissPopover"
+ >{{ $options.i18n.confirmButtonText }}</gl-button
+ >
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
index d0beb0f39b3..5160903c762 100644
--- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
+++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -10,38 +10,48 @@ export default {
taskActions: s__('WorkItem|Task actions'),
},
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
},
- inject: ['canUpdate', 'toggleClass'],
+ inject: ['canUpdate'],
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ this.closeDropdown();
},
deleteTaskListItem() {
eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ this.closeDropdown();
+ },
+ closeDropdown() {
+ this.$refs.dropdown.close();
},
},
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
+ v-if="canUpdate"
+ ref="dropdown"
class="task-list-item-actions-wrapper"
category="tertiary"
icon="ellipsis_v"
- lazy
no-caret
- right
- :text="$options.i18n.taskActions"
+ placement="right"
+ :toggle-text="$options.i18n.taskActions"
text-sr-only
- :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`"
+ toggle-class="task-list-item-actions gl-opacity-0 gl-p-2! "
>
- <gl-dropdown-item v-if="canUpdate" @click="convertToTask">
- {{ $options.i18n.convertToTask }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem">
- {{ $options.i18n.delete }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-item class="gl-ml-2!" @action="convertToTask">
+ <template #list-item>
+ {{ $options.i18n.convertToTask }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item class="gl-ml-2!" @action="deleteTaskListItem">
+ <template #list-item>
+ <span class="gl-text-red-500!">{{ $options.i18n.delete }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 6978f730e1d..2d2ef327018 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -1,17 +1,9 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { __ } from '~/locale';
-import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
export default {
- i18n: {
- editTitleAndDescription: __('Edit title and description'),
- },
- components: {
- GlButton,
- },
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
@@ -35,11 +27,6 @@ export default {
type: String,
required: true,
},
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -60,9 +47,6 @@ export default {
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
- edit() {
- eventHub.$emit('open.form');
- },
},
};
</script>
@@ -77,16 +61,8 @@ export default {
}"
class="title gl-font-size-h-display"
data-qa-selector="title_content"
+ data-testid="issue-title"
dir="auto"
></h1>
- <gl-button
- v-if="showInlineEditButton && canUpdate"
- v-gl-tooltip.bottom
- icon="pencil"
- class="btn-edit js-issuable-edit"
- :title="$options.i18n.editTitleAndDescription"
- :aria-label="$options.i18n.editTitleAndDescription"
- @click="edit"
- />
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js
index a100aaf88ad..6320e4ef266 100644
--- a/app/assets/javascripts/issues/show/constants.js
+++ b/app/assets/javascripts/issues/show/constants.js
@@ -1,6 +1,5 @@
import { __ } from '~/locale';
-export const INCIDENT_TYPE = 'incident';
export const INCIDENT_TYPE_PATH = 'issues/incident';
export const ISSUE_STATE_EVENT_CLOSE = 'CLOSE';
export const ISSUE_STATE_EVENT_REOPEN = 'REOPEN';
@@ -18,3 +17,5 @@ export const issueState = {
issueType: undefined,
isDirty: false,
};
+
+export const NEW_ACTIONS_POPOVER_KEY = 'new-actions-popover-viewed';
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 1793ce66ad4..5a51ac18446 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -2,14 +2,16 @@ import Vue from 'vue';
import { mapGetters } from 'vuex';
import errorTrackingStore from '~/error_tracking/store';
import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { TYPE_INCIDENT } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import IssueApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
import IncidentTabs from './components/incidents/incident_tabs.vue';
import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
-import { INCIDENT_TYPE, issueState } from './constants';
+import { issueState } from './constants';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
+import createRouter from './components/incidents/router';
const bootstrapApollo = (state = {}) => {
return apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -35,23 +37,28 @@ export function initIncidentApp(issueData = {}, store) {
canUpdateTimelineEvent,
iid,
issuableId,
+ currentPath,
+ currentTab,
projectNamespace,
projectPath,
projectId,
+ hasLinkedAlerts,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
state,
} = issueData;
const fullPath = `${projectNamespace}/${projectPath}`;
+ const router = createRouter(currentPath, currentTab);
return new Vue({
el,
name: 'DescriptionRoot',
apolloProvider,
store,
+ router,
provide: {
- issueType: INCIDENT_TYPE,
+ issueType: TYPE_INCIDENT,
canCreateIncident,
canUpdateTimelineEvent,
canUpdate,
@@ -59,6 +66,7 @@ export function initIncidentApp(issueData = {}, store) {
iid,
issuableId,
projectId,
+ hasLinkedAlerts: parseBoolean(hasLinkedAlerts),
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
contentEditorOnIssues: gon.features.contentEditorOnIssues,
@@ -125,7 +133,6 @@ export function initIssueApp(issueData, store) {
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
issueId: this.getNoteableData?.id,
- issueIid: this.getNoteableData?.iid,
},
});
},
@@ -142,7 +149,7 @@ export function initHeaderActions(store, type = '') {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
const canCreate =
- type === INCIDENT_TYPE ? el.dataset.canCreateIncident : el.dataset.canCreateIssue;
+ type === TYPE_INCIDENT ? el.dataset.canCreateIncident : el.dataset.canCreateIssue;
return new Vue({
el,
@@ -157,6 +164,7 @@ export function initHeaderActions(store, type = '') {
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
+ issuableId: el.dataset.issuableId,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType,
@@ -167,6 +175,8 @@ export function initHeaderActions(store, type = '') {
reportedUserId: parseInt(el.dataset.reportedUserId, 10),
reportedFromUrl: el.dataset.reportedFromUrl,
submitAsSpamPath: el.dataset.submitAsSpamPath,
+ issuableEmailAddress: el.dataset.issuableEmailAddress,
+ fullPath: el.dataset.projectPath,
},
render: (createElement) => createElement(HeaderActions),
});
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index 8c5dc88f183..184635e63f3 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -16,15 +16,6 @@ export const setApiBaseURL = (baseURL = null) => {
axiosInstance.defaults.baseURL = baseURL;
};
-export const addSubscription = async (addPath, namespace) => {
- const jwt = await getJwt();
-
- return axiosInstance.post(addPath, {
- jwt,
- namespace_path: namespace,
- });
-};
-
export const removeSubscription = async (removePath) => {
const jwt = await getJwt();
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
index fa1c2e1912c..cd0f4c2f66f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
@@ -1,27 +1,15 @@
<script>
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import { addSubscription } from '~/jira_connect/subscriptions/api';
-import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupItemName from '../group_item_name.vue';
-import {
- INTEGRATIONS_DOC_LINK,
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE,
-} from '../../constants';
+import { I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE } from '../../constants';
export default {
components: {
GlButton,
GroupItemName,
},
- mixins: [glFeatureFlagMixin()],
inject: {
- addSubscriptionsPath: {
- default: '',
- },
subscriptionsPath: {
default: '',
},
@@ -42,43 +30,19 @@ export default {
isLoading: false,
};
},
- computed: {
- oauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
methods: {
...mapActions(['addSubscription']),
async onClick() {
- if (this.oauthEnabled) {
- this.isLoading = true;
+ this.isLoading = true;
+ try {
await this.addSubscription({
namespacePath: this.group.full_path,
subscriptionsPath: this.subscriptionsPath,
});
- this.isLoading = false;
- } else {
- this.deprecatedAddSubscription();
+ } catch (error) {
+ this.$emit('error', error?.response?.data?.error || I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE);
}
- },
- deprecatedAddSubscription() {
- this.isLoading = true;
-
- addSubscription(this.addSubscriptionsPath, this.group.full_path)
- .then(() => {
- persistAlert({
- title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- linkUrl: INTEGRATIONS_DOC_LINK,
- variant: 'success',
- });
-
- reloadPage();
- })
- .catch((error) => {
- this.$emit('error', error?.response?.data?.error || I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE);
- this.isLoading = false;
- });
+ this.isLoading = false;
},
},
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index ec42b533dd4..7e79572f76d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -4,7 +4,6 @@ import { isEmpty } from 'lodash';
import { mapState, mapMutations, mapActions } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import AccessorUtilities from '~/lib/utils/accessor';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in/sign_in_page.vue';
@@ -23,11 +22,7 @@ export default {
SubscriptionsPage,
UserLink,
},
- mixins: [glFeatureFlagMixin()],
inject: {
- usersPath: {
- default: '',
- },
subscriptionsPath: {
default: '',
},
@@ -45,21 +40,14 @@ export default {
return !isEmpty(this.subscriptions);
},
userSignedIn() {
- if (this.isOauthEnabled) {
- return Boolean(this.currentUser);
- }
-
- return Boolean(!this.usersPath);
- },
- isOauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
+ return Boolean(this.currentUser);
},
/**
* Returns false if the GitLab for Jira app doesn't support the user's browser.
* Any web API that the GitLab for Jira app depends on should be checked here.
*/
isBrowserSupported() {
- return !this.isOauthEnabled || AccessorUtilities.canUseCrypto();
+ return AccessorUtilities.canUseCrypto();
},
gitlabUrl() {
return gon.gitlab_url;
@@ -80,11 +68,10 @@ export default {
}),
...mapActions(['fetchSubscriptions']),
/**
- * Fetch subscriptions from the REST API,
- * if the jiraConnectOauth flag is enabled.
+ * Fetch subscriptions from the REST API.
*/
fetchSubscriptionsOauth() {
- if (!this.isOauthEnabled || !this.userSignedIn) return;
+ if (!this.userSignedIn) return;
this.fetchSubscriptions(this.subscriptionsPath);
},
@@ -113,12 +100,7 @@ export default {
<gl-link :href="gitlabUrl" target="_blank">
<img :src="gitlabLogo" class="gl-h-6" :alt="__('GitLab')" />
</gl-link>
- <user-link
- :user-signed-in="userSignedIn"
- :has-subscriptions="hasSubscriptions"
- :user="currentUser"
- class="gl-fixed gl-right-4"
- />
+ <user-link v-if="userSignedIn" :user="currentUser" class="gl-fixed gl-right-4" />
</header>
<main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
deleted file mode 100644
index ec718d5b3ca..00000000000
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- usersPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- signInURL: '',
- };
- },
- created() {
- this.setSignInURL();
- },
- methods: {
- async setSignInURL() {
- this.signInURL = await getGitlabSignInURL(this.usersPath);
- },
- },
- i18n: {
- defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
- },
-};
-</script>
-<template>
- <gl-button category="primary" variant="info" :href="signInURL" target="_blank">
- <slot>
- {{ $options.i18n.defaultButtonText }}
- </slot>
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index 65c69bcfa82..bc8cdf35701 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -12,6 +12,7 @@ import {
I18N_OAUTH_FAILED_MESSAGE,
OAUTH_SELF_MANAGED_DOC_LINK,
OAUTH_WINDOW_OPTIONS,
+ OAUTH_CALLBACK_MESSAGE_TYPE,
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
} from '~/jira_connect/subscriptions/constants';
import { fetchOAuthApplicationId, fetchOAuthToken } from '~/jira_connect/subscriptions/api';
@@ -130,6 +131,11 @@ export default {
}
},
async handleWindowMessage(event) {
+ // Make sure this ia a message from the OAuth flow in pages/jira_connect/oauth_callbacks/index.js
+ if (event.data?.type !== OAUTH_CALLBACK_MESSAGE_TYPE) {
+ return;
+ }
+
if (window.origin !== event.origin) {
this.loading = false;
return;
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index b253f888d22..cc0af0b9ab7 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -1,44 +1,24 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
export default {
components: {
GlLink,
GlSprintf,
- SignInOauthButton: () => import('./sign_in_oauth_button.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
- usersPath: {
- default: '',
- },
gitlabUserPath: {
default: '',
},
},
props: {
- userSignedIn: {
- type: Boolean,
- required: true,
- },
- hasSubscriptions: {
- type: Boolean,
- required: true,
- },
user: {
type: Object,
required: false,
default: null,
},
},
- data() {
- return {
- signInURL: '',
- };
- },
computed: {
gitlabUserName() {
return gon.current_username ?? this.user?.username;
@@ -54,15 +34,8 @@ export default {
? this.$options.i18n.signedInAsUserText
: this.$options.i18n.signedInText;
},
- isOauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
- async created() {
- this.signInURL = await getGitlabSignInURL(this.usersPath);
},
i18n: {
- signInText: __('Sign in to GitLab'),
signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
signedInText: __('Signed in to GitLab'),
},
@@ -70,22 +43,12 @@ export default {
</script>
<template>
<div class="gl-font-base">
- <gl-sprintf v-if="userSignedIn" :message="signedInText">
+ <gl-sprintf :message="signedInText">
<template #user_link>
<gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
{{ gitlabUserHandle }}
</gl-link>
</template>
</gl-sprintf>
-
- <template v-else-if="hasSubscriptions">
- <sign-in-oauth-button v-if="isOauthEnabled" category="tertiary">
- {{ $options.i18n.signInText }}
- </sign-in-oauth-button>
-
- <gl-link v-else data-testid="sign-in-link" :href="signInURL" target="_blank">
- {{ $options.i18n.signInText }}
- </gl-link>
- </template>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index bb22a4ef252..321d10205e6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -54,6 +54,8 @@ export const OAUTH_WINDOW_OPTIONS = [
`top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`,
].join(',');
+export const OAUTH_CALLBACK_MESSAGE_TYPE = 'jiraConnectOauthCallback';
+
export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = {
long: 'SHA-256',
short: 'S256',
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index 21ff85e58e2..8854157054d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -2,7 +2,6 @@ import '~/webpack';
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
-import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
@@ -17,14 +16,11 @@ export function initJiraConnect() {
setConfigs();
Vue.use(Translate);
- Vue.use(GlFeatureFlagsPlugin);
const {
groupsPath,
subscriptions,
- addSubscriptionsPath,
subscriptionsPath,
- usersPath,
gitlabUserPath,
oauthMetadata,
publicKeyStorageEnabled,
@@ -38,9 +34,7 @@ export function initJiraConnect() {
store,
provide: {
groupsPath,
- addSubscriptionsPath,
subscriptionsPath,
- usersPath,
gitlabUserPath,
oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
publicKeyStorageEnabled,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
index 6de3f507a39..113ce34fdcd 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
@@ -2,29 +2,20 @@
import { s__ } from '~/locale';
import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../../components/subscriptions_list.vue';
export default {
name: 'SignInGitlabCom',
components: {
SubscriptionsList,
- SignInLegacyButton: () => import('../../components/sign_in_legacy_button.vue'),
SignInOauthButton: () => import('../../components/sign_in_oauth_button.vue'),
},
- mixins: [glFeatureFlagMixin()],
- inject: ['usersPath'],
props: {
hasSubscriptions: {
type: Boolean,
required: true,
},
},
- computed: {
- useSignInOauthButton() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
i18n: {
signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab to get started.'),
@@ -44,16 +35,12 @@ export default {
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<sign-in-oauth-button
- v-if="useSignInOauthButton"
:gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
>
{{ $options.i18n.signInButtonTextWithSubscriptions }}
</sign-in-oauth-button>
- <sign-in-legacy-button v-else :users-path="usersPath">
- {{ $options.i18n.signInButtonTextWithSubscriptions }}
- </sign-in-legacy-button>
</div>
<subscriptions-list />
@@ -61,12 +48,10 @@ export default {
<div v-else class="gl-text-center">
<p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
<sign-in-oauth-button
- v-if="useSignInOauthButton"
:gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
- <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" />
</div>
</div>
</template>
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..8cc107930d1 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
@@ -12,7 +12,6 @@ import {
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import SignInOauthButton from '../../../components/sign_in_oauth_button.vue';
-import SetupInstructions from './setup_instructions.vue';
import VersionSelectForm from './version_select_form.vue';
export default {
@@ -20,31 +19,23 @@ export default {
components: {
GlButton,
SignInOauthButton,
- SetupInstructions,
VersionSelectForm,
},
data() {
return {
gitlabBasePath: null,
loadingVersionSelect: false,
- showSetupInstructions: false,
};
},
computed: {
hasSelectedVersion() {
return this.gitlabBasePath !== null;
},
- subtitle() {
- return this.hasSelectedVersion
- ? this.$options.i18n.signInSubtitle
- : this.$options.i18n.versionSelectSubtitle;
- },
},
mounted() {
this.gitlabBasePath = retrieveBaseUrl();
- setApiBaseURL(this.gitlabBasePath);
if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) {
- this.showSetupInstructions = true;
+ setApiBaseURL(this.gitlabBasePath);
}
},
methods: {
@@ -70,9 +61,6 @@ export default {
this.loadingVersionSelect = false;
});
},
- onSetupNext() {
- this.showSetupInstructions = false;
- },
onSignInError() {
this.$emit('error');
},
@@ -80,7 +68,6 @@ export default {
i18n: {
title: s__('JiraService|Welcome to GitLab for Jira'),
signInSubtitle: s__('JiraService|Sign in to GitLab to link namespaces.'),
- versionSelectSubtitle: s__('JiraService|What version of GitLab are you using?'),
changeVersionButtonText: s__('JiraService|Change GitLab version'),
},
};
@@ -90,7 +77,6 @@ export default {
<div>
<div class="gl-text-center">
<h2>{{ $options.i18n.title }}</h2>
- <p data-testid="subtitle">{{ subtitle }}</p>
</div>
<version-select-form
@@ -101,9 +87,8 @@ export default {
/>
<template v-else>
- <setup-instructions v-if="showSetupInstructions" @next="onSetupNext" />
-
- <div v-else class="gl-text-center">
+ <div class="gl-text-center">
+ <p data-testid="subtitle">{{ $options.i18n.signInSubtitle }}</p>
<sign-in-oauth-button
class="gl-mb-5"
:gitlab-base-path="gitlabBasePath"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
new file mode 100644
index 00000000000..8ddbbffa708
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
@@ -0,0 +1,22 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ },
+ i18n: {
+ title: s__('JiraService|Are you a GitLab administrator?'),
+ body: s__(
+ "JiraService|Setting up this integration is only possible if you're a GitLab administrator.",
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.title" :dismissible="false">
+ {{ $options.i18n.body }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
index 00fa739b518..621bcccd19a 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <div class="gl-max-w-62 gl-mx-auto gl-mt-7">
+ <div class="gl-mt-5">
<h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3>
<p>
{{
@@ -28,8 +28,9 @@ export default {
>
</p>
- <gl-button variant="confirm" @click="$emit('next')">
- {{ __('Next') }}
- </gl-button>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <gl-button @click="$emit('back')">{{ __('Back') }}</gl-button>
+ <gl-button variant="confirm" @click="$emit('next')">{{ __('Next') }}</gl-button>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
index 37a65946b3f..3a080afd3c5 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
@@ -10,6 +10,8 @@ import {
import { __, s__ } from '~/locale';
import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
+import SelfManagedAlert from './self_managed_alert.vue';
+import SetupInstructions from './setup_instructions.vue';
const RADIO_OPTIONS = {
saas: 'saas',
@@ -27,6 +29,8 @@ export default {
GlFormInput,
GlFormRadio,
GlButton,
+ SelfManagedAlert,
+ SetupInstructions,
},
props: {
loading: {
@@ -39,25 +43,54 @@ export default {
return {
selected: DEFAULT_RADIO_OPTION,
selfManagedBasePathInput: '',
+ showSetupInstructions: false,
+ showSelfManagedInstanceInput: false,
};
},
computed: {
isSelfManagedSelected() {
return this.selected === RADIO_OPTIONS.selfManaged;
},
+ submitText() {
+ return this.isSelfManagedSelected
+ ? this.$options.i18n.buttonNext
+ : this.$options.i18n.buttonSave;
+ },
+ showVersonSelect() {
+ return !this.showSetupInstructions && !this.showSelfManagedInstanceInput;
+ },
},
methods: {
onSubmit() {
- const gitlabBasePath =
- this.selected === RADIO_OPTIONS.saas ? GITLAB_COM_BASE_PATH : this.selfManagedBasePathInput;
+ if (this.isSelfManagedSelected && !this.showSelfManagedInstanceInput) {
+ this.showSetupInstructions = true;
+ return;
+ }
+
+ const gitlabBasePath = this.isSelfManagedSelected
+ ? this.selfManagedBasePathInput
+ : GITLAB_COM_BASE_PATH;
this.$emit('submit', gitlabBasePath);
},
+
+ onSetupNext() {
+ this.showSetupInstructions = false;
+ this.showSelfManagedInstanceInput = true;
+ },
+
+ onSetupBack() {
+ this.showSetupInstructions = false;
+ this.showSelfManagedInstanceInput = false;
+ },
},
radioOptions: RADIO_OPTIONS,
i18n: {
+ title: s__('JiraService|What version of GitLab are you using?'),
saasRadioLabel: __('GitLab.com (SaaS)'),
saasRadioHelp: __('Most common'),
selfManagedRadioLabel: __('GitLab (self-managed)'),
+ buttonNext: __('Next'),
+ buttonSave: __('Save'),
instanceURLInputLabel: s__('JiraService|GitLab instance URL'),
instanceURLInputDescription: s__('JiraService|For example: https://gitlab.example.com'),
},
@@ -66,30 +99,50 @@ export default {
<template>
<gl-form class="gl-max-w-62 gl-mx-auto" @submit.prevent="onSubmit">
- <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version">
- <gl-form-radio :value="$options.radioOptions.saas">
- {{ $options.i18n.saasRadioLabel }}
- <template #help>
- {{ $options.i18n.saasRadioHelp }}
- </template>
- </gl-form-radio>
- <gl-form-radio :value="$options.radioOptions.selfManaged">
- {{ $options.i18n.selfManagedRadioLabel }}
- </gl-form-radio>
- </gl-form-radio-group>
+ <div v-if="showVersonSelect">
+ <h5 class="gl-mb-5">{{ $options.i18n.title }}</h5>
+ <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version">
+ <gl-form-radio :value="$options.radioOptions.saas">
+ {{ $options.i18n.saasRadioLabel }}
+ <template #help>
+ {{ $options.i18n.saasRadioHelp }}
+ </template>
+ </gl-form-radio>
+ <gl-form-radio :value="$options.radioOptions.selfManaged">
+ {{ $options.i18n.selfManagedRadioLabel }}
+ </gl-form-radio>
+ </gl-form-radio-group>
+ <self-managed-alert v-if="isSelfManagedSelected" />
+
+ <div class="gl-display-flex gl-justify-content-end gl-mt-5">
+ <gl-button variant="confirm" type="submit" :loading="loading" data-testid="submit-button">{{
+ submitText
+ }}</gl-button>
+ </div>
+ </div>
- <gl-form-group
- v-if="isSelfManagedSelected"
- class="gl-ml-6"
- :label="$options.i18n.instanceURLInputLabel"
- :description="$options.i18n.instanceURLInputDescription"
- label-for="self-managed-instance-input"
- >
- <gl-form-input id="self-managed-instance-input" v-model="selfManagedBasePathInput" required />
- </gl-form-group>
+ <setup-instructions v-else-if="showSetupInstructions" @next="onSetupNext" @back="onSetupBack" />
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="confirm" type="submit" :loading="loading">{{ __('Save') }}</gl-button>
+ <div v-else-if="showSelfManagedInstanceInput">
+ <gl-form-group
+ :label="$options.i18n.instanceURLInputLabel"
+ :description="$options.i18n.instanceURLInputDescription"
+ label-for="self-managed-instance-input"
+ >
+ <gl-form-input
+ id="self-managed-instance-input"
+ v-model="selfManagedBasePathInput"
+ required
+ />
+ </gl-form-group>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <gl-button data-testid="back-button" @click.prevent="onSetupBack">{{
+ __('Back')
+ }}</gl-button>
+ <gl-button variant="confirm" type="submit" :loading="loading">{{
+ $options.i18n.buttonSave
+ }}</gl-button>
+ </div>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
index e6a94ffbaa4..ee20e21011f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
@@ -1,12 +1,10 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SignInGitlabCom from './sign_in_gitlab_com.vue';
import SignInGitlabMultiversion from './sign_in_gitlab_multiversion/index.vue';
export default {
name: 'SignInPage',
components: { SignInGitlabCom, SignInGitlabMultiversion },
- mixins: [glFeatureFlagMixin()],
props: {
hasSubscriptions: {
type: Boolean,
@@ -19,7 +17,7 @@ export default {
},
computed: {
isOauthSelfManagedEnabled() {
- return this.glFeatures.jiraConnectOauth && this.publicKeyStorageEnabled;
+ return this.publicKeyStorageEnabled;
},
},
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
index fff34e1d75d..3b8e4c7540b 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
@@ -14,8 +14,6 @@ import {
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
- ADD_SUBSCRIPTION_LOADING,
- ADD_SUBSCRIPTION_ERROR,
SET_ALERT,
SET_CURRENT_USER,
SET_CURRENT_USER_ERROR,
@@ -51,25 +49,17 @@ export const addSubscription = async (
{ commit, state, dispatch },
{ namespacePath, subscriptionsPath },
) => {
- try {
- commit(ADD_SUBSCRIPTION_LOADING, true);
-
- await addJiraConnectSubscription(namespacePath, {
- jwt: await getJwt(),
- accessToken: state.accessToken,
- });
+ await addJiraConnectSubscription(namespacePath, {
+ jwt: await getJwt(),
+ accessToken: state.accessToken,
+ });
- commit(SET_ALERT, {
- title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- linkUrl: INTEGRATIONS_DOC_LINK,
- variant: 'success',
- });
+ commit(SET_ALERT, {
+ title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ linkUrl: INTEGRATIONS_DOC_LINK,
+ variant: 'success',
+ });
- dispatch('fetchSubscriptions', subscriptionsPath);
- } catch (e) {
- commit(ADD_SUBSCRIPTION_ERROR, e);
- } finally {
- commit(ADD_SUBSCRIPTION_LOADING, false);
- }
+ dispatch('fetchSubscriptions', subscriptionsPath);
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
index d4893fbcaf6..63aad27aeb6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
@@ -4,9 +4,6 @@ export const SET_SUBSCRIPTIONS = 'SET_SUBSCRIPTIONS';
export const SET_SUBSCRIPTIONS_LOADING = 'SET_SUBSCRIPTIONS_LOADING';
export const SET_SUBSCRIPTIONS_ERROR = 'SET_SUBSCRIPTIONS_ERROR';
-export const ADD_SUBSCRIPTION_LOADING = 'ADD_SUBSCRIPTION_LOADING';
-export const ADD_SUBSCRIPTION_ERROR = 'ADD_SUBSCRIPTION_ERROR';
-
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export const SET_CURRENT_USER_ERROR = 'SET_CURRENT_USER_ERROR';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
index 60076c918fd..270ce9fab66 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
@@ -3,8 +3,6 @@ import {
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
- ADD_SUBSCRIPTION_LOADING,
- ADD_SUBSCRIPTION_ERROR,
SET_CURRENT_USER,
SET_CURRENT_USER_ERROR,
SET_ACCESS_TOKEN,
@@ -25,13 +23,6 @@ export default {
state.subscriptionsError = subscriptionsError;
},
- [ADD_SUBSCRIPTION_LOADING](state, loading) {
- state.addSubscriptionLoading = loading;
- },
- [ADD_SUBSCRIPTION_ERROR](state, error) {
- state.addSubscriptionError = error;
- },
-
[SET_CURRENT_USER](state, currentUser) {
state.currentUser = currentUser;
},
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
index 82a8517b511..f35f79e3f53 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
@@ -10,9 +10,6 @@ export default function createState({
subscriptionsLoading,
subscriptionsError: false,
- addSubscriptionLoading: false,
- addSubscriptionError: false,
-
currentUser,
currentUserError: null,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index 6db8b62d692..0b88e83f8da 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -1,5 +1,4 @@
import AccessorUtilities from '~/lib/utils/accessor';
-import { objectToQuery } from '~/lib/utils/url_utility';
import { ALERT_LOCALSTORAGE_KEY, BASE_URL_LOCALSTORAGE_KEY } from './constants';
const isFunction = (fn) => typeof fn === 'function';
@@ -76,18 +75,6 @@ export const getJwt = () => {
});
};
-export const getLocation = () => {
- return new Promise((resolve) => {
- if (isFunction(AP?.getLocation)) {
- AP.getLocation((location) => {
- resolve(location);
- });
- } else {
- resolve();
- }
- });
-};
-
export const reloadPage = () => {
if (isFunction(AP?.navigator?.reload)) {
AP.navigator.reload();
@@ -101,17 +88,3 @@ export const sizeToParent = () => {
AP.sizeToParent();
}
};
-
-export const getGitlabSignInURL = async (signInURL) => {
- const location = await getLocation();
-
- if (location) {
- const queryParams = {
- return_to: location,
- };
-
- return `${signInURL}?${objectToQuery(queryParams)}`;
- }
-
- return signInURL;
-};
diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql
new file mode 100644
index 00000000000..f4a0b10672e
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql
@@ -0,0 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql"
+
+fragment BaseCiJob on CiJob {
+ id
+ manualVariables {
+ nodes {
+ ...ManualCiVariable
+ }
+ }
+ __typename
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql
new file mode 100644
index 00000000000..0479df7bc4c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql
@@ -0,0 +1,6 @@
+fragment ManualCiVariable on CiVariable {
+ __typename
+ id
+ key
+ value
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql
new file mode 100644
index 00000000000..520deef5136
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql
@@ -0,0 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
+mutation playJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) {
+ jobPlay(input: { id: $id, variables: $variables }) {
+ job {
+ ...BaseCiJob
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
index 2b79892a072..e35d603ea71 100644
--- a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
+++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
@@ -1,14 +1,9 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) {
jobRetry(input: { id: $id, variables: $variables }) {
job {
- id
- manualVariables {
- nodes {
- id
- key
- value
- }
- }
+ ...BaseCiJob
webPath
}
errors
diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
index aaf1dec8e0f..95e3521091d 100644
--- a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
+++ b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
@@ -1,16 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
query getJob($fullPath: ID!, $id: JobID!) {
project(fullPath: $fullPath) {
id
job(id: $id) {
- id
+ ...BaseCiJob
manualJob
- manualVariables {
- nodes {
- id
- key
- value
- }
- }
name
}
}
diff --git a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
index e9809ac661b..ea7e13418f2 100644
--- a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
@@ -103,6 +103,8 @@ export default {
} else {
next();
}
+ }).catch(() => {
+ this.failureCount = null;
});
}
},
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
index 763eb6705aa..d3b2ddc5422 100644
--- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -10,16 +10,17 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { cloneDeep, uniqueId } from 'lodash';
-import { mapActions } from 'vuex';
import { fetchPolicies } from '~/lib/graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
+import { reportMessageToSentry } from '~/jobs/utils';
import GetJob from './graphql/queries/get_job.query.graphql';
+import playJobWithVariablesMutation from './graphql/mutations/job_play_with_variables.mutation.graphql';
import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql';
// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue
@@ -54,8 +55,9 @@ export default {
const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes);
return [...jobVariables.reverse(), ...this.variables];
},
- error() {
+ error(error) {
createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
},
},
},
@@ -69,13 +71,14 @@ export default {
required: true,
},
},
- clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0'],
+ clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0 gl-m-0! gl-ml-3!'],
inputTypes: {
key: 'key',
value: 'value',
},
i18n: {
- clearInputs: s__('CiVariables|Clear inputs'),
+ cancel: s__('CiVariables|Cancel'),
+ removeInputs: s__('CiVariables|Remove inputs'),
formHelpText: s__(
'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.',
),
@@ -86,14 +89,10 @@ export default {
keyLabel: s__('CiVariables|Key'),
keyPlaceholder: s__('CiVariables|Input variable key'),
runAgainButtonText: s__('CiVariables|Run job again'),
- triggerButtonText: s__('CiVariables|Run job'),
+ runButtonText: s__('CiVariables|Run job'),
valueLabel: s__('CiVariables|Value'),
valuePlaceholder: s__('CiVariables|Input variable value'),
},
- variableValueKeys: {
- rest: 'secret_value',
- gql: 'value',
- },
data() {
return {
job: {},
@@ -104,30 +103,63 @@ export default {
value: '',
},
],
- runAgainBtnDisabled: false,
- triggerBtnDisabled: false,
+ runBtnDisabled: false,
};
},
computed: {
+ mutationVariables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId),
+ variables: this.preparedVariables,
+ };
+ },
preparedVariables() {
- // filtering out 'id' along with empty variables to send only key, value in the mutation.
- // This will be removed in: https://gitlab.com/gitlab-org/gitlab/-/issues/377268
-
return this.variables
.filter((variable) => variable.key !== '')
- .map(({ key, value }) => ({ key, [this.valueKey]: value }));
+ .map(({ key, value }) => ({ key, value }));
},
- valueKey() {
+ runBtnText() {
return this.isRetryable
- ? this.$options.variableValueKeys.gql
- : this.$options.variableValueKeys.rest;
+ ? this.$options.i18n.runAgainButtonText
+ : this.$options.i18n.runButtonText;
},
variableSettings() {
return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
},
},
methods: {
- ...mapActions(['triggerManualJob']),
+ async playJob() {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: playJobWithVariablesMutation,
+ variables: this.mutationVariables,
+ });
+ if (data.jobPlay?.errors?.length) {
+ createAlert({ message: data.jobPlay.errors[0] });
+ } else {
+ this.navigateToJob(data.jobPlay?.job?.webPath);
+ }
+ } catch (error) {
+ createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
+ }
+ },
+ async retryJob() {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: retryJobWithVariablesMutation,
+ variables: this.mutationVariables,
+ });
+ if (data.jobRetry?.errors?.length) {
+ createAlert({ message: data.jobRetry.errors[0] });
+ } else {
+ this.navigateToJob(data.jobRetry?.job?.webPath);
+ }
+ } catch (error) {
+ createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
+ }
+ },
addEmptyVariable() {
const lastVar = this.variables[this.variables.length - 1];
@@ -153,37 +185,17 @@ export default {
inputRef(type, id) {
return `${this.$options.inputTypes[type]}-${id}`;
},
- navigateToRetriedJob(retryPath) {
- redirectTo(retryPath);
+ navigateToJob(path) {
+ redirectTo(path); // eslint-disable-line import/no-deprecated
},
- async retryJob() {
- try {
- const { data } = await this.$apollo.mutate({
- mutation: retryJobWithVariablesMutation,
- variables: {
- id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId),
- // we need to ensure no empty variables are passed to the API
- variables: this.preparedVariables,
- },
- });
- if (data.jobRetry?.errors?.length) {
- createAlert({ message: data.jobRetry.errors[0] });
- } else {
- this.navigateToRetriedJob(data.jobRetry?.job?.webPath);
- }
- } catch (error) {
- createAlert({ message: JOB_GRAPHQL_ERRORS.retryMutationErrorText });
- }
- },
- runAgain() {
- this.runAgainBtnDisabled = true;
-
- this.retryJob();
- },
- triggerJob() {
- this.triggerBtnDisabled = true;
+ runJob() {
+ this.runBtnDisabled = true;
- this.triggerManualJob(this.preparedVariables);
+ if (this.isRetryable) {
+ this.retryJob();
+ } else {
+ this.playJob();
+ }
},
},
};
@@ -197,7 +209,7 @@ export default {
<div
v-for="(variable, index) in variables"
:key="variable.id"
- class="gl-display-flex gl-align-items-center gl-mb-4"
+ class="gl-display-flex gl-align-items-center gl-mb-5"
data-testid="ci-variable-row"
>
<gl-form-input-group class="gl-mr-4 gl-flex-grow-1">
@@ -232,12 +244,11 @@ export default {
<gl-button
v-if="canRemove(index)"
v-gl-tooltip
- :aria-label="$options.i18n.clearInputs"
- :title="$options.i18n.clearInputs"
+ :aria-label="$options.i18n.removeInputs"
+ :title="$options.i18n.removeInputs"
:class="$options.clearBtnSharedClasses"
category="tertiary"
- variant="danger"
- icon="clear"
+ icon="remove"
data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
@@ -248,8 +259,7 @@ export default {
:class="$options.clearBtnSharedClasses"
data-testid="delete-variable-btn-placeholder"
category="tertiary"
- variant="danger"
- icon="clear"
+ icon="remove"
/>
</div>
@@ -271,37 +281,23 @@ export default {
</template>
</gl-sprintf>
</div>
- <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-button
+ v-if="isRetryable"
class="gl-mt-5"
- :aria-label="__('Cancel')"
data-testid="cancel-btn"
@click="$emit('hideManualVariablesForm')"
- >{{ __('Cancel') }}</gl-button
+ >{{ $options.i18n.cancel }}</gl-button
>
<gl-button
class="gl-mt-5"
variant="confirm"
category="primary"
- :aria-label="__('Run manual job again')"
- :disabled="runAgainBtnDisabled"
+ :disabled="runBtnDisabled"
data-testid="run-manual-job-btn"
- @click="runAgain"
- >
- {{ $options.i18n.runAgainButtonText }}
- </gl-button>
- </div>
- <div v-else class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-button
- class="gl-mt-5"
- variant="confirm"
- category="primary"
- :aria-label="__('Trigger manual job')"
- :disabled="triggerBtnDisabled"
- data-testid="trigger-manual-job-btn"
- @click="triggerJob"
+ @click="runJob"
>
- {{ $options.i18n.triggerButtonText }}
+ {{ runBtnText }}
</gl-button>
</div>
</div>
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/job/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
index 913924cc7b1..a3f1a2c4be8 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
@@ -30,18 +30,16 @@ export default {
return {
primaryProps: {
text: this.$options.i18n.primaryText,
- attributes: [
- {
- 'data-method': 'post',
- 'data-testid': 'retry-button-modal',
- href: this.href,
- variant: 'danger',
- },
- ],
+ attributes: {
+ 'data-method': 'post',
+ 'data-testid': 'retry-button-modal',
+ href: this.href,
+ variant: 'danger',
+ },
},
cancelProps: {
text: this.$options.i18n.cancel,
- attributes: [{ category: 'secondary', variant: 'default' }],
+ attributes: { category: 'secondary', variant: 'default' },
},
};
},
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
index 05567328660..0ba34eafa58 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
@@ -22,6 +22,11 @@ export default {
required: false,
default: '',
},
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasTitle() {
@@ -35,10 +40,19 @@ export default {
</script>
<template>
<p class="gl-display-flex gl-justify-content-space-between gl-mb-2">
- <span v-if="hasTitle"
- ><b>{{ title }}:</b> {{ value }}</span
- >
- <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank">
+ <span v-if="hasTitle">
+ <b>{{ title }}:</b>
+ <gl-link
+ v-if="path"
+ :href="path"
+ class="gl-text-blue-600!"
+ data-testid="job-sidebar-value-link"
+ >
+ {{ value }}
+ </gl-link>
+ <span v-else>{{ value }}</span>
+ </span>
+ <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank" data-testid="job-sidebar-help-link">
<gl-icon name="question-o" />
</gl-link>
</p>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
index 8100bc2d87a..d791705d80d 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
index 8300a22cb67..3cd90eb3bca 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
@@ -70,6 +70,9 @@ export default {
timeoutSource: this.job.metadata.timeout_source,
});
},
+ runnerAdminPath() {
+ return this.job?.runner?.admin_path || '';
+ },
},
i18n: {
COVERAGE: __('Coverage'),
@@ -104,7 +107,12 @@ export default {
data-testid="job-timeout"
:title="$options.i18n.TIMEOUT"
/>
- <detail-row v-if="job.runner" :value="runnerId" :title="$options.i18n.RUNNER" />
+ <detail-row
+ v-if="job.runner"
+ :value="runnerId"
+ :title="$options.i18n.RUNNER"
+ :path="runnerAdminPath"
+ />
<detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" />
<p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index e3afe9b7c67..28a17abb20b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 9647582b81d..ba1801f5c58 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,5 +1,7 @@
<script>
import { mapState, mapActions } from 'vuex';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import { getLocationHash } from '~/lib/utils/url_utility';
import CollapsibleLogSection from './collapsible_section.vue';
import LogLine from './line.vue';
@@ -25,13 +27,25 @@ export default {
},
updated() {
this.$nextTick(() => {
- this.handleScrollDown();
+ if (!window.location.hash) {
+ this.handleScrollDown();
+ }
});
},
mounted() {
- this.$nextTick(() => {
- this.handleScrollDown();
- });
+ if (window.location.hash) {
+ const lineNumber = getLocationHash();
+
+ this.unwatchJobLog = this.$watch('jobLog', async () => {
+ if (this.jobLog.length) {
+ await this.$nextTick();
+
+ const el = document.getElementById(lineNumber);
+ scrollToElement(el);
+ this.unwatchJobLog();
+ }
+ });
+ }
},
methods: {
...mapActions(['toggleCollapsibleLine', 'scrollBottom']),
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 17766b4d162..d97f6f6ff8c 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -1,7 +1,14 @@
<script>
-import { GlButton, GlButtonGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import {
ACTIONS_DOWNLOAD_ARTIFACTS,
ACTIONS_START_NOW,
@@ -49,6 +56,7 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
inject: {
admin: {
@@ -130,7 +138,7 @@ export default {
} else if (redirect) {
// Retry and Play actions redirect to job detail view
// we don't need to refetch with jobActionPerformed event
- redirectTo(job.detailedStatus.detailsPath);
+ redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated
} else {
eventHub.$emit('jobActionPerformed');
}
@@ -178,11 +186,12 @@ export default {
<template v-if="canReadJob && canUpdateJob">
<gl-button
v-if="isActive"
- data-testid="cancel-button"
+ v-gl-tooltip
icon="cancel"
:title="$options.CANCEL"
:aria-label="$options.CANCEL"
:disabled="cancelBtnDisabled"
+ data-testid="cancel-button"
@click="cancelJob()"
/>
<template v-else-if="isScheduled">
@@ -191,6 +200,7 @@ export default {
</gl-button>
<gl-button
v-gl-modal-directive="$options.playJobModalId"
+ v-gl-tooltip
icon="play"
:title="$options.ACTIONS_START_NOW"
:aria-label="$options.ACTIONS_START_NOW"
@@ -206,6 +216,7 @@ export default {
</gl-sprintf>
</gl-modal>
<gl-button
+ v-gl-tooltip
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
:aria-label="$options.ACTIONS_UNSCHEDULE"
@@ -218,6 +229,7 @@ export default {
<!--Note: This is the manual job play button -->
<gl-button
v-if="manualJobPlayable"
+ v-gl-tooltip
icon="play"
:title="$options.ACTIONS_PLAY"
:aria-label="$options.ACTIONS_PLAY"
@@ -227,6 +239,7 @@ export default {
/>
<gl-button
v-else-if="isRetryable"
+ v-gl-tooltip
icon="retry"
:title="retryButtonTitle"
:aria-label="retryButtonTitle"
@@ -239,6 +252,7 @@ export default {
</template>
<gl-button
v-if="shouldDisplayArtifacts"
+ v-gl-tooltip
icon="download"
:title="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
:aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
index d1b2da4d115..11593fa355a 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -25,7 +25,7 @@ export default {
return this.job?.duration;
},
durationFormatted() {
- return durationTimeFormatted(this.duration);
+ return formatTime(this.duration * 1000);
},
},
};
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 41ce6e4d64d..1b572e60c58 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
/* Error constants */
-export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
export const RAW_TEXT_WARNING = s__(
'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.',
diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
index 8bcd7ffd10f..5390c023da4 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
+++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
@@ -11,42 +11,48 @@ export default {
},
CiJobConnection: {
merge(existing = {}, incoming, { args = {} }) {
- let nodes;
+ 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;
+ 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];
+ 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 {
- nodes = [...incoming.nodes];
- }
- } else {
- if (!existing.pageInfo?.hasNextPage) {
- nodes = [...incoming.nodes];
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
- return {
- nodes,
- statuses,
- pageInfo,
- count: incoming.count,
- };
- }
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ };
+ }
- nodes = [...existing.nodes, ...incoming.nodes];
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
+ } else {
+ nodes = [...incoming.nodes];
}
- } else {
- nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ };
}
return {
- nodes,
- statuses,
- pageInfo,
- count: incoming.count,
+ nodes: existing.nodes,
+ pageInfo: existing.pageInfo,
+ statuses: args.statuses,
};
},
},
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 851be211b25..69719011079 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -2,7 +2,6 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
project(fullPath: $fullPath) {
id
jobs(after: $after, first: $first, statuses: $statuses) {
- count
pageInfo {
endCursor
hasNextPage
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql
new file mode 100644
index 00000000000..a4e02ae721a
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql
@@ -0,0 +1,8 @@
+query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!]) {
+ project(fullPath: $fullPath) {
+ id
+ jobs(statuses: $statuses) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index 9ee4439b618..84479ec421e 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -2,6 +2,8 @@
import { GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue';
+import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
@@ -19,6 +21,8 @@ export default {
GlTable,
JobCell,
PipelineCell,
+ ProjectCell,
+ RunnerCell,
},
props: {
jobs: {
@@ -30,6 +34,11 @@ export default {
required: false,
default: () => DEFAULT_FIELDS,
},
+ admin: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
formatCoverage(coverage) {
@@ -66,6 +75,14 @@ export default {
<pipeline-cell :job="item" />
</template>
+ <template v-if="admin" #cell(project)="{ item }">
+ <project-cell :job="item" />
+ </template>
+
+ <template v-if="admin" #cell(runner)="{ item }">
+ <runner-cell :job="item" />
+ </template>
+
<template #cell(stage)="{ item }">
<div class="gl-text-truncate">
<span data-testid="job-stage-name">{{ item.stage.name }}</span>
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 3209fc4b90d..09fa006cb88 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,11 +1,13 @@
<script>
-import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility';
+import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
import { validateQueryString } from '../filtered_search/utils';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
+import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
import JobsTableTabs from './jobs_table_tabs.vue';
@@ -13,20 +15,21 @@ import { RAW_TEXT_WARNING } from './constants';
export default {
i18n: {
- errorMsg: __('There was an error fetching the jobs for your project.'),
+ jobsFetchErrorMsg: __('There was an error fetching the jobs for your project.'),
+ jobsCountErrorMsg: __('There was an error fetching the number of jobs for your project.'),
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,
JobsFilteredSearch,
JobsTable,
JobsTableEmptyState,
JobsTableTabs,
GlIntersectionObserver,
GlLoadingIcon,
+ JobsSkeletonLoader,
},
inject: {
fullPath: {
@@ -43,15 +46,32 @@ export default {
};
},
update(data) {
- const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data.project || {};
+ const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
return {
list,
pageInfo,
- count,
};
},
error() {
- this.hasError = true;
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ jobsCount: {
+ query: GetJobsCount,
+ context: {
+ isSingleRequest: true,
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ ...this.validatedQueryString,
+ };
+ },
+ update({ project }) {
+ return project?.jobs?.count || 0;
+ },
+ error() {
+ this.error = this.$options.i18n.jobsCountErrorMsg;
},
},
},
@@ -60,11 +80,11 @@ export default {
jobs: {
list: [],
},
- hasError: false,
- isAlertDismissed: false,
+ error: '',
scope: null,
infiniteScrollingTriggered: false,
filterSearchTriggered: false,
+ jobsCount: null,
count: 0,
};
},
@@ -72,9 +92,6 @@ export default {
loading() {
return this.$apollo.queries.jobs.loading;
},
- shouldShowAlert() {
- return this.hasError && !this.isAlertDismissed;
- },
// 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
@@ -95,9 +112,6 @@ export default {
showFilteredSearch() {
return !this.scope;
},
- jobsCount() {
- return this.jobs.count;
- },
validatedQueryString() {
const queryStringObject = queryToObject(window.location.search);
@@ -116,17 +130,37 @@ export default {
},
},
methods: {
+ updateHistoryAndFetchCount(status = null) {
+ this.$apollo.queries.jobsCount.refetch({ statuses: status });
+
+ updateHistory({
+ url: setUrlParams({ statuses: status }, window.location.href, true),
+ });
+ },
fetchJobsByStatus(scope) {
this.infiniteScrollingTriggered = false;
+ if (this.scope === scope) return;
+
this.scope = scope;
+ if (!this.scope) this.updateHistoryAndFetchCount();
+
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
filterJobsBySearch(filters) {
this.infiniteScrollingTriggered = false;
this.filterSearchTriggered = true;
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ this.updateHistoryAndFetchCount();
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+
+ return;
+ }
+
// Eventually there will be more tokens available
// this code is written to scale for those tokens
filters.forEach((filter) => {
@@ -141,10 +175,7 @@ export default {
}
if (filter.type === 'status') {
- updateHistory({
- url: setUrlParams({ statuses: filter.value.data }, window.location.href, true),
- });
-
+ this.updateHistoryAndFetchCount(filter.value.data);
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
}
});
@@ -168,14 +199,14 @@ export default {
<template>
<div>
<gl-alert
- v-if="shouldShowAlert"
+ v-if="error"
class="gl-mt-2"
variant="danger"
data-testid="jobs-table-error-alert"
dismissible
- @dismiss="isAlertDismissed = true"
+ @dismiss="error = ''"
>
- {{ $options.i18n.errorMsg }}
+ {{ error }}
</gl-alert>
<jobs-table-tabs
@@ -190,26 +221,11 @@ export default {
/>
</div>
- <div v-if="showSkeletonLoader" class="gl-mt-5">
- <gl-skeleton-loader :width="1248" :height="73">
- <circle cx="748.031" cy="37.7193" r="15.0307" />
- <circle cx="787.241" cy="37.7193" r="15.0307" />
- <circle cx="827.759" cy="37.7193" r="15.0307" />
- <circle cx="866.969" cy="37.7193" r="15.0307" />
- <circle cx="380" cy="37" r="18" />
- <rect x="432" y="19" width="126.587" height="15" />
- <rect x="432" y="41" width="247" height="15" />
- <rect x="158" y="19" width="86.1" height="15" />
- <rect x="158" y="41" width="168" height="15" />
- <rect x="22" y="19" width="96" height="36" />
- <rect x="924" y="30" width="96" height="15" />
- <rect x="1057" y="20" width="166" height="35" />
- </gl-skeleton-loader>
- </div>
+ <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" />
<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/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
index 68c6c669a1a..797facb1eb8 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue';
import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility';
export default {
@@ -9,11 +10,16 @@ export default {
GlTab,
GlTabs,
GlLoadingIcon,
+ CancelJobs,
},
inject: {
jobStatuses: {
default: {},
},
+ url: {
+ type: String,
+ default: '',
+ },
},
props: {
allJobsCount: {
@@ -24,6 +30,11 @@ export default {
type: Boolean,
required: true,
},
+ showCancelAllJobsButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tabs() {
@@ -51,23 +62,27 @@ export default {
</script>
<template>
- <gl-tabs content-class="gl-py-0">
- <gl-tab
- v-for="tab in tabs"
- :key="tab.text"
- :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- 'data-testid': tab.testId,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- @click="$emit('fetchJobsByStatus', tab.scope)"
- >
- <template #title>
- <span>{{ tab.text }}</span>
- <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" />
+ <div class="gl-display-flex align-items-lg-center">
+ <gl-tabs content-class="gl-py-0">
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
+ 'data-testid': tab.testId,
+ } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ @click="$emit('fetchJobsByStatus', tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+ <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" />
- <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge">
- {{ tab.count }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
+ <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge">
+ {{ tab.count }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <div class="gl-flex-grow-1"></div>
+ <cancel-jobs v-if="showCancelAllJobsButton" :url="url" />
+ </div>
</template>
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 027d896ba0e..40b3de7edd9 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -19,7 +19,7 @@ export const JOB_SIDEBAR_COPY = {
};
export const JOB_GRAPHQL_ERRORS = {
- retryMutationErrorText: __('There was an error running the job. Please try again.'),
+ jobMutationErrorText: __('There was an error running the job. Please try again.'),
jobQueryErrorText: __('There was an error fetching the job.'),
};
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index af2d720643f..b348478ccda 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
@@ -22,7 +22,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
pagePath,
});
- return Promise.all([dispatch('fetchJob')]);
+ return dispatch('fetchJob');
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 87c00ad4d70..b7d7006ee61 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -33,8 +33,7 @@ export default {
// the job log response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
-
- state.jobLog = log.lines ? logLinesParser(log.lines) : state.jobLog;
+ state.jobLog = log.lines ? logLinesParser(log.lines, [], window.location.hash) : state.jobLog;
state.jobLogSize = log.size || state.jobLogSize;
}
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index a7b95154c1b..bc76901026d 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -18,12 +18,26 @@ export const parseLine = (line = {}, lineNumber) => ({
* @param Object line
* @param Number lineNumber
*/
-export const parseHeaderLine = (line = {}, lineNumber) => ({
- isClosed: parseBoolean(line.section_options?.collapsed),
- isHeader: true,
- line: parseLine(line, lineNumber),
- lines: [],
-});
+export const parseHeaderLine = (line = {}, lineNumber, hash) => {
+ // if a hash is present in the URL then we ensure
+ // all sections are visible so we can scroll to the hash
+ // in the DOM
+ if (hash) {
+ return {
+ isClosed: false,
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+ };
+ }
+
+ return {
+ isClosed: parseBoolean(line.section_options?.collapsed),
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+ };
+};
/**
* Finds the matching header section
@@ -104,7 +118,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
-export const logLinesParser = (lines = [], accumulator = []) =>
+export const logLinesParser = (lines = [], accumulator = [], hash = '') =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
@@ -113,7 +127,7 @@ export const logLinesParser = (lines = [], accumulator = []) =>
// If the object is an header, we parse it into another structure
if (line.section_header) {
- acc.push(parseHeaderLine(line, lineNumber));
+ acc.push(parseHeaderLine(line, lineNumber, hash));
} else if (isCollapsibleSection(acc, last, line)) {
// if the object belongs to a nested section, we append it to the new `lines` array of the
// previously formatted header
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/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
index 1b99a094c48..298cc20ab35 100644
--- a/app/assets/javascripts/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
@@ -9,7 +9,7 @@ import eventHub from '../event_hub';
export default {
primaryProps: {
text: s__('Labels|Promote Label'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js
index 033ca9dd3ea..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';
@@ -37,6 +35,8 @@ export default class CreateLabelDropdown {
// eslint-disable-next-line @gitlab/no-global-event-off
this.$newColorField.off('keyup change');
// eslint-disable-next-line @gitlab/no-global-event-off
+ this.$colorPreview.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$dropdownBack.off('click');
// eslint-disable-next-line @gitlab/no-global-event-off
this.$cancelButton.off('click');
@@ -47,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);
@@ -54,6 +55,10 @@ export default class CreateLabelDropdown {
this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$colorPreview.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$newColorField.on('input', this.updateColorPreview.bind(this));
+ this.$colorPreview.on('input', this.updateColorPickerPreview.bind(this));
this.$dropdownBack.on('click', this.resetForm.bind(this));
@@ -73,7 +78,19 @@ export default class CreateLabelDropdown {
e.stopPropagation();
this.$newColorField.val($this.data('color')).trigger('change');
- this.$colorPreview.css('background-color', $this.data('color')).parent().addClass('is-active');
+ this.$colorPreview.val($this.data('color')).trigger('change');
+ }
+
+ updateColorPreview() {
+ const previewColor = this.$newColorField.val();
+ return this.$colorPreview.val(previewColor);
+ // Updates the preview color with the hex-color input
+ }
+
+ updateColorPickerPreview() {
+ const previewColor = this.$colorPreview.val();
+ return this.$newColorField.val(previewColor);
+ // Updates the input color with the hex-color from the picker
}
enableLabelCreateButton() {
@@ -92,7 +109,7 @@ export default class CreateLabelDropdown {
this.$addList.prop('checked', this.addListDefault);
- this.$colorPreview.css('background-color', '').parent().removeClass('is-active');
+ this.$colorPreview.val('');
}
saveLabel(e) {
diff --git a/app/assets/javascripts/labels/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js
index c4f80d32a83..3683bb5d5e5 100644
--- a/app/assets/javascripts/labels/group_label_subscription.js
+++ b/app/assets/javascripts/labels/group_label_subscription.js
@@ -1,7 +1,8 @@
import $ from 'jquery';
import { __ } from '~/locale';
import { fixTitle, hide } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
const tooltipTitles = {
@@ -65,7 +66,7 @@ export default class GroupLabelSubscription {
static setNewTooltip($button) {
if (!$button.hasClass('js-subscribe-button')) return;
- const type = $button.hasClass('js-group-level') ? 'group' : 'project';
+ const type = $button.hasClass('js-group-level') ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
const newTitle = tooltipTitles[type];
const $el = $('.js-unsubscribe-button', $button.closest('.label-actions-list'));
diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js
index 0d4113bba4c..c7c17607af6 100644
--- a/app/assets/javascripts/labels/index.js
+++ b/app/assets/javascripts/labels/index.js
@@ -120,16 +120,15 @@ export function initAdminLabels() {
const emptyState = document.querySelector('.js-admin-labels-empty-state');
function removeLabelSuccessCallback() {
- this.closest('li').classList.add('gl-display-none!');
+ this.closest('li.label-list-item').classList.add('gl-display-none!');
const labelsCount = document.querySelectorAll(
- 'ul.manage-labels-list li:not(.gl-display-none\\!)',
+ 'ul.manage-labels-list li.label-list-item:not(.gl-display-none\\!)',
).length;
// display the empty state if there are no more labels
if (labelsCount < 1 && !pagination && emptyState) {
emptyState.classList.remove('gl-display-none');
- labelsContainer.classList.add('gl-display-none');
}
}
diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js
index be515869bff..e3d56df53f8 100644
--- a/app/assets/javascripts/labels/label_manager.js
+++ b/app/assets/javascripts/labels/label_manager.js
@@ -3,7 +3,7 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import { dispose } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -106,7 +106,7 @@ export default class LabelManager {
if (action === 'remove') {
$('.js-priority-badge', $label).remove();
} else {
- $('.label-links', $label).append(this.$badgeItemTemplate.clone().html());
+ $('.label-links', $label).prepend(this.$badgeItemTemplate.clone().html());
}
}
diff --git a/app/assets/javascripts/labels/labels.js b/app/assets/javascripts/labels/labels.js
index cd8cf0d354c..acb29e1a1f0 100644
--- a/app/assets/javascripts/labels/labels.js
+++ b/app/assets/javascripts/labels/labels.js
@@ -7,29 +7,39 @@ export default class Labels {
this.cleanBinding();
this.addBinding();
this.updateColorPreview();
+ this.updateColorPickerPreview();
}
addBinding() {
$(document).on('click', '.suggest-colors a', this.setSuggestedColor);
+ $(document).on('input', '.label-color-preview', this.updateColorPickerPreview);
return $(document).on('input', 'input#label_color', this.updateColorPreview);
}
// eslint-disable-next-line class-methods-use-this
cleanBinding() {
$(document).off('click', '.suggest-colors a');
+ $(document).off('input', '.label-color-preview');
return $(document).off('input', 'input#label_color');
}
// eslint-disable-next-line class-methods-use-this
updateColorPreview() {
const previewColor = $('input#label_color').val();
- return $('div.label-color-preview').css('background-color', previewColor);
+ return $('.label-color-preview').val(previewColor);
// Updates the preview color with the hex-color input
}
+ // eslint-disable-next-line class-methods-use-this
+ updateColorPickerPreview() {
+ const previewColor = $('.label-color-preview').val();
+ return $('input#label_color').val(previewColor);
+ // Updates the input color with the hex-color from the picker
+ }
// Updates the preview color with a click on a suggested color
setSuggestedColor(e) {
const color = $(e.currentTarget).data('color');
$('input#label_color').val(color);
this.updateColorPreview();
+ this.updateColorPickerPreview();
// Notify the form, that color has changed
$('.label-form').trigger('keyup');
return e.preventDefault();
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 515b0a79a03..587cc82f0fa 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -6,7 +6,7 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import IssuableBulkUpdateActions from '~/issuable/issuable_bulk_update_actions';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { sprintf, __ } from '~/locale';
import CreateLabelDropdown from './create_label_dropdown';
diff --git a/app/assets/javascripts/labels/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js
index 9ca6ee5609c..1629d3b4d88 100644
--- a/app/assets/javascripts/labels/project_label_subscription.js
+++ b/app/assets/javascripts/labels/project_label_subscription.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { fixTitle } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -68,7 +69,7 @@ export default class ProjectLabelSubscription {
}
static setNewTitle($button, originalTitle, newStatus) {
- const type = /group/.test(originalTitle) ? 'group' : 'project';
+ const type = /group/.test(originalTitle) ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
const newTitle = tooltipTitles[type][newStatus];
$button.attr('title', newTitle);
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index b8138f34d45..f5078962b8f 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -70,10 +70,11 @@ function initDeferred() {
}
export default function initLayoutNav() {
- const contextualSidebar = new ContextualSidebar();
- contextualSidebar.bindEvents();
-
- initFlyOutNav();
+ if (!gon.use_new_navigation) {
+ const contextualSidebar = new ContextualSidebar();
+ contextualSidebar.bindEvents();
+ initFlyOutNav();
+ }
requestIdleCallback(initDeferred);
}
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..a4c13f9e40e 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,8 +122,10 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
path = '/api/graphql',
useGet = false,
- localCacheKey = null,
} = config;
+
+ const shouldUnbatch = gon.features?.unbatchGraphqlQueries;
+
let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
@@ -152,7 +163,7 @@ export default (resolvers = {}, config = {}) => {
};
const requestLink = ApolloLink.split(
- () => useGet,
+ () => useGet || shouldUnbatch,
new HttpLink({ ...httpOptions, fetch: fetchIntervention }),
new BatchHttpLink(httpOptions),
);
@@ -171,6 +182,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 +249,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 +264,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..bbc1d8ae1e1 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -6,17 +6,20 @@ const setIframeRenderedSize = (h, w) => {
window.parent.postMessage({ h, w }, origin);
};
-const drawDiagram = (source) => {
+const drawDiagram = async (source) => {
const element = document.getElementById('app');
const insertSvg = (svgCode) => {
// 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);
+
+ const { svg } = await mermaid.mermaidAPI.render('mermaid', source);
+ insertSvg(svg);
};
const darkModeEnabled = () => getParameterByName('darkMode') === 'true';
diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js
new file mode 100644
index 00000000000..ef3f54ec314
--- /dev/null
+++ b/app/assets/javascripts/lib/mousetrap.js
@@ -0,0 +1,59 @@
+// This is the only file allowed to import directly from the package.
+// eslint-disable-next-line no-restricted-imports
+import Mousetrap from 'mousetrap';
+
+const additionalStopCallbacks = [];
+const originalStopCallback = Mousetrap.prototype.stopCallback;
+
+Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
+ for (const callback of additionalStopCallbacks) {
+ const returnValue = callback.call(this, e, element, combo);
+ if (returnValue !== undefined) return returnValue;
+ }
+
+ return originalStopCallback.call(this, e, element, combo);
+};
+
+/**
+ * Add a stop callback to Mousetrap.
+ *
+ * This allows overriding the default behaviour of Mousetrap#stopCallback,
+ * which is to stop the bound key handler/callback from being called if the key
+ * combo is pressed inside form fields (input, select, textareas, etc). See
+ * https://craig.is/killing/mice#api.stopCallback.
+ *
+ * The stopCallback registered here has the same signature as
+ * Mousetrap#stopCallback, with the one difference being that the callback
+ * should return `undefined` if it has no opinion on whether the current key
+ * combo should be stopped or not, and the next stop callback should be
+ * consulted instead. If a boolean is returned, no other stop callbacks are
+ * called.
+ *
+ * Note: This approach does not always work as expected when coupled with
+ * Mousetrap's pause plugin, which is used for enabling/disabling all keyboard
+ * shortcuts. That plugin assumes it's the first to execute and overwrite
+ * Mousetrap's `stopCallback` method, whereas to work correctly with this, it
+ * must execute last. This is not guaranteed or even attempted.
+ *
+ * To work correctly, we may need to reimplement the pause plugin here.
+ *
+ * @param {(e: Event, element: Element, combo: string) => boolean|undefined}
+ * stopCallback The additional stop callback function to add to the chain
+ * of stop callbacks.
+ * @returns {void}
+ */
+export const addStopCallback = (stopCallback) => {
+ // Unshift, since we want to iterate through them in reverse order, so that
+ // the most recently added handler is called first, and the original
+ // stopCallback method is called last.
+ additionalStopCallbacks.unshift(stopCallback);
+};
+
+/**
+ * Clear additionalStopCallbacks. Used only for tests.
+ */
+export const clearStopCallbacksForTests = () => {
+ additionalStopCallbacks.length = 0;
+};
+
+export { Mousetrap };
diff --git a/app/assets/javascripts/lib/swagger.js b/app/assets/javascripts/lib/swagger.js
index ed646176604..fcdab18c623 100644
--- a/app/assets/javascripts/lib/swagger.js
+++ b/app/assets/javascripts/lib/swagger.js
@@ -1,6 +1,13 @@
import { SwaggerUIBundle } from 'swagger-ui-dist';
import { safeLoad } from 'js-yaml';
import { isObject } from '~/lib/utils/type_utility';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import { resetServiceWorkersPublicPath } from '~/lib/utils/webpack';
+
+const resetWebpackPublicPath = () => {
+ window.gon = { relative_url_root: getParameterByName('relativeRootPath') };
+ resetServiceWorkersPublicPath();
+};
const renderSwaggerUI = (value) => {
/* SwaggerUIBundle accepts openapi definition
@@ -12,6 +19,8 @@ const renderSwaggerUI = (value) => {
spec = safeLoad(spec, { json: true });
}
+ resetWebpackPublicPath();
+
Promise.all([import(/* webpackChunkName: 'openapi' */ 'swagger-ui-dist/swagger-ui.css')])
.then(() => {
SwaggerUIBundle({
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/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index 3d8df4fde05..a9f4257e28b 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -8,7 +8,7 @@ const colorValidatorEl = document.createElement('div');
* element’s color property. If the color expression is valid,
* the DOM API will accept the value.
*
- * @param {String} color color expression rgba, hex, hsla, etc.
+ * @param {String} colorExpression color expression rgba, hex, hsla, etc.
*/
export const isValidColorExpression = (colorExpression) => {
colorValidatorEl.style.color = '';
@@ -18,32 +18,6 @@ export const isValidColorExpression = (colorExpression) => {
};
/**
- * Convert hex color to rgb array
- *
- * @param hex string
- * @returns array|null
- */
-export const hexToRgb = (hex) => {
- // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
- const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
- const fullHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b);
-
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex);
- return result
- ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
- : null;
-};
-
-export const textColorForBackground = (backgroundColor) => {
- const [r, g, b] = hexToRgb(backgroundColor);
-
- if (r + g + b > 500) {
- return '#333333';
- }
- return '#FFFFFF';
-};
-
-/**
* Check whether a color matches the expected hex format
*
* This matches any hex (0-9 and A-F) value which is either 3 or 6 characters in length
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
index 3bfbfea7f22..a6081303bf8 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
@@ -12,6 +12,7 @@ export function confirmAction(
modalHtmlMessage,
title,
hideCancel,
+ size,
} = {},
) {
return new Promise((resolve) => {
@@ -36,6 +37,7 @@ export function confirmAction(
title,
modalHtmlMessage,
hideCancel,
+ size,
},
on: {
confirmed() {
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index ea91ccec546..24be1485379 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: false,
},
+ size: {
+ type: String,
+ required: false,
+ default: 'sm',
+ },
},
computed: {
primaryAction() {
@@ -103,9 +108,9 @@ export default {
<template>
<gl-modal
ref="modal"
- size="sm"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ :size="size"
:title="title"
:action-primary="primaryAction"
:action-cancel="cancelAction"
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 2c8953237cf..fb69a61880a 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -26,3 +26,8 @@ export const DEFAULT_TH_CLASSES =
export const DRAWER_Z_INDEX = 252;
export const MIN_USERNAME_LENGTH = 2;
+
+export const BYTES_FORMAT_BYTES = 'Bytes';
+export const BYTES_FORMAT_KIB = 'KiB';
+export const BYTES_FORMAT_MIB = 'MiB';
+export const BYTES_FORMAT_GIB = 'GiB';
diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js
index e4f68dd1b6c..87cc69bad61 100644
--- a/app/assets/javascripts/lib/utils/css_utils.js
+++ b/app/assets/javascripts/lib/utils/css_utils.js
@@ -23,3 +23,28 @@ export function loadCSSFile(path) {
export function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
+
+/**
+ * Return the measured width and height of a temporary element with the given
+ * CSS classes.
+ *
+ * Multiple classes can be given by separating them with spaces.
+ *
+ * Since this forces a layout calculation, do not call this frequently or in
+ * loops.
+ *
+ * Finally, this assumes the styles for the given classes are loaded.
+ *
+ * @param {string} className CSS class(es) to apply to a temporary element and
+ * measure.
+ * @returns {{ width: number, height: number }} Measured width and height in
+ * CSS pixels.
+ */
+export function getCssClassDimensions(className) {
+ const el = document.createElement('div');
+ el.className = className;
+ document.body.appendChild(el);
+ const { width, height } = el.getBoundingClientRect();
+ el.remove();
+ return { width, height };
+}
diff --git a/app/assets/javascripts/lib/utils/datetime/constants.js b/app/assets/javascripts/lib/utils/datetime/constants.js
new file mode 100644
index 00000000000..869ade45ebd
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/constants.js
@@ -0,0 +1,7 @@
+// Keys for the memoized Intl dateTime formatters
+export const DATE_WITH_TIME_FORMAT = 'DATE_WITH_TIME_FORMAT';
+export const DATE_ONLY_FORMAT = 'DATE_ONLY_FORMAT';
+
+export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT;
+
+export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT];
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 04a82836f69..e1a57bf4589 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -162,16 +162,24 @@ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = fals
* @returns {string}
*/
export const formatTime = (milliseconds) => {
- const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
- const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
- const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
+ const seconds = Math.round(milliseconds / 1000);
+ const absSeconds = Math.abs(seconds);
+
+ const remainingSeconds = Math.floor(absSeconds) % 60;
+ const remainingMinutes = Math.floor(absSeconds / 60) % 60;
+ const hours = Math.floor(absSeconds / 60 / 60);
+
let formattedTime = '';
- if (remainingHours < 10) formattedTime += '0';
- formattedTime += `${remainingHours}:`;
+ if (hours < 10) formattedTime += '0';
+ formattedTime += `${hours}:`;
if (remainingMinutes < 10) formattedTime += '0';
formattedTime += `${remainingMinutes}:`;
if (remainingSeconds < 10) formattedTime += '0';
formattedTime += remainingSeconds;
+
+ if (seconds < 0) {
+ return `-${formattedTime}`;
+ }
return formattedTime;
};
@@ -203,7 +211,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
const isNonZero = Boolean(unitValue);
if (fullNameFormat && isNonZero) {
- // Remove traling 's' if unit value is singular
+ // Remove trailing 's' if unit value is singular
const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
return `${memo} ${unitValue} ${formattedUnitName}`;
}
@@ -387,26 +395,6 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont
return '-';
};
-export const durationTimeFormatted = (duration) => {
- const date = new Date(duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
-};
-
/**
* Converts a numeric utc offset in seconds to +/- hours
* ie -32400 => -9 hours
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/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
index 05f34db662a..a973cd890ba 100644
--- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
@@ -1,6 +1,7 @@
import * as timeago from 'timeago.js';
import { languageCode, s__, createDateTimeFormat } from '~/locale';
import { formatDate } from './date_format_utility';
+import { DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT, DEFAULT_DATE_TIME_FORMAT } from './constants';
/**
* Timeago uses underscores instead of dashes to separate language from country code.
@@ -106,26 +107,39 @@ timeago.register(timeagoLanguageCode, memoizedLocale());
timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration());
-let memoizedFormatter = null;
+const setupAbsoluteFormatters = () => {
+ const cache = {};
-function setupAbsoluteFormatter() {
- if (memoizedFormatter === null) {
- const formatter = createDateTimeFormat({
- dateStyle: 'medium',
- timeStyle: 'short',
- });
+ // Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options)
+ const formats = {
+ [DATE_WITH_TIME_FORMAT]: () => ({ dateStyle: 'medium', timeStyle: 'short' }),
+ [DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }),
+ };
+
+ return (formatName = DEFAULT_DATE_TIME_FORMAT) => {
+ if (cache[formatName]) {
+ return cache[formatName];
+ }
+
+ let format = formats[formatName] && formats[formatName]();
+ if (!format) {
+ format = formats[DEFAULT_DATE_TIME_FORMAT]();
+ }
+
+ const formatter = createDateTimeFormat(format);
- memoizedFormatter = {
+ cache[formatName] = {
format(date) {
return formatter.format(date instanceof Date ? date : new Date(date));
},
};
- }
- return memoizedFormatter;
-}
+ return cache[formatName];
+ };
+};
+const memoizedFormatters = setupAbsoluteFormatters();
-export const getTimeago = () =>
- window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago;
+export const getTimeago = (formatName) =>
+ window.gon?.time_display_relative === false ? memoizedFormatters(formatName) : timeago;
/**
* For the given elements, sets a tooltip with a formatted date.
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index c1081239544..a6331bc6551 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,4 +1,6 @@
+export * from './datetime/constants';
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/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 317c401e404..198f2da385c 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -39,7 +39,7 @@ export const toggleContainerClasses = (containerEl, classList) => {
* Return a object mapping element dataset names to booleans.
*
* This is useful for data- attributes whose presense represent
- * a truthiness, no matter the value of the attribute. The absense of the
+ * a truthiness, no matter the value of the attribute. The absence of the
* attribute represents falsiness.
*
* This can be useful when Rails-provided boolean-like values are passed
diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js
new file mode 100644
index 00000000000..febf83a4d38
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/error_message.js
@@ -0,0 +1,15 @@
+/**
+ * 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/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index f99a4927338..c80d3f24d07 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -29,3 +29,10 @@ export const validateImageName = (file) => {
const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/;
return legalImageRegex.test(fileName) ? fileName : 'image.png';
};
+
+export const validateFileFromAllowList = (fileName, allowList) => {
+ const parts = fileName.split('.');
+ const ext = `.${parts[parts.length - 1]}`;
+
+ return allowList.includes(ext);
+};
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/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index b0e31fe729b..d64f84d2040 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,5 +1,12 @@
import { sprintf, __ } from '~/locale';
-import { BYTES_IN_KIB, THOUSAND } from './constants';
+import {
+ BYTES_IN_KIB,
+ THOUSAND,
+ BYTES_FORMAT_BYTES,
+ BYTES_FORMAT_KIB,
+ BYTES_FORMAT_MIB,
+ BYTES_FORMAT_GIB,
+} from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -64,25 +71,51 @@ export function bytesToGiB(number) {
}
/**
- * Port of rails number_to_human_size
* Formats the bytes in number into a more understandable
- * representation (e.g., giving it 1500 yields 1.5 KB).
+ * representation. Returns an array with the first value being the human size
+ * and the second value being the format (e.g., [1.5, 'KiB']).
*
* @param {Number} size
* @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String}
*/
-export function numberToHumanSize(size, digits = 2) {
+export function numberToHumanSizeSplit(size, digits = 2) {
const abs = Math.abs(size);
if (abs < BYTES_IN_KIB) {
- return sprintf(__('%{size} bytes'), { size });
+ return [size.toString(), BYTES_FORMAT_BYTES];
} else if (abs < BYTES_IN_KIB ** 2) {
- return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(digits) });
+ return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB];
} else if (abs < BYTES_IN_KIB ** 3) {
- return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(digits) });
+ return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB];
+ }
+ return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB];
+}
+
+/**
+ * Port of rails number_to_human_size
+ * Formats the bytes in number into a more understandable
+ * representation (e.g., giving it 1500 yields 1.5 KB).
+ *
+ * @param {Number} size
+ * @param {Number} digits - The number of digits to appear after the decimal point
+ * @returns {String}
+ */
+export function numberToHumanSize(size, digits = 2) {
+ const [humanSize, format] = numberToHumanSizeSplit(size, digits);
+
+ switch (format) {
+ case BYTES_FORMAT_BYTES:
+ return sprintf(__('%{size} bytes'), { size: humanSize });
+ case BYTES_FORMAT_KIB:
+ return sprintf(__('%{size} KiB'), { size: humanSize });
+ case BYTES_FORMAT_MIB:
+ return sprintf(__('%{size} MiB'), { size: humanSize });
+ case BYTES_FORMAT_GIB:
+ return sprintf(__('%{size} GiB'), { size: humanSize });
+ default:
+ return '';
}
- return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(digits) });
}
/**
diff --git a/app/assets/javascripts/lib/utils/ref_validator.js b/app/assets/javascripts/lib/utils/ref_validator.js
new file mode 100644
index 00000000000..d679a3b4198
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ref_validator.js
@@ -0,0 +1,145 @@
+import { __, sprintf } from '~/locale';
+
+// this service validates tagName agains git ref format.
+// the git spec can be found here: https://git-scm.com/docs/git-check-ref-format#_description
+
+// the ruby counterpart of the validator is here:
+// lib/gitlab/git_ref_validator.rb
+
+const EXPANDED_PREFIXES = ['refs/heads/', 'refs/remotes/', 'refs/tags'];
+const DISALLOWED_PREFIXES = ['-', '/'];
+const DISALLOWED_POSTFIXES = ['/'];
+const DISALLOWED_NAMES = ['HEAD', '@'];
+const DISALLOWED_SUBSTRINGS = [' ', '\\', '~', ':', '..', '^', '?', '*', '[', '@{'];
+const DISALLOWED_SEQUENCE_POSTFIXES = ['.lock', '.'];
+const DISALLOWED_SEQUENCE_PREFIXES = ['.'];
+
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARACTERS_REGEX = /[\x00-\x19\x7f]/;
+
+const toReadableString = (array) => array.map((item) => `"${item}"`).join(', ');
+
+const DisallowedPrefixesValidationMessage = sprintf(
+ __('Tag name should not start with %{prefixes}'),
+ {
+ prefixes: toReadableString([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES]),
+ },
+ false,
+);
+
+const DisallowedPostfixesValidationMessage = sprintf(
+ __('Tag name should not end with %{postfixes}'),
+ { postfixes: toReadableString(DISALLOWED_POSTFIXES) },
+ false,
+);
+
+const DisallowedNameValidationMessage = sprintf(
+ __('Tag name cannot be one of the following: %{names}'),
+ { names: toReadableString(DISALLOWED_NAMES) },
+ false,
+);
+
+const EmptyNameValidationMessage = __('Tag name should not be empty');
+
+const DisallowedSubstringsValidationMessage = sprintf(
+ __('Tag name should not contain any of the following: %{substrings}'),
+ { substrings: toReadableString(DISALLOWED_SUBSTRINGS) },
+ false,
+);
+
+const DisallowedSequenceEmptyValidationMessage = __(
+ `No slash-separated tag name component can be empty`,
+);
+
+const DisallowedSequencePrefixesValidationMessage = sprintf(
+ __('No slash-separated component can begin with %{sequencePrefixes}'),
+ { sequencePrefixes: toReadableString(DISALLOWED_SEQUENCE_PREFIXES) },
+ false,
+);
+
+const DisallowedSequencePostfixesValidationMessage = sprintf(
+ __('No slash-separated component can end with %{sequencePostfixes}'),
+ { sequencePostfixes: toReadableString(DISALLOWED_SEQUENCE_POSTFIXES) },
+ false,
+);
+
+const ControlCharactersValidationMessage = __('Tag name should not contain any control characters');
+
+export const validationMessages = {
+ EmptyNameValidationMessage,
+ DisallowedPrefixesValidationMessage,
+ DisallowedPostfixesValidationMessage,
+ DisallowedNameValidationMessage,
+ DisallowedSubstringsValidationMessage,
+ DisallowedSequenceEmptyValidationMessage,
+ DisallowedSequencePrefixesValidationMessage,
+ DisallowedSequencePostfixesValidationMessage,
+ ControlCharactersValidationMessage,
+};
+
+export class ValidationResult {
+ isValid = true;
+ validationErrors = [];
+
+ addValidationError = (errorMessage) => {
+ this.isValid = false;
+ this.validationErrors.push(errorMessage);
+ };
+}
+
+export const validateTag = (refName) => {
+ if (typeof refName !== 'string') {
+ throw new Error('refName argument must be a string');
+ }
+
+ const validationResult = new ValidationResult();
+
+ if (!refName || refName.trim() === '') {
+ validationResult.addValidationError(EmptyNameValidationMessage);
+ return validationResult;
+ }
+
+ if (CONTROL_CHARACTERS_REGEX.test(refName)) {
+ validationResult.addValidationError(ControlCharactersValidationMessage);
+ }
+
+ if (DISALLOWED_NAMES.some((name) => name === refName)) {
+ validationResult.addValidationError(DisallowedNameValidationMessage);
+ }
+
+ if ([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES].some((prefix) => refName.startsWith(prefix))) {
+ validationResult.addValidationError(DisallowedPrefixesValidationMessage);
+ }
+
+ if (DISALLOWED_POSTFIXES.some((postfix) => refName.endsWith(postfix))) {
+ validationResult.addValidationError(DisallowedPostfixesValidationMessage);
+ }
+
+ if (DISALLOWED_SUBSTRINGS.some((substring) => refName.includes(substring))) {
+ validationResult.addValidationError(DisallowedSubstringsValidationMessage);
+ }
+
+ const refNameParts = refName.split('/');
+
+ if (refNameParts.some((part) => part === '')) {
+ validationResult.addValidationError(DisallowedSequenceEmptyValidationMessage);
+ }
+
+ if (
+ refNameParts.some((part) =>
+ DISALLOWED_SEQUENCE_PREFIXES.some((prefix) => part.startsWith(prefix)),
+ )
+ ) {
+ validationResult.addValidationError(DisallowedSequencePrefixesValidationMessage);
+ }
+
+ if (
+ refNameParts.some((part) =>
+ DISALLOWED_SEQUENCE_POSTFIXES.some((postfix) => part.endsWith(postfix)),
+ )
+ ) {
+ validationResult.addValidationError(DisallowedSequencePostfixesValidationMessage);
+ }
+
+ return validationResult;
+};
diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js
index 5d194340b9e..1db863294f8 100644
--- a/app/assets/javascripts/lib/utils/resize_observer.js
+++ b/app/assets/javascripts/lib/utils/resize_observer.js
@@ -19,27 +19,31 @@ export function createResizeObserver() {
* @param {Object} options
* @param {string} options.targetId - id of element to scroll to
* @param {string} options.container - Selector of element containing target
+ * @param {Element} options.component - Element containing target
*
* @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID
*/
export function scrollToTargetOnResize({
targetId = window.location.hash.slice(1),
container = '#content-body',
+ containerId,
} = {}) {
if (!targetId) return null;
const ro = createResizeObserver();
- const containerEl = document.querySelector(container);
+ const containerEl =
+ document.querySelector(`#${containerId}`) || document.querySelector(container);
let interactionListenersAdded = false;
- function keepTargetAtTop() {
+ function keepTargetAtTop(evt) {
const anchorEl = document.getElementById(targetId);
+ const scrollContainer = containerId ? evt.target : document.documentElement;
if (!anchorEl) return;
const anchorTop = anchorEl.getBoundingClientRect().top + window.scrollY;
const top = anchorTop - contentTop();
- document.documentElement.scrollTo({
+ scrollContainer.scrollTo({
top,
});
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/sticky.js b/app/assets/javascripts/lib/utils/sticky.js
deleted file mode 100644
index a6d53358cb8..00000000000
--- a/app/assets/javascripts/lib/utils/sticky.js
+++ /dev/null
@@ -1,60 +0,0 @@
-export const createPlaceholder = () => {
- const placeholder = document.createElement('div');
- placeholder.classList.add('sticky-placeholder');
-
- return placeholder;
-};
-
-export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => {
- const top = Math.floor(el.offsetTop - scrollY);
-
- if (top <= stickyTop && !el.classList.contains('is-stuck')) {
- const placeholder = insertPlaceholder ? createPlaceholder() : null;
- const heightBefore = el.offsetHeight;
-
- el.classList.add('is-stuck');
-
- if (insertPlaceholder) {
- el.parentNode.insertBefore(placeholder, el.nextElementSibling);
-
- placeholder.style.height = `${heightBefore - el.offsetHeight}px`;
- }
- } else if (top > stickyTop && el.classList.contains('is-stuck')) {
- el.classList.remove('is-stuck');
-
- if (
- insertPlaceholder &&
- el.nextElementSibling &&
- el.nextElementSibling.classList.contains('sticky-placeholder')
- ) {
- el.nextElementSibling.remove();
- }
- }
-};
-
-/**
- * Create a listener that will toggle a 'is-stuck' class, based on the current scroll position.
- *
- * - If the current environment does not support `position: sticky`, do nothing.
- *
- * @param {HTMLElement} el The `position: sticky` element.
- * @param {Number} stickyTop Used to determine when an element is stuck.
- * @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck?
- */
-export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => {
- if (!el) return;
-
- if (
- typeof CSS === 'undefined' ||
- !CSS.supports('(position: -webkit-sticky) or (position: sticky)')
- )
- return;
-
- document.addEventListener(
- 'scroll',
- () => isSticky(el, window.scrollY, stickyTop, insertPlaceholder),
- {
- passive: true,
- },
- );
-};
diff --git a/app/assets/javascripts/lib/utils/tappable_promise.js b/app/assets/javascripts/lib/utils/tappable_promise.js
new file mode 100644
index 00000000000..8d327dabe1b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/tappable_promise.js
@@ -0,0 +1,49 @@
+/**
+ * A promise that is also tappable, i.e. something you can subscribe
+ * to to get progress of a promise until it resolves.
+ *
+ * @example Usage
+ * const tp = new TappablePromise((resolve, reject, tap) => {
+ * for (let i = 0; i < 10; i++) {
+ * tap(i/10);
+ * }
+ * resolve();
+ * });
+ *
+ * tp.tap((progress) => {
+ * console.log(progress);
+ * }).then(() => {
+ * console.log('done');
+ * });
+ *
+ * // Output:
+ * // 0
+ * // 0.1
+ * // 0.2
+ * // ...
+ * // 0.9
+ * // done
+ *
+ *
+ * @param {(resolve: Function, reject: Function, tap: Function) => void} callback
+ * @returns {Promise & { tap: Function }}}
+ */
+export default function TappablePromise(callback) {
+ let progressCallback;
+
+ const promise = new Promise((resolve, reject) => {
+ try {
+ const tap = (progress) => progressCallback?.(progress);
+ resolve(callback(tap, resolve, reject));
+ } catch (e) {
+ reject(e);
+ }
+ });
+
+ promise.tap = function tap(_progressCallback) {
+ progressCallback = _progressCallback;
+ return this;
+ };
+
+ return promise;
+}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 05ed08931bb..a2873622682 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
const INDENT_CHAR = ' ';
@@ -370,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();
@@ -625,10 +626,9 @@ export function addMarkdownListeners(form) {
Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
});
- // eslint-disable-next-line @gitlab/no-global-event-off
- const $allToolbarBtns = $('.js-md', form)
- .off('click')
- .on('click', function () {
+ const $allToolbarBtns = $(form)
+ .off('click', '.js-md')
+ .on('click', '.js-md', function () {
const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
@@ -669,3 +669,50 @@ export function removeMarkdownListeners(form) {
// eslint-disable-next-line @gitlab/no-global-event-off
return $('.js-md', form).off('click');
}
+
+/**
+ * If the textarea cursor is positioned in a Markdown image declaration,
+ * it uses the Markdown API to resolve the image’s absolute URL.
+ * @param {Object} textarea Textarea DOM element
+ * @param {String} markdownPreviewPath Markdown API path
+ * @returns {Object} an object containing the image’s absolute URL, filename,
+ * and the markdown declaration. If the textarea cursor is not positioned
+ * in an image, it returns null.
+ */
+export const resolveSelectedImage = async (textArea, markdownPreviewPath = '') => {
+ const { lines, startPos } = linesFromSelection(textArea);
+
+ // image declarations can’t span more than one line in Markdown
+ if (lines > 0) {
+ return null;
+ }
+
+ const selectedLine = lines[0];
+
+ if (!/!\[.+?\]\(.+?\)/.test(selectedLine)) return null;
+
+ const lineSelectionStart = textArea.selectionStart - startPos;
+ const preExlm = selectedLine.substring(0, lineSelectionStart).lastIndexOf('!');
+ const postClose = selectedLine.substring(lineSelectionStart).indexOf(')');
+
+ if (preExlm >= 0 && postClose >= 0) {
+ const imageMarkdown = selectedLine.substring(preExlm, lineSelectionStart + postClose + 1);
+ const { data } = await axios.post(markdownPreviewPath, { text: imageMarkdown });
+ const parser = new DOMParser();
+
+ const dom = parser.parseFromString(data.body, 'text/html');
+ const imageURL = dom.body.querySelector('a').getAttribute('href');
+
+ if (imageURL) {
+ const filename = imageURL.substring(imageURL.lastIndexOf('/') + 1);
+
+ return {
+ imageMarkdown,
+ imageURL,
+ filename,
+ };
+ }
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 367180714df..963041dd5d0 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,4 +1,5 @@
import { isString, memoize } from 'lodash';
+import { sprintf, __ } from '~/locale';
import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util';
import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
@@ -482,7 +483,7 @@ export const markdownConfig = {
'ul',
'var',
],
- ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
+ ALLOWED_ATTR: ['class', 'style', 'href', 'src', 'dir'],
ALLOW_DATA_ATTR: false,
};
@@ -525,3 +526,45 @@ export function base64DecodeUnicode(str) {
const decoder = new TextDecoder('utf8');
return decoder.decode(base64ToBuffer(str));
}
+
+// returns an array of errors (if there are any)
+const INVALID_BRANCH_NAME_CHARS = [' ', '~', '^', ':', '?', '*', '[', '..', '@{', '\\', '//'];
+
+/**
+ * Returns an array of invalid characters found in a branch name
+ *
+ * @param {String} name branch name to check
+ * @return {Array} Array of invalid characters found
+ */
+export const findInvalidBranchNameCharacters = (name) => {
+ const invalidChars = [];
+
+ INVALID_BRANCH_NAME_CHARS.forEach((pattern) => {
+ if (name.indexOf(pattern) > -1) {
+ invalidChars.push(pattern);
+ }
+ });
+
+ return invalidChars;
+};
+
+/**
+ * Returns a string describing validation errors for a branch name
+ *
+ * @param {Array} invalidChars Array of invalid characters that were found
+ * @return {String} Error message describing on the invalid characters found
+ */
+export const humanizeBranchValidationErrors = (invalidChars = []) => {
+ const chars = invalidChars.filter((c) => INVALID_BRANCH_NAME_CHARS.includes(c));
+
+ if (chars.length && !chars.includes(' ')) {
+ return sprintf(__("Can't contain %{chars}"), { chars: chars.join(', ') });
+ } else if (chars.includes(' ') && chars.length <= 1) {
+ return __("Can't contain spaces");
+ } else if (chars.includes(' ') && chars.length > 1) {
+ return sprintf(__("Can't contain spaces, %{chars}"), {
+ chars: chars.filter((c) => c !== ' ').join(', '),
+ });
+ }
+ return '';
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index f33484f4192..f16ff188edb 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -5,8 +5,11 @@ const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
+// GitLab default domain (override in jh)
+export const DOMAIN = 'gitlab.com';
+
// About GitLab default host (overwrite in jh)
-export const PROMO_HOST = 'about.gitlab.com';
+export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com
// About Gitlab default url (overwrite in jh)
export const PROMO_URL = `https://${PROMO_HOST}`;
@@ -269,6 +272,11 @@ export const setUrlFragment = (url, fragment) => {
return `${rootUrl}#${encodedFragment}`;
};
+/**
+ * Navigates to a URL
+ * @param {*} url - url to navigate to
+ * @param {*} external - if true, open a new page or tab
+ */
export function visitUrl(url, external = false) {
if (external) {
// Simulate `target="_blank" rel="noopener noreferrer"`
@@ -281,6 +289,19 @@ export function visitUrl(url, external = false) {
}
}
+export function refreshCurrentPage() {
+ visitUrl(window.location.href);
+}
+
+/**
+ * Navigates to a URL
+ * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead
+ * @param {*} url
+ */
+export function redirectTo(url) {
+ return window.location.assign(url);
+}
+
export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
if (win.history) {
if (replace) {
@@ -291,14 +312,6 @@ export function updateHistory({ state = {}, title = '', url, replace = false, wi
}
}
-export function refreshCurrentPage() {
- visitUrl(window.location.href);
-}
-
-export function redirectTo(url) {
- return window.location.assign(url);
-}
-
export const escapeFileUrl = (fileUrl) => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
export function webIDEUrl(route = undefined) {
@@ -318,15 +331,6 @@ export function getBaseURL() {
}
/**
- * Takes a URL and returns content from the start until the final '/'
- *
- * @param {String} url - full url, including protocol and host
- */
-export function stripFinalUrlSegment(url) {
- return new URL('.', url).href;
-}
-
-/**
* Returns true if url is an absolute URL
*
* @param {String} url
diff --git a/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js
new file mode 100644
index 00000000000..ec8feb7d2e6
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+
+export const compatFunctionalMixin = Vue.version.startsWith('3')
+ ? {
+ created() {
+ this.props = this.$props;
+ this.listeners = this.$listeners;
+ },
+ }
+ : {
+ created() {
+ throw new Error('This mixin should not be executed in Vue.js 2');
+ },
+ };
diff --git a/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js
new file mode 100644
index 00000000000..b69f5e0c546
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js
@@ -0,0 +1,9 @@
+// See https://v3-migration.vuejs.org/breaking-changes/custom-directives.html#edge-case-accessing-the-component-instance
+export function getInstanceFromDirective({ binding, vnode }) {
+ if (binding.instance) {
+ // this is Vue.js 3, even in compat mode
+ return binding.instance;
+ }
+
+ return vnode.context;
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js
new file mode 100644
index 00000000000..daafbad8ba1
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js
@@ -0,0 +1,9 @@
+// this will be replaced by markRaw from vue.js v3
+export function markRaw(obj) {
+ Object.defineProperty(obj, '__v_skip', {
+ value: true,
+ configurable: true,
+ });
+
+ return obj;
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js
new file mode 100644
index 00000000000..616bd4786a9
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js
@@ -0,0 +1,11 @@
+export function normalizeChildren(children) {
+ if (typeof children !== 'object' || Array.isArray(children)) {
+ return children;
+ }
+
+ if (typeof children.default === 'function') {
+ return children.default();
+ }
+
+ return children;
+}
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..aa2963ece31
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -0,0 +1,115 @@
+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: [],
+ 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 new Proxy(createRouter(transformOptions(options)), {
+ get(target, prop) {
+ const result = target[prop];
+ // eslint-disable-next-line no-underscore-dangle
+ if (result?.__v_isRef) {
+ return result.value;
+ }
+
+ return result;
+ },
+ });
+ }
+
+ 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/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js
index 38d9d84f889..28b0892d126 100644
--- a/app/assets/javascripts/listbox/redirect_behavior.js
+++ b/app/assets/javascripts/listbox/redirect_behavior.js
@@ -1,5 +1,5 @@
import { initListbox } from '~/listbox';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
/**
* Instantiates GlCollapsibleListbox components with redirect behavior for tags created
@@ -15,7 +15,7 @@ export function initRedirectListboxBehavior() {
return elements.map((el) =>
initListbox(el, {
onChange({ href }) {
- redirectTo(href);
+ redirectTo(href); // eslint-disable-line import/no-deprecated
},
}),
);
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/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index c8c6b51f374..12df67670f9 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -22,7 +22,9 @@ export default (input, parameters, escapeParameters = true) => {
mappedParameters.forEach((key, parameterName) => {
const parameterValue = mappedParameters.get(parameterName);
const escapedParameterValue = escapeParameters ? escape(parameterValue) : parameterValue;
- output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), escapedParameterValue);
+ // Pass the param value as a function to ignore special replacement patterns like $` and $'.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#syntax
+ output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), () => escapedParameterValue);
});
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 4c715c4993f..fd002e29afc 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -89,9 +89,11 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
- initTopNav();
+ if (!gon.use_new_navigation) {
+ initTopNav();
+ initTodoToggle();
+ }
initBreadcrumbs();
- initTodoToggle();
initPrefetchLinks('.js-prefetch-document');
initLogoAnimation();
initServicePingConsent();
@@ -104,14 +106,6 @@ function deferredInitialisation() {
initCopyCodeButton();
initGitlabVersionCheck();
- // Init super sidebar
- if (gon.use_new_navigation) {
- // eslint-disable-next-line promise/catch-or-return
- import('./super_sidebar/super_sidebar_bundle').then(({ initSuperSidebar }) => {
- initSuperSidebar();
- });
- }
-
addSelectOnFocusBehaviour('.js-select-on-focus');
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
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..8cdaa76e673 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,10 +1,11 @@
<script>
import { mapState } from 'vuex';
+import { __ } from '~/locale';
import {
getParameterByName,
setUrlParams,
queryToObject,
- redirectTo,
+ redirectTo, // eslint-disable-line import/no-deprecated
} from '~/lib/utils/url_utility';
import {
SORT_QUERY_PARAM_NAME,
@@ -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,
@@ -106,6 +119,7 @@ export default {
const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME);
const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME);
+ // eslint-disable-next-line import/no-deprecated
redirectTo(
setUrlParams(
{
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/members/constants.js b/app/assets/javascripts/members/constants.js
index 68c5831db62..8e5b88d362e 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -192,8 +192,6 @@ export const MEMBER_STATE_ACTIVE = 2;
export const BADGE_LABELS_AWAITING_SIGNUP = __('Awaiting user signup');
export const BADGE_LABELS_PENDING = __('Pending owner action');
-export const DAYS_TO_EXPIRE_SOON = 7;
-
export const LEAVE_MODAL_ID = 'member-leave-modal';
export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index 707e8a0645f..c6feb684795 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE } from '../constants';
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 20ee9a17fa0..af66600089f 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -33,7 +33,7 @@ export default {
i18n: {
commitStatSummary: __('Showing %{conflict}'),
resolveInfo: __(
- 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
+ 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}.',
),
},
computed: {
@@ -148,10 +148,10 @@ export default {
</gl-button>
</div>
</div>
- <div class="diff-content diff-wrap-lines">
+ <div class="diff-content diff-wrap-lines gl-rounded-bottom-base">
<div
v-if="file.resolveMode === 'interactive' && file.type === 'text'"
- class="file-content"
+ class="file-content gl-rounded-bottom-base"
>
<parallel-conflict-lines v-if="isParallel" :file="file" />
<inline-conflict-lines v-else :file="file" />
@@ -164,8 +164,7 @@ export default {
</div>
</div>
</div>
- <hr />
- <div class="resolve-conflicts-form">
+ <div class="resolve-conflicts-form gl-mt-6">
<div class="form-group row">
<div class="col-md-4">
<h4 class="gl-mt-0">
@@ -180,9 +179,7 @@ export default {
<code>{{ s__('MergeConflict|Use theirs') }}</code>
</template>
<template #branch_name>
- <a class="ref-name" :href="sourceBranchPath">
- {{ conflictsData.sourceBranch }}
- </a>
+ <a class="ref-name" :href="sourceBranchPath">{{ conflictsData.sourceBranch }}</a>
</template>
</gl-sprintf>
</div>
@@ -204,7 +201,7 @@ export default {
<gl-button
:disabled="!isReadyToCommit"
variant="confirm"
- class="js-submit-button"
+ class="js-submit-button gl-mr-2"
@click="submitResolvedConflicts(resolveConflictsPath)"
>
{{ getCommitButtonText }}
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index f84eaabf9e7..07a32a77c6a 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -1,5 +1,5 @@
import { setCookie } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 61abdca0a5b..4277e535d20 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,7 +1,8 @@
/* eslint-disable func-names, no-underscore-dangle, consistent-return */
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
@@ -27,7 +28,7 @@ function MergeRequest(opts) {
if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
- dataType: 'merge_request',
+ dataType: TYPE_MERGE_REQUEST,
fieldName: 'description',
selector: '.detail-page-description',
lockVersion: this.$el.data('lockVersion'),
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 46ee8fecfc5..cef224d83e2 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,7 +1,9 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { createAlert } from '~/alert';
import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils';
import { parseUrlPathname } from '~/lib/utils/url_utility';
import createEventHub from '~/helpers/event_hub_factory';
@@ -13,9 +15,15 @@ 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';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
// MergeRequestTabs
//
// Handles persisting and restoring the current tab selection and lazily-loading
@@ -75,18 +83,6 @@ function scrollToContainer(container) {
}
}
-function computeTopOffset(tabs) {
- const navbar = document.querySelector('.navbar-gitlab');
- const peek = document.getElementById('js-peek');
- let stickyTop;
-
- stickyTop = navbar ? navbar.offsetHeight : 0;
- stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop;
- stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop;
-
- return stickyTop;
-}
-
function mountPipelines() {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
const { mrWidgetData } = gl;
@@ -94,10 +90,13 @@ function mountPipelines() {
components: {
CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
},
+ apolloProvider,
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ fullPath: pipelineTableViewEl.dataset.fullPath,
+ manualActionsLimit: 50,
},
render(createElement) {
return createElement('commit-pipelines-table', {
@@ -134,11 +133,11 @@ function destroyPipelines(app) {
return null;
}
-function loadDiffs({ url, sticky, tabs }) {
+function loadDiffs({ url, tabs }) {
return axios.get(url).then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
- initDiffStatsDropdown(sticky);
+ initDiffStatsDropdown();
localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
@@ -177,12 +176,14 @@ function getActionFromHref(href) {
}
const pageBundles = {
- show: () => import(/* webpackPrefetch: true */ '~/mr_notes/init_notes'),
+ show: () => import(/* webpackPrefetch: true */ 'ee_else_ce/mr_notes/mount_app'),
diffs: () => import(/* webpackPrefetch: true */ '~/diffs'),
};
export default class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
+ const containers = document.querySelectorAll('.content-wrapper .container-fluid');
+ this.contentWrapper = containers[containers.length - 1];
this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container');
this.mergeRequestTabsAll =
this.mergeRequestTabs && this.mergeRequestTabs.querySelectorAll
@@ -208,7 +209,7 @@ export default class MergeRequestTabs {
this.diffsLoaded = false;
this.diffsClass = null;
this.commitsLoaded = false;
- this.fixedLayoutPref = null;
+ this.isFixedLayoutPreferred = this.contentWrapper.classList.contains('container-limited');
this.eventHub = createEventHub();
this.loadedPages = { [action]: true };
@@ -250,10 +251,11 @@ export default class MergeRequestTabs {
}
recallScroll(action) {
const storedPosition = this.scrollPositions[action];
+ if (storedPosition == null) return;
setTimeout(() => {
window.scrollTo({
- top: storedPosition && storedPosition > 0 ? storedPosition : 0,
+ top: storedPosition > 0 ? storedPosition : 0,
left: 0,
behavior: 'auto',
});
@@ -306,7 +308,7 @@ export default class MergeRequestTabs {
const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`);
if (tab) tab.classList.add('active');
- if (!this.loadedPages[action] && action in pageBundles) {
+ if (isInVueNoteablePage() && !this.loadedPages[action] && action in pageBundles) {
toggleLoader(true);
pageBundles[action]()
.then(({ default: init }) => {
@@ -316,7 +318,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') });
});
}
@@ -468,12 +470,14 @@ export default class MergeRequestTabs {
axios
.get(`${source}.json`, { params: { page, per_page: 100 } })
- .then(({ data }) => {
+ .then(({ data: { html, count, next_page: nextPage } }) => {
toggleLoader(false);
+ document.querySelector('.js-commits-count').textContent = count;
+
const commitsDiv = document.querySelector('div#commits');
// eslint-disable-next-line no-unsanitized/property
- commitsDiv.innerHTML += data.html;
+ commitsDiv.innerHTML += html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
scrollToContainer('#commits');
@@ -489,7 +493,7 @@ export default class MergeRequestTabs {
});
}
- if (!data.next_page) {
+ if (!nextPage) {
return import('./add_context_commits_modal');
}
@@ -521,7 +525,6 @@ export default class MergeRequestTabs {
loadDiffs({
url: diffUrl,
- sticky: computeTopOffset(this.mergeRequestTabs),
tabs: this,
})
.then(() => {
@@ -561,22 +564,12 @@ export default class MergeRequestTabs {
return action === 'diffs' || action === 'new/diffs';
}
- expandViewContainer(removeLimited = true) {
- const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs');
- if (this.fixedLayoutPref === null) {
- this.fixedLayoutPref = $wrapper.hasClass('container-limited');
- }
- if (this.diffViewType() === 'parallel' || removeLimited) {
- $wrapper.removeClass('container-limited');
- } else {
- $wrapper.toggleClass('container-limited', this.fixedLayoutPref);
- }
+ expandViewContainer() {
+ this.contentWrapper.classList.remove('container-limited');
}
resetViewContainer() {
- if (this.fixedLayoutPref !== null) {
- $('.content-wrapper .container-fluid').toggleClass('container-limited', this.fixedLayoutPref);
- }
+ this.contentWrapper.classList.toggle('container-limited', this.isFixedLayoutPreferred);
}
// Expand the issuable sidebar unless the user explicitly collapsed it
diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
index 1590e693c07..a5a4e683214 100644
--- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
+++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
@@ -1,13 +1,13 @@
<script>
-import { GlListbox } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
- GlListbox,
+ GlCollapsibleListbox,
},
props: {
staticData: {
@@ -124,7 +124,7 @@ export default {
:name="inputName"
data-testid="target-project-input"
/>
- <gl-listbox
+ <gl-collapsible-listbox
v-model="selected"
:items="filteredData"
:toggle-text="current.text || dropdownHeader"
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 525094271d9..e63b9613257 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -10,8 +10,31 @@ import StatusBox from '~/issuable/components/status_box.vue';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import titleSubscription from '../queries/title.subscription.graphql';
export default {
+ apollo: {
+ $subscribe: {
+ title: {
+ query() {
+ return titleSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return !this.issuableId || !this.glFeatures.realtimeMrStatusChange;
+ },
+ result({ data: { mergeRequestMergeStatusUpdated } }) {
+ if (mergeRequestMergeStatusUpdated) {
+ this.titleHtml = mergeRequestMergeStatusUpdated.titleHtml;
+ }
+ },
+ },
+ },
+ },
components: {
GlIntersectionObserver,
GlLink,
@@ -36,6 +59,7 @@ export default {
return {
isStickyHeaderVisible: false,
discussionCounter: 0,
+ titleHtml: this.title,
};
},
computed: {
@@ -82,17 +106,17 @@ export default {
@disappear="setStickyHeaderVisible(true)"
>
<div
- class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block"
+ class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-display-none gl-md-display-flex gl-flex-direction-column gl-justify-content-end gl-border-b"
:class="{ 'gl-visibility-hidden': !isStickyHeaderVisible }"
>
<div
- class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5"
+ class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5 gl-w-full"
:class="{ 'gl-max-w-container-xl': !isFluidLayout }"
>
<div class="gl-w-full gl-display-flex gl-align-items-center">
<status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
<p
- v-safe-html:[$options.safeHtmlConfig]="title"
+ v-safe-html:[$options.safeHtmlConfig]="titleHtml"
class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4"
></p>
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/merge_requests/queries/title.subscription.graphql b/app/assets/javascripts/merge_requests/queries/title.subscription.graphql
new file mode 100644
index 00000000000..4f04e944f7e
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/queries/title.subscription.graphql
@@ -0,0 +1,8 @@
+subscription getTitleSubscription($issuableId: IssuableID!) {
+ mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ titleHtml
+ }
+ }
+}
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 4b3c1bd7d10..c13bf50eba7 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,9 +1,9 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, n__, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -76,7 +76,7 @@ Once deleted, it cannot be undone or recovered.`),
});
// follow the rediect to milestones overview page
- redirectTo(response.request.responseURL);
+ redirectTo(response.request.responseURL); // eslint-disable-line import/no-deprecated
})
.catch((error) => {
eventHub.$emit('deleteMilestoneModal.requestFinished', {
@@ -103,7 +103,7 @@ Once deleted, it cannot be undone or recovered.`),
},
primaryProps: {
text: s__('Milestones|Delete milestone'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
index 9e537fa2c82..63791dcd011 100644
--- a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -80,11 +80,11 @@ export default {
},
primaryAction: {
text: s__('Milestones|Promote Milestone'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
cancelAction: {
text: __('Cancel'),
- attributes: [],
+ attributes: {},
},
};
</script>
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index f90fdb04923..8780d931588 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -4,6 +4,7 @@ import initDatePicker from '~/behaviors/date_picker';
import GLForm from '~/gl_form';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Milestone from '~/milestones/milestone';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
@@ -12,6 +13,9 @@ import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
import eventHub from './event_hub';
+// See app/views/shared/milestones/_description.html.haml
+export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description';
+
export function initForm(initGFM = true) {
new ZenMode(); // eslint-disable-line no-new
initDatePicker();
@@ -34,6 +38,8 @@ export function initShow() {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
+
+ renderGFM(document.querySelector(MILESTONE_DESCRIPTION_ELEMENT));
}
export function initPromoteMilestoneModal() {
@@ -64,8 +70,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 +79,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/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js
index d9e72340d62..20cc0352c33 100644
--- a/app/assets/javascripts/milestones/milestone.js
+++ b/app/assets/javascripts/milestones/milestone.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 2995f19c470..299c9731ad7 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { hide } from '~/tooltips';
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 037120a0d81..68b18a34ded 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { escape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
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..c10b0cb52b9
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
@@ -0,0 +1,104 @@
+<script>
+import {
+ GlModal,
+ GlDisclosureDropdown,
+ GlTooltipDirective,
+ GlDisclosureDropdownItem,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ },
+ 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>
+ <div>
+ <gl-disclosure-dropdown
+ placement="right"
+ category="tertiary"
+ :aria-label="__('More actions')"
+ icon="ellipsis_v"
+ no-caret
+ >
+ <gl-disclosure-dropdown-item
+ v-gl-modal-directive="modal.id"
+ :aria-label="actionPrimaryText"
+ variant="danger"
+ >
+ <template #list-item>
+ <span class="gl-text-red-500">
+ {{ actionPrimaryText }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
+
+ <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>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
deleted file mode 100644
index d0c42905ee2..00000000000
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
+++ /dev/null
@@ -1,115 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
-
-export default {
- name: 'MlCandidate',
- components: {
- IncubationAlert,
- GlLink,
- },
- props: {
- candidate: {
- type: Object,
- required: true,
- },
- },
- i18n: {
- titleLabel: __('Model candidate details'),
- infoLabel: __('Info'),
- idLabel: __('ID'),
- statusLabel: __('Status'),
- experimentLabel: __('Experiment'),
- artifactsLabel: __('Artifacts'),
- parametersLabel: __('Parameters'),
- metricsLabel: __('Metrics'),
- metadataLabel: __('Metadata'),
- },
- computed: {
- sections() {
- return [
- {
- sectionName: this.$options.i18n.parametersLabel,
- sectionValues: this.candidate.params,
- },
- {
- sectionName: this.$options.i18n.metricsLabel,
- sectionValues: this.candidate.metrics,
- },
- {
- sectionName: this.$options.i18n.metadataLabel,
- sectionValues: this.candidate.metadata,
- },
- ];
- },
- },
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.FEATURE_NAME"
- :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
- />
-
- <h3>
- {{ $options.i18n.titleLabel }}
- </h3>
-
- <table class="candidate-details">
- <tbody>
- <tr class="divider"></tr>
-
- <tr>
- <td class="gl-text-secondary gl-font-weight-bold">{{ $options.i18n.infoLabel }}</td>
- <td class="gl-font-weight-bold">{{ $options.i18n.idLabel }}</td>
- <td>{{ candidate.info.iid }}</td>
- </tr>
-
- <tr>
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.statusLabel }}</td>
- <td>{{ candidate.info.status }}</td>
- </tr>
-
- <tr>
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.experimentLabel }}</td>
- <td>
- <gl-link :href="candidate.info.path_to_experiment">{{
- candidate.info.experiment_name
- }}</gl-link>
- </td>
- </tr>
-
- <tr v-if="candidate.info.path_to_artifact">
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.artifactsLabel }}</td>
- <td>
- <gl-link :href="candidate.info.path_to_artifact">{{
- $options.i18n.artifactsLabel
- }}</gl-link>
- </td>
- </tr>
-
- <template v-for="{ sectionName, sectionValues } in sections">
- <tr :key="sectionName" class="divider"></tr>
-
- <tr v-for="(item, index) in sectionValues" :key="item.name">
- <td v-if="index === 0" class="gl-text-secondary gl-font-weight-bold">
- {{ sectionName }}
- </td>
- <td v-else></td>
- <td class="gl-font-weight-bold">{{ item.name }}</td>
- <td>{{ item.value }}</td>
- </tr>
- </template>
- </tbody>
- </table>
- </div>
-</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
deleted file mode 100644
index c09aabb0d40..00000000000
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
+++ /dev/null
@@ -1,200 +0,0 @@
-<script>
-import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
-import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
-import {
- LIST_KEY_CREATED_AT,
- BASE_SORT_FIELDS,
- METRIC_KEY_PREFIX,
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
-} from '~/ml/experiment_tracking/constants';
-import { s__ } from '~/locale';
-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';
-
-export default {
- name: 'MlExperiment',
- components: {
- GlTable,
- GlLink,
- TimeAgo,
- IncubationAlert,
- RegistrySearch,
- KeysetPagination,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'],
- data() {
- const query = queryToObject(window.location.search);
-
- const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
-
- let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
-
- if (query.orderByType === 'metric') {
- orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
- }
-
- return {
- filters: filter,
- sorting: {
- orderBy,
- sort: (query.sort || 'desc').toLowerCase(),
- },
- };
- },
- computed: {
- fields() {
- if (this.candidates.length === 0) return [];
-
- return [
- { key: 'name', label: this.$options.i18n.nameLabel },
- { key: 'created_at', label: this.$options.i18n.createdAtLabel },
- { key: 'user', label: this.$options.i18n.userLabel },
- ...this.paramNames,
- ...this.metricNames,
- { key: 'details', label: '' },
- { key: 'artifact', label: '' },
- ];
- },
- displayPagination() {
- return this.candidates.length > 0;
- },
- sortableFields() {
- return [
- ...BASE_SORT_FIELDS,
- ...this.metricNames.map((name) => ({
- orderBy: `${METRIC_KEY_PREFIX}${name}`,
- label: capitalizeFirstCharacter(name),
- })),
- ];
- },
- parsedQuery() {
- const name = this.filters
- .map((f) => f.value.data)
- .join(' ')
- .trim();
-
- const filterByQuery = name === '' ? {} : { name };
-
- let orderByType = 'column';
- let { orderBy } = this.sorting;
- const { sort } = this.sorting;
-
- if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
- orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
- orderByType = 'metric';
- }
-
- return { ...filterByQuery, orderBy, orderByType, sort };
- },
- },
- methods: {
- submitFilters() {
- return visitUrl(setUrlParams({ ...this.parsedQuery }));
- },
- updateFilters(newValue) {
- this.filters = newValue;
- },
- updateSorting(newValue) {
- this.sorting = { ...this.sorting, ...newValue };
- },
- updateSortingAndEmitUpdate(newValue) {
- this.updateSorting(newValue);
- this.submitFilters();
- },
- },
- i18n: {
- titleLabel: s__('MlExperimentTracking|Experiment candidates'),
- emptyStateLabel: s__('MlExperimentTracking|No candidates to display'),
- artifactsLabel: s__('MlExperimentTracking|Artifacts'),
- detailsLabel: s__('MlExperimentTracking|Details'),
- userLabel: s__('MlExperimentTracking|User'),
- createdAtLabel: s__('MlExperimentTracking|Created at'),
- nameLabel: s__('MlExperimentTracking|Name'),
- noDataContent: s__('MlExperimentTracking|-'),
- filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'),
- },
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.FEATURE_NAME"
- :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
- />
-
- <h3>
- {{ $options.i18n.titleLabel }}
- </h3>
-
- <registry-search
- :filters="filters"
- :sorting="sorting"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSortingAndEmitUpdate"
- @filter:changed="updateFilters"
- @filter:submit="submitFilters"
- @filter:clear="filters = []"
- />
-
- <gl-table
- :fields="fields"
- :items="candidates"
- :empty-text="$options.i18n.emptyStateLabel"
- show-empty
- small
- class="gl-mt-0! ml-candidate-table"
- >
- <template #cell()="data">
- <div v-gl-tooltip.hover :title="data.value">{{ data.value }}</div>
- </template>
-
- <template #cell(artifact)="data">
- <gl-link
- v-if="data.value"
- v-gl-tooltip.hover
- :href="data.value"
- target="_blank"
- :title="$options.i18n.artifactsLabel"
- >{{ $options.i18n.artifactsLabel }}</gl-link
- >
- <div v-else v-gl-tooltip.hover :title="$options.i18n.artifactsLabel">
- {{ $options.i18n.noDataContent }}
- </div>
- </template>
-
- <template #cell(details)="data">
- <gl-link v-gl-tooltip.hover :href="data.value" :title="$options.i18n.detailsLabel">{{
- $options.i18n.detailsLabel
- }}</gl-link>
- </template>
-
- <template #cell(created_at)="data">
- <time-ago v-gl-tooltip.hover :time="data.value" :title="data.value" />
- </template>
-
- <template #cell(user)="data">
- <gl-link
- v-if="data.value"
- v-gl-tooltip.hover
- :href="data.value.path"
- :title="data.value.username"
- >@{{ data.value.username }}</gl-link
- >
- <div v-else>{{ $options.i18n.noDataContent }}</div>
- </template>
- </gl-table>
-
- <keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
- </div>
-</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue b/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue
new file mode 100644
index 00000000000..02869bacb66
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ pageTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ experimentBadgeLabel: __('Experiment'),
+ },
+ experimentDocHref: helpPagePath('user/project/ml/experiment_tracking/index.md'),
+};
+</script>
+
+<template>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <div class="page-title gl-flex-grow-1 gl-display-flex gl-align-items-center">
+ <h3 class="gl-font-size-h-display gl-my-0">{{ pageTitle }}</h3>
+ <gl-badge class="gl-mx-4" variant="info" size="lg" :href="$options.experimentDocHref">
+ {{ $options.i18n.experimentBadgeLabel }}
+ </gl-badge>
+ </div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js
index 15462b519e1..f18fbc7e2cd 100644
--- a/app/assets/javascripts/ml/experiment_tracking/constants.js
+++ b/app/assets/javascripts/ml/experiment_tracking/constants.js
@@ -1,21 +1,4 @@
-import { __, s__ } from '~/locale';
-
-export const METRIC_KEY_PREFIX = 'metric.';
-
-export const LIST_KEY_CREATED_AT = 'created_at';
-
-export const BASE_SORT_FIELDS = Object.freeze([
- {
- orderBy: 'name',
- label: __('Name'),
- },
- {
- orderBy: LIST_KEY_CREATED_AT,
- label: __('Created at'),
- },
-]);
-
-export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg';
+import { s__ } from '~/locale';
export const FEATURE_NAME = s__('MlExperimentTracking|Machine learning experiment tracking');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
new file mode 100644
index 00000000000..20c5248052b
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'CandidateDetailRow',
+ components: {
+ GlLink,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: [String, Number],
+ required: true,
+ },
+ href: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sectionLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <tr>
+ <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td>
+ <td class="gl-font-weight-bold">{{ label }}</td>
+ <td>
+ <gl-link v-if="href" :href="href">{{ text }}</gl-link>
+ <template v-else>{{ text }}</template>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js
new file mode 100644
index 00000000000..529bd6fe9f2
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js
@@ -0,0 +1,3 @@
+import MlCandidatesShow from './ml_candidates_show.vue';
+
+export default MlCandidatesShow;
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
new file mode 100644
index 00000000000..3ef73e7c874
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
@@ -0,0 +1,123 @@
+<script>
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import DetailRow from './components/candidate_detail_row.vue';
+
+import {
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ PARAMETERS_LABEL,
+ METRICS_LABEL,
+ METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
+ MLFLOW_ID_LABEL,
+} from './translations';
+
+export default {
+ name: 'MlCandidatesShow',
+ components: {
+ ModelExperimentsHeader,
+ DeleteButton,
+ DetailRow,
+ },
+ props: {
+ candidate: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
+ MLFLOW_ID_LABEL,
+ },
+ computed: {
+ info() {
+ return Object.freeze(this.candidate.info);
+ },
+ sections() {
+ return [
+ {
+ sectionName: PARAMETERS_LABEL,
+ sectionValues: this.candidate.params,
+ },
+ {
+ sectionName: METRICS_LABEL,
+ sectionValues: this.candidate.metrics,
+ },
+ {
+ sectionName: METADATA_LABEL,
+ sectionValues: this.candidate.metadata,
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <model-experiments-header :page-title="$options.i18n.TITLE_LABEL">
+ <delete-button
+ :delete-path="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"
+ />
+ </model-experiments-header>
+
+ <table class="candidate-details gl-w-full">
+ <tbody>
+ <tr class="divider"></tr>
+
+ <detail-row
+ :label="$options.i18n.ID_LABEL"
+ :section-label="$options.i18n.INFO_LABEL"
+ :text="info.iid"
+ />
+
+ <detail-row :label="$options.i18n.MLFLOW_ID_LABEL" :text="info.eid" />
+
+ <detail-row :label="$options.i18n.STATUS_LABEL" :text="info.status" />
+
+ <detail-row
+ :label="$options.i18n.EXPERIMENT_LABEL"
+ :text="info.experiment_name"
+ :href="info.path_to_experiment"
+ />
+
+ <detail-row
+ v-if="info.path_to_artifact"
+ :label="$options.i18n.ARTIFACTS_LABEL"
+ :href="info.path_to_artifact"
+ :text="$options.i18n.ARTIFACTS_LABEL"
+ />
+
+ <template v-for="{ sectionName, sectionValues } in sections">
+ <tr v-if="sectionValues" :key="sectionName" class="divider"></tr>
+
+ <detail-row
+ v-for="(item, index) in sectionValues"
+ :key="item.name"
+ :label="item.name"
+ :section-label="index === 0 ? sectionName : ''"
+ :text="item.value"
+ />
+ </template>
+ </tbody>
+ </table>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..66ee84adb4e
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const TITLE_LABEL = s__('MlExperimentTracking|Model candidate details');
+export const INFO_LABEL = s__('MlExperimentTracking|Info');
+export const ID_LABEL = s__('MlExperimentTracking|ID');
+export const MLFLOW_ID_LABEL = s__('MlExperimentTracking|MLflow run ID');
+export const STATUS_LABEL = s__('MlExperimentTracking|Status');
+export const EXPERIMENT_LABEL = s__('MlExperimentTracking|Experiment');
+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/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
index 4f2b8db3c00..66f94c6bee5 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
@@ -1,20 +1,16 @@
<script>
import { GlTableLite, GlEmptyState, GlLink } from '@gitlab/ui';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
import Pagination from '~/vue_shared/components/incubation/pagination.vue';
-import {
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
- EMPTY_STATE_SVG,
-} from '~/ml/experiment_tracking/constants';
+import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
import * as constants from '~/ml/experiment_tracking/routes/experiments/index/constants';
import * as translations from '~/ml/experiment_tracking/routes/experiments/index/translations';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
export default {
name: 'MlExperimentsIndexApp',
components: {
Pagination,
- IncubationAlert,
+ ModelExperimentsHeader,
GlTableLite,
GlEmptyState,
GlLink,
@@ -28,6 +24,10 @@ export default {
type: Object,
required: true,
},
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
},
tableFields: constants.EXPERIMENTS_TABLE_FIELDS,
i18n: translations,
@@ -45,7 +45,6 @@ export default {
constants: {
FEATURE_NAME,
FEATURE_FEEDBACK_ISSUE,
- EMPTY_STATE_SVG,
...constants,
},
};
@@ -53,14 +52,7 @@ export default {
<template>
<div v-if="hasExperiments">
- <h1 class="page-title gl-font-size-h-display">
- {{ $options.i18n.TITLE_LABEL }}
- </h1>
-
- <incubation-alert
- :feature-name="$options.constants.FEATURE_NAME"
- :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
- />
+ <model-experiments-header :page-title="$options.i18n.TITLE_LABEL" />
<gl-table-lite :items="tableItems" :fields="$options.tableFields">
<template #cell(nameColumn)="data">
@@ -78,7 +70,7 @@ export default {
:title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
:primary-button-text="$options.i18n.CREATE_NEW_LABEL"
:primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH"
- :svg-path="$options.constants.EMPTY_STATE_SVG"
+ :svg-path="emptyStateSvgPath"
:description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
class="gl-py-8"
/>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js
new file mode 100644
index 00000000000..4d34555ac2f
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js
@@ -0,0 +1,21 @@
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const METRIC_KEY_PREFIX = 'metric.';
+export const LIST_KEY_CREATED_AT = 'created_at';
+export const BASE_SORT_FIELDS = Object.freeze([
+ {
+ orderBy: 'name',
+ label: s__('MlExperimentTracking|Name'),
+ },
+ {
+ orderBy: LIST_KEY_CREATED_AT,
+ label: s__('MlExperimentTracking|Created at'),
+ },
+]);
+export const CREATE_CANDIDATE_HELP_PATH = helpPagePath(
+ 'user/project/ml/experiment_tracking/index.md',
+ {
+ anchor: 'tracking-new-experiments-and-trials',
+ },
+);
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js
new file mode 100644
index 00000000000..5903866b6dd
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js
@@ -0,0 +1,3 @@
+import MlExperimentsShow from './ml_experiments_show.vue';
+
+export default MlExperimentsShow;
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
new file mode 100644
index 00000000000..25c06aa2f7f
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
@@ -0,0 +1,254 @@
+<script>
+import { GlTableLite, GlLink, GlEmptyState, GlButton } from '@gitlab/ui';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
+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 ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+
+import {
+ LIST_KEY_CREATED_AT,
+ BASE_SORT_FIELDS,
+ METRIC_KEY_PREFIX,
+ CREATE_CANDIDATE_HELP_PATH,
+} from './constants';
+import * as translations from './translations';
+
+export default {
+ name: 'MlExperimentsShow',
+ components: {
+ GlTableLite,
+ GlLink,
+ GlEmptyState,
+ GlButton,
+ TimeAgo,
+ RegistrySearch,
+ KeysetPagination,
+ ModelExperimentsHeader,
+ DeleteButton,
+ },
+ props: {
+ experiment: {
+ type: Object,
+ required: true,
+ },
+ candidates: {
+ type: Array,
+ required: true,
+ },
+ metricNames: {
+ type: Array,
+ required: true,
+ },
+ paramNames: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const query = queryToObject(window.location.search);
+
+ const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
+
+ let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
+
+ if (query.orderByType === 'metric') {
+ orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
+ }
+
+ return {
+ filters: filter,
+ sorting: {
+ orderBy,
+ sort: (query.sort || 'desc').toLowerCase(),
+ },
+ };
+ },
+ computed: {
+ fields() {
+ if (this.candidates.length === 0) return [];
+
+ return [
+ { key: 'nameColumn', label: this.$options.i18n.NAME_LABEL },
+ { key: 'created_at', label: this.$options.i18n.CREATED_AT_LABEL },
+ { key: 'user', label: this.$options.i18n.USER_LABEL },
+ ...this.paramNames,
+ ...this.metricNames,
+ { key: 'ci_job', label: this.$options.i18n.CI_JOB_LABEL },
+ { key: 'artifact', label: this.$options.i18n.ARTIFACTS_LABEL },
+ ];
+ },
+ displayPagination() {
+ return this.candidates.length > 0;
+ },
+ sortableFields() {
+ return [
+ ...BASE_SORT_FIELDS,
+ ...this.metricNames.map((name) => ({
+ orderBy: `${METRIC_KEY_PREFIX}${name}`,
+ label: capitalizeFirstCharacter(name),
+ })),
+ ];
+ },
+ parsedQuery() {
+ const name = this.filters
+ .map((f) => f.value.data)
+ .join(' ')
+ .trim();
+
+ const filterByQuery = name === '' ? {} : { name };
+
+ let orderByType = 'column';
+ let { orderBy } = this.sorting;
+ const { sort } = this.sorting;
+
+ if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
+ orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
+ orderByType = 'metric';
+ }
+
+ return { ...filterByQuery, orderBy, orderByType, sort };
+ },
+ tableItems() {
+ return this.candidates.map((candidate) => ({
+ ...candidate,
+ nameColumn: {
+ name: candidate.name,
+ details_path: candidate.details,
+ },
+ }));
+ },
+ 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() {
+ return visitUrl(setUrlParams({ ...this.parsedQuery }));
+ },
+ updateFilters(newValue) {
+ this.filters = newValue;
+ },
+ updateSorting(newValue) {
+ this.sorting = { ...this.sorting, ...newValue };
+ },
+ updateSortingAndEmitUpdate(newValue) {
+ this.updateSorting(newValue);
+ this.submitFilters();
+ },
+ downloadCsv() {
+ const currentPath = window.location.pathname;
+ const currentSearch = window.location.search;
+
+ visitUrl(`${currentPath}.csv${currentSearch}`);
+ },
+ },
+ i18n: translations,
+ constants: {
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+ CREATE_CANDIDATE_HELP_PATH,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <model-experiments-header :page-title="experiment.name">
+ <gl-button class="gl-mr-3" @click="downloadCsv">{{
+ $options.i18n.DOWNLOAD_AS_CSV_LABEL
+ }}</gl-button>
+ <delete-button v-bind="deleteButtonInfo" />
+ </model-experiments-header>
+
+ <registry-search
+ :filters="filters"
+ :sorting="sorting"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="submitFilters"
+ @filter:clear="filters = []"
+ />
+
+ <div v-if="hasItems" class="gl-overflow-x-auto">
+ <gl-table-lite
+ :fields="fields"
+ :items="tableItems"
+ show-empty
+ small
+ class="gl-mt-0! ml-candidate-table"
+ >
+ <template #cell()="data">
+ <div>{{ data.value }}</div>
+ </template>
+
+ <template #cell(nameColumn)="data">
+ <gl-link :href="data.value.details_path">
+ <span v-if="data.value.name"> {{ data.value.name }}</span>
+ <span v-else class="gl-font-style-italic">{{ $options.i18n.NO_CANDIDATE_NAME }}</span>
+ </gl-link>
+ </template>
+
+ <template #cell(artifact)="data">
+ <gl-link v-if="data.value" :href="data.value" target="_blank">{{
+ $options.i18n.ARTIFACTS_LABEL
+ }}</gl-link>
+ <div v-else class="gl-font-style-italic gl-text-gray-500">
+ {{ $options.i18n.NO_ARTIFACT }}
+ </div>
+ </template>
+
+ <template #cell(created_at)="data">
+ <time-ago :time="data.value" />
+ </template>
+
+ <template #cell(user)="data">
+ <gl-link v-if="data.value" :href="data.value.path">@{{ data.value.username }}</gl-link>
+ <div v-else>{{ $options.i18n.NO_DATA_CONTENT }}</div>
+ </template>
+
+ <template #cell(ci_job)="data">
+ <gl-link v-if="data.value" :href="data.value.path" target="_blank">{{
+ data.value.name
+ }}</gl-link>
+ <div v-else class="gl-font-style-italic gl-text-gray-500">
+ {{ $options.i18n.NO_JOB }}
+ </div>
+ </template>
+ </gl-table-lite>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
+ :primary-button-text="$options.i18n.CREATE_NEW_LABEL"
+ :primary-button-link="$options.constants.CREATE_CANDIDATE_HELP_PATH"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
+ class="gl-py-8"
+ />
+
+ <keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
+ </div>
+</template>
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
new file mode 100644
index 00000000000..3af33f53fbd
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
@@ -0,0 +1,24 @@
+import { s__ } from '~/locale';
+
+export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts');
+export const DETAILS_LABEL = s__('MlExperimentTracking|Details');
+export const USER_LABEL = s__('MlExperimentTracking|Author');
+export const CI_JOB_LABEL = s__('MlExperimentTracking|CI Job');
+export const CREATED_AT_LABEL = s__('MlExperimentTracking|Created at');
+export const NAME_LABEL = s__('MlExperimentTracking|Name');
+export const NO_DATA_CONTENT = s__('MlExperimentTracking|-');
+export const FILTER_CANDIDATES_LABEL = s__('MlExperimentTracking|Filter candidates');
+export const NO_CANDIDATE_NAME = s__('MlExperimentTracking|No name');
+export const NO_ARTIFACT = s__('MlExperimentTracking|No artifacts');
+export const NO_JOB = s__('MlExperimentTracking|-');
+export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create new candidates');
+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?');
+export const DOWNLOAD_AS_CSV_LABEL = s__('MlExperimentTracking|Download as CSV');
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index a995497ab9c..b6eb1a23f87 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -1,8 +1,9 @@
<script>
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
+
import produce from 'immer';
import { flattenDeep, isNumber } from 'lodash';
-import { hexToRgb } from '~/lib/utils/color_utils';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
@@ -20,7 +21,7 @@ const LOWER = 2;
*/
const AREA_COLOR = colorValues.anomalyAreaColor;
const AREA_OPACITY = areaOpacityValues.default;
-const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`;
+const AREA_COLOR_RGBA = hexToRgba(AREA_COLOR, AREA_OPACITY);
/**
* The anomaly component highlights when a metric shows
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2c185794d17..752ba4241d8 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,17 +8,24 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import Mousetrap from 'mousetrap';
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import {
+ keysFor,
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_EXPAND_PANEL,
+ METRICS_SHOW_ALERTS,
+} from '~/behaviors/shortcuts/keybindings';
import invalidUrl from '~/lib/utils/invalid_url';
import { ESC_KEY } from '~/lib/utils/keys';
+import { Mousetrap } from '~/lib/mousetrap';
import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { defaultTimeRange } from '~/vue_shared/constants';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { metricStates, keyboardShortcutKeys } from '../constants';
+import { metricStates } from '../constants';
import {
timeRangeFromUrl,
panelToUrl,
@@ -214,12 +221,28 @@ export default {
created() {
window.addEventListener('keyup', this.onKeyup);
- Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut);
+ Mousetrap.bind(keysFor(METRICS_EXPAND_PANEL), () =>
+ this.runShortcut('onExpandFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_SHOW_ALERTS), () =>
+ this.runShortcut('showAlertModalFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_DOWNLOAD_CSV), () =>
+ this.runShortcut('downloadCsvFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_COPY_LINK_TO_CHART), () =>
+ this.runShortcut('copyChartLinkFromKeyboardShotcut'),
+ );
},
destroyed() {
window.removeEventListener('keyup', this.onKeyup);
- Mousetrap.unbind(Object.values(keyboardShortcutKeys));
+ [
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_EXPAND_PANEL,
+ METRICS_SHOW_ALERTS,
+ ].forEach((command) => Mousetrap.unbind(keysFor(command)));
},
mounted() {
if (!this.hasMetrics) {
@@ -339,42 +362,18 @@ export default {
},
/**
* TODO: Investigate this to utilize the eventBus from Vue
- * The intentation behind this cleanup is to allow for better tests
+ * The intention behind this cleanup is to allow for better tests
* as well as use the correct eventBus facilities that are compatible
* with Vue 3
* https://gitlab.com/gitlab-org/gitlab/-/issues/225583
*/
//
- runShortcut(e) {
+ runShortcut(actionToRun) {
const panel = this.$refs[this.hoveredPanel];
if (!panel) return;
const [panelInstance] = panel;
- let actionToRun = '';
-
- switch (e.key) {
- case keyboardShortcutKeys.EXPAND:
- actionToRun = 'onExpandFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.SHOW_ALERT:
- actionToRun = 'showAlertModalFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.DOWNLOAD_CSV:
- actionToRun = 'downloadCsvFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.CHART_COPY:
- actionToRun = 'copyChartLinkFromKeyboardShotcut';
- break;
-
- default:
- actionToRun = 'onExpandFromKeyboardShortcut';
- break;
- }
-
panelInstance[actionToRun]();
},
setHoveredPanel(groupKey, graphIndex) {
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
index d67154b7697..29ce8572e9a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
@@ -12,7 +12,7 @@ import {
import { mapState, mapGetters, mapActions } from 'vuex';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import invalidUrl from '~/lib/utils/invalid_url';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { PANEL_NEW_PAGE } from '../router/constants';
@@ -113,7 +113,7 @@ export default {
const dashboardPath = encodeURIComponent(
dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
);
- redirectTo(`${baseURL}/${dashboardPath}`);
+ redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
},
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 7bb0d3874d1..44dde454983 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -13,7 +13,7 @@ import {
import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import invalidUrl from '~/lib/utils/invalid_url';
-import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
@@ -137,13 +137,13 @@ export default {
const dashboardPath = encodeURIComponent(
dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
);
- redirectTo(`${baseURL}/${dashboardPath}`);
+ redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
this.filterEnvironments(searchTerm);
}, 500),
onDateTimePickerInput(timeRange) {
- redirectTo(timeRangeToUrl(timeRange));
+ redirectTo(timeRangeToUrl(timeRange)); // eslint-disable-line import/no-deprecated
},
onDateTimePickerInvalid() {
this.$emit('dateTimePickerInvalid');
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 1b506c6564b..e35dcc350f2 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -226,12 +226,6 @@ export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
*/
export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/';
-export const OPERATORS = {
- greaterThan: '>',
- equalTo: '==',
- lessThan: '<',
-};
-
/**
* Dashboard yml files support custom user-defined variables that
* are rendered as input elements in the monitoring dashboard.
@@ -262,19 +256,6 @@ export const VARIABLE_TYPES = {
*/
export const VARIABLE_PREFIX = 'var-';
-/**
- * All of the actions inside each panel dropdown can be accessed
- * via keyboard shortcuts than can be activated via mouse hovers
- * and or focus via tabs.
- */
-
-export const keyboardShortcutKeys = {
- EXPAND: 'e',
- SHOW_ALERT: 'a',
- DOWNLOAD_CSV: 'd',
- CHART_COPY: 'c',
-};
-
export const thresholdModeTypes = {
ABSOLUTE: 'absolute',
PERCENTAGE: 'percentage',
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0ef365c6368..32e85262882 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -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/index.js b/app/assets/javascripts/mr_notes/index.js
index a202923bd21..10fb89feccc 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,22 +1,3 @@
-import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal';
-import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
-import { initMrStateLazyLoad } from '~/mr_notes/init';
-import MergeRequest from '../merge_request';
-import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import initMrNotes from './init_mr_notes';
-export default function initMrNotes() {
- resetServiceWorkersPublicPath();
-
- const mrShowNode = document.querySelector('.merge-request');
- // eslint-disable-next-line no-new
- new MergeRequest({
- action: mrShowNode.dataset.mrAction,
- });
-
- initMrStateLazyLoad();
-
- document.addEventListener('merged:UpdateActions', () => {
- initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
- initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal');
- });
-}
+export default initMrNotes;
diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js
index 79447bc115d..9852efea95f 100644
--- a/app/assets/javascripts/mr_notes/init.js
+++ b/app/assets/javascripts/mr_notes/init.js
@@ -22,7 +22,7 @@ function setupMrNotesState(notesDataset) {
store.dispatch('setEndpoints', endpoints);
}
-export function initMrStateLazyLoad() {
+export function initMrStateLazyLoad({ reviewBarParams } = {}) {
store.dispatch('setActiveTab', window.mrTabs.getCurrentAction());
window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) =>
store.dispatch('setActiveTab', value),
@@ -42,7 +42,7 @@ export function initMrStateLazyLoad() {
eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes'));
requestIdleCallback(() => {
- initReviewBar();
+ initReviewBar(reviewBarParams);
initOverviewTabCounter();
initDiscussionCounter();
});
diff --git a/app/assets/javascripts/mr_notes/init_mr_notes.js b/app/assets/javascripts/mr_notes/init_mr_notes.js
new file mode 100644
index 00000000000..e0a8d1f7e7d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/init_mr_notes.js
@@ -0,0 +1,22 @@
+import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal';
+import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
+import { initMrStateLazyLoad } from '~/mr_notes/init';
+import MergeRequest from '../merge_request';
+import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+
+export default function initMrNotes(lazyLoadParams) {
+ resetServiceWorkersPublicPath();
+
+ const mrShowNode = document.querySelector('.merge-request');
+ // eslint-disable-next-line no-new
+ new MergeRequest({
+ action: mrShowNode.dataset.mrAction,
+ });
+
+ initMrStateLazyLoad(lazyLoadParams);
+
+ document.addEventListener('merged:UpdateActions', () => {
+ initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
+ initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal');
+ });
+}
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index d968c125068..1795363f24c 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -1,5 +1,8 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { parseBoolean } from '~/lib/utils/common_utils';
import store from '~/mr_notes/stores';
@@ -9,7 +12,7 @@ import NotesApp from '../notes/components/notes_app.vue';
import { getNotesFilterData } from '../notes/utils/get_notes_filter_data';
import initWidget from '../vue_merge_request_widget';
-export default () => {
+export default ({ editorAiActions = [] } = {}) => {
requestIdleCallback(
() => {
renderGFM(document.getElementById('diff-notes-app'));
@@ -22,6 +25,8 @@ export default () => {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const notesDataset = el.dataset;
@@ -33,8 +38,12 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
reportAbusePath: notesDataset.reportAbusePath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ editorAiActions,
+ mrFilter: true,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
diff --git a/app/assets/javascripts/mr_notes/mount_app.js b/app/assets/javascripts/mr_notes/mount_app.js
new file mode 100644
index 00000000000..f635492e6a9
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/mount_app.js
@@ -0,0 +1,6 @@
+import initNotes from './init_notes';
+
+// this module is required for the EE functions to work properly with merge request tabs
+export default () => {
+ initNotes();
+};
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/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
index 09757ce17fa..b7dd509f7f2 100644
--- a/app/assets/javascripts/namespaces/leave_by_url.js
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { initRails } from '~/lib/utils/rails_ujs';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index da22a8d2fb7..9db123da405 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -1,13 +1,12 @@
<script>
-import { GlBadge, GlToggle } from '@gitlab/ui';
+import { GlToggle, GlDisclosureDropdownItem } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
export default {
i18n: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'),
toggleLabel: s__('NorthstarNavigation|Toggle new navigation'),
@@ -16,8 +15,8 @@ export default {
),
},
components: {
- GlBadge,
GlToggle,
+ GlDisclosureDropdownItem,
},
props: {
enabled: {
@@ -28,6 +27,11 @@ export default {
type: String,
required: true,
},
+ newNavigation: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -45,7 +49,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();
@@ -61,12 +65,22 @@ export default {
</script>
<template>
- <li>
+ <gl-disclosure-dropdown-item v-if="newNavigation" @action="toggleNav">
+ <div class="gl-new-dropdown-item-content">
+ <div
+ class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!"
+ >
+ {{ $options.i18n.toggleMenuItemLabel }}
+ <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
+ </div>
+ </div>
+ </gl-disclosure-dropdown-item>
+
+ <li v-else>
<div
class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center"
>
<b>{{ $options.i18n.sectionTitle }}</b>
- <gl-badge variant="info">{{ $options.i18n.badgeLabel }}</gl-badge>
</div>
<div
@@ -74,7 +88,12 @@ export default {
@click.prevent.stop="toggleNav"
>
{{ $options.i18n.toggleMenuItemLabel }}
- <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
+ <gl-toggle
+ :value="isEnabled"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ data-qa-selector="new_navigation_toggle"
+ />
</div>
</li>
</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
index bfcdcfc7292..2dfd77bc02e 100644
--- a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
+++ b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
@@ -1,5 +1,7 @@
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants';
export default {
components: {
@@ -7,6 +9,7 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
+ InviteMembersTrigger,
},
props: {
viewModel: {
@@ -22,6 +25,11 @@ export default {
return this.sections.length > 1;
},
},
+ methods: {
+ isInvitedMembers(menuItem) {
+ return menuItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
+ },
+ },
};
</script>
@@ -41,7 +49,16 @@ export default {
{{ title }}
</gl-dropdown-section-header>
<template v-for="menuItem in menu_items">
+ <invite-members-trigger
+ v-if="isInvitedMembers(menuItem)"
+ :key="`${index}_item_${menuItem.id}`"
+ :trigger-element="`dropdown-${menuItem.data.trigger_element}`"
+ :display-text="menuItem.title"
+ :icon="menuItem.icon"
+ :trigger-source="menuItem.data.trigger_source"
+ />
<gl-dropdown-item
+ v-else
:key="`${index}_item_${menuItem.id}`"
link-class="top-nav-menu-item"
:href="menuItem.href"
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..41149875d6c
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
@@ -0,0 +1,108 @@
+import { sanitize } from '~/lib/dompurify';
+
+function parseItems(itemIndexes, itemColumns) {
+ // Fetching items: if the dataframe has a single column index, the table is simple
+ // 0: tr > th(index0 value) th(column0 value) th(column1 value)
+ // 1: tr > th(index0 value) th(column0 value) th(column1 value)
+ //
+ // But if the dataframe has multiple column indexes, it uses rowspan, and the row below won't have a value for that
+ // index.
+ // 0: tr > th(index0 value, rowspan=2) th(index1 value) th(column0 value) th(column1 value)
+ // 1: tr > th(index1 value) th(column0 value) th(column1 value)
+ //
+ // So, when parsing row 1, and the count of <th> elements is less than indexCount, we fill with the first
+ // values of row 0
+ const indexCount = itemIndexes[0].length;
+ const rowCount = itemIndexes.length;
+
+ const filledIndexes = itemIndexes.map((row, rowIndex) => {
+ const indexesInRow = row.length;
+ if (indexesInRow === indexCount) {
+ return row;
+ }
+ return itemIndexes[rowIndex - 1].slice(0, -indexesInRow).concat(row);
+ });
+
+ const items = Array(rowCount);
+
+ for (let row = 0; row < rowCount; row += 1) {
+ items[row] = {
+ ...Object.fromEntries(filledIndexes[row].map((value, counter) => [`index${counter}`, value])),
+ ...Object.fromEntries(itemColumns[row].map((value, counter) => [`column${counter}`, value])),
+ };
+ }
+ return items;
+}
+
+function labelsToFields(labels, isIndex = true) {
+ return labels.map((label, counter) => ({
+ key: isIndex ? `index${counter}` : `column${counter}`,
+ label,
+ sortable: true,
+ class: isIndex ? 'gl-font-weight-bold' : '',
+ }));
+}
+
+function parseFields(columnAndIndexLabels, indexCount, columnCount) {
+ // Fetching the labels: if the dataframe has a single column index, it will be in the format:
+ // thead
+ // tr
+ // th(index0 label) th(column0 label) th(column1 label)
+ //
+ // If there are multiple index columns, it the header will actually have two rows:
+ // thead
+ // tr
+ // th() th() th(column 0 label) th(column1 label)
+ // tr
+ // th(index0 label) th(index1 label) th() th()
+
+ const columnLabels = columnAndIndexLabels[0].slice(-columnCount);
+ const indexLabels = columnAndIndexLabels[columnAndIndexLabels.length - 1].slice(0, indexCount);
+
+ const indexFields = labelsToFields(indexLabels, true);
+ const columnFields = labelsToFields(columnLabels, false);
+
+ return [...indexFields, ...columnFields];
+}
+
+/**
+ * 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 columnAndIndexLabels = [...htmlDoc.querySelectorAll('table > thead tr')].map((row) =>
+ [...row.querySelectorAll('th')].map((item) => item.innerText),
+ );
+
+ if (columnAndIndexLabels.length === 0) return { fields: [], items: [] };
+
+ const tableRows = [...htmlDoc.querySelectorAll('table > tbody > tr')];
+
+ const itemColumns = tableRows.map((row) =>
+ [...row.querySelectorAll('td')].map((item) => item.innerText),
+ );
+
+ const itemIndexes = tableRows.map((row) =>
+ [...row.querySelectorAll('th')].map((item) => item.innerText),
+ );
+
+ const fields = parseFields(columnAndIndexLabels, itemIndexes[0].length, itemColumns[0].length);
+ const items = parseItems(itemIndexes, itemColumns);
+
+ return { fields, items };
+}
+
+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/error.vue b/app/assets/javascripts/notebook/cells/output/error.vue
new file mode 100644
index 00000000000..9afc89cde4f
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/error.vue
@@ -0,0 +1,40 @@
+<script>
+import Prompt from '../prompt.vue';
+import Markdown from '../markdown.vue';
+
+export default {
+ name: 'ErrorOutput',
+ components: {
+ Prompt,
+ Markdown,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: Array,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ parsedError() {
+ let parsed = this.rawCode.map((l) => l.replace(/\u001B\[[0-9][0-9;]*m/g, '')); // eslint-disable-line no-control-regex
+ parsed = ['```error', ...parsed, '```'].join('\n'); // eslint-disable-line @gitlab/require-i18n-strings
+ return { source: [parsed] };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" />
+ <markdown :cell="parsedError" :hide-prompt="true" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index bd01534089e..0437b85913b 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -4,8 +4,12 @@ import HtmlOutput from './html.vue';
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';
export default {
props: {
@@ -28,6 +32,8 @@ export default {
outputType(output) {
if (output.text) {
return 'text/plain';
+ } else if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return 'error';
} else if (output.data['image/png']) {
return 'image/png';
} else if (output.data['image/jpeg']) {
@@ -56,10 +62,14 @@ export default {
getComponent(output) {
if (output.text) {
return CodeOutput;
+ } else if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return ErrorOutput;
} else if (output.data['image/png']) {
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']) {
@@ -80,6 +90,10 @@ export default {
return output.text.join('');
}
+ if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return output.traceback;
+ }
+
return this.dataForType(output, this.outputType(output));
},
},
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index df9694b7cd8..5f254cae73d 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -60,6 +60,10 @@ export default {
margin-bottom: 10px;
}
+.output .text-cell {
+ overflow-x: auto;
+}
+
.cell pre {
margin: 0;
width: 100%;
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index cc372520c70..bde7d219e9f 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -67,7 +67,7 @@ export default {
</script>
<template>
<div
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
+ class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden"
>
<div
v-if="withAlertContainer"
@@ -76,7 +76,7 @@ export default {
></div>
<noteable-warning
v-if="hasWarning"
- class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
+ class="gl-py-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
:is-locked="isLocked"
:is-confidential="isConfidential"
:noteable-type="noteableType"
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4f7256d0b0e..6794f838c84 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,20 +1,20 @@
<script>
import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
-import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
-import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+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,
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -35,7 +35,7 @@ export default {
components: {
NoteSignedOutWidget,
DiscussionLockedWidget,
- MarkdownField,
+ MarkdownEditor,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -61,6 +61,14 @@ export default {
errors: [],
noteIsInternal: false,
isSubmitting: false,
+ formFieldProps: {
+ 'aria-label': this.$options.i18n.comment,
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ id: 'note-body',
+ name: 'note[note]',
+ class: 'js-note-text note-textarea js-gfm-input markdown-area',
+ 'data-qa-selector': 'comment_field',
+ },
};
},
computed: {
@@ -74,6 +82,9 @@ export default {
'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noteableDisplayName() {
const displayNameMap = {
[constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue,
@@ -95,16 +106,11 @@ export default {
}
return this.noteType === constants.COMMENT ? comment : startThread;
},
- textareaPlaceholder() {
- return this.noteIsInternal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
discussionsRequireResolution() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE;
},
isOpen() {
- return this.openState === constants.OPENED || this.openState === constants.REOPENED;
+ return this.openState === STATUS_OPEN || this.openState === STATUS_REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
@@ -152,7 +158,7 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
- this.openState !== constants.MERGED &&
+ this.openState !== STATUS_MERGED &&
!this.closedAndLocked
);
},
@@ -180,14 +186,27 @@ export default {
containsLink() {
return ATTACHMENT_REGEXP.test(this.note);
},
+ autosaveKey() {
+ if (this.isLoggedIn) {
+ const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
+ return `${this.$options.i18n.note}/${noteableType}/${this.getNoteableData.id}`;
+ }
+
+ return null;
+ },
+ },
+ watch: {
+ noteIsInternal(val) {
+ this.formFieldProps.placeholder = val
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
- this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED);
+ this.toggleIssueLocalState(isClosed ? STATUS_CLOSED : STATUS_REOPENED);
});
-
- this.initAutoSave();
},
methods: {
...mapActions([
@@ -209,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) {
@@ -231,8 +250,14 @@ 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.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
@@ -249,7 +274,6 @@ export default {
.catch(({ response }) => {
this.handleSaveError(response);
- this.discard(false);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -286,20 +310,10 @@ export default {
}),
);
},
- discard(shouldClear = true) {
- // `blur` is needed to clear slash commands autocomplete cache if event fired.
- // `focus` is needed to remain cursor in the textarea.
- this.$refs.textarea.blur();
- this.$refs.textarea.focus();
-
- if (shouldClear) {
- this.note = '';
- this.noteIsInternal = false;
- this.resizeTextarea();
- this.$refs.markdownField.previewMarkdown = false;
- }
-
- this.autosave.reset();
+ discard() {
+ this.note = '';
+ this.noteIsInternal = false;
+ this.$refs.markdownEditor.togglePreview(false);
},
editCurrentUserLastNote() {
if (this.note === '') {
@@ -312,28 +326,15 @@ export default {
}
}
},
- initAutoSave() {
- if (this.isLoggedIn) {
- const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
-
- this.autosave = new Autosave(this.$refs.textarea, [
- this.$options.i18n.note,
- noteableType,
- this.getNoteableData.id,
- ]);
- }
- },
- resizeTextarea() {
- this.$nextTick(() => {
- Autosize.update(this.$refs.textarea);
- });
- },
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
dismissError(index) {
this.errors.splice(index, 1);
},
+ onInput(value) {
+ this.note = value;
+ },
},
};
</script>
@@ -362,35 +363,24 @@ export default {
:noteable-type="noteableType"
:contains-link="containsLink"
>
- <markdown-field
- ref="markdownField"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ :value="note"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
- :textarea-value="note"
- >
- <template #textarea>
- <textarea
- id="note-body"
- ref="textarea"
- v-model="note"
- dir="auto"
- :disabled="isSubmitting"
- name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
- data-qa-selector="comment_field"
- data-testid="comment-field"
- data-supports-quick-actions="true"
- :aria-label="$options.i18n.comment"
- :placeholder="textareaPlaceholder"
- @keydown.up="editCurrentUserLastNote()"
- @keydown.meta.enter="handleEnter()"
- @keydown.ctrl.enter="handleEnter()"
- ></textarea>
- </template>
- </markdown-field>
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :form-field-props="formFieldProps"
+ :autosave-key="autosaveKey"
+ :disabled="isSubmitting"
+ :autocomplete-data-sources="autocompleteDataSources"
+ supports-quick-actions
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleEnter()"
+ @keydown.ctrl.enter="handleEnter()"
+ @input="onInput"
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="hasDrafts">
@@ -421,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.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 21b48a2a666..692fd6cc500 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,5 +1,10 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -25,9 +30,10 @@ const SORT_OPTIONS = [
export default {
SORT_OPTIONS,
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
LocalStorageSync,
},
mixins: [Tracking.mixin()],
@@ -164,44 +170,64 @@ export default {
as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
id="discussion-preferences-dropdown"
class="full-width-mobile"
data-qa-selector="discussion_preferences_dropdown"
- :text="__('Sort or filter')"
+ :toggle-text="__('Sort or filter')"
:disabled="isLoading"
- right
+ placement="right"
>
- <div id="discussion-sort">
- <gl-dropdown-item
+ <gl-disclosure-dropdown-group id="discussion-sort">
+ <gl-disclosure-dropdown-item
v-for="{ text, key, cls } in $options.SORT_OPTIONS"
:key="text"
:class="cls"
- is-check-item
- :is-checked="isSortDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
+ :is-selected="isSortDropdownItemActive(key)"
+ @action="fetchSortedDiscussions(key)"
>
- {{ text }}
- </gl-dropdown-item>
- </div>
- <gl-dropdown-divider />
- <div
+ <template #list-item>
+ <gl-icon
+ name="mobile-issue-close"
+ data-testid="dropdown-item-checkbox"
+ :class="[
+ 'gl-dropdown-item-check-icon',
+ { 'gl-visibility-hidden': !isSortDropdownItemActive(key) },
+ 'gl-text-blue-400',
+ ]"
+ />
+ {{ text }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-group
id="discussion-filter"
class="discussion-filter-container js-discussion-filter-container"
+ bordered
>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
v-for="filter in filters"
:key="filter.value"
- is-check-item
- :is-checked="filter.value === currentValue"
+ :is-selected="filter.value === currentValue"
:class="{ 'is-active': filter.value === currentValue }"
:data-filter-type="filterType(filter.value)"
data-qa-selector="filter_menu_item"
- @click.prevent="selectFilter(filter.value)"
+ @action="selectFilter(filter.value)"
>
- {{ filter.title }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
+ <template #list-item>
+ <gl-icon
+ name="mobile-issue-close"
+ data-testid="dropdown-item-checkbox"
+ :class="[
+ 'gl-dropdown-item-check-icon',
+ { 'gl-visibility-hidden': filter.value !== currentValue },
+ 'gl-text-blue-400',
+ ]"
+ />
+ {{ filter.title }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</div>
</template>
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/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index 8ac3f6bea68..bcf9b4cf893 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -33,8 +33,10 @@ export default {
</script>
<template>
- <div class="disabled-comment text-center">
- <span class="issuable-note-warning inline">
+ <div class="disabled-comments gl-mt-3">
+ <span
+ class="issuable-note-warning gl-display-inline-block gl-w-full gl-px-5 gl-py-4 gl-rounded-base"
+ >
<gl-icon :size="16" name="lock" class="icon" />
<span v-if="isProjectArchived">
{{ projectArchivedWarning }}
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index 03bdc7a2cc6..faef8595998 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,12 +1,11 @@
<script>
-/* global Mousetrap */
-import 'mousetrap';
import { throttle } from 'lodash';
import {
keysFor,
MR_NEXT_UNRESOLVED_DISCUSSION,
MR_PREVIOUS_UNRESOLVED_DISCUSSION,
} from '~/behaviors/shortcuts/keybindings';
+import { Mousetrap } from '~/lib/mousetrap';
import eventHub from '~/notes/event_hub';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
index 663163a7552..1dd07fe90d2 100644
--- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -1,4 +1,5 @@
<script>
+import { normalizeChildren } from '~/lib/utils/vue3compat/normalize_children';
/**
* Wrapper for discussion notes replies section.
*
@@ -26,7 +27,7 @@ export default {
);
}
- return children;
+ return normalizeChildren(children);
},
};
</script>
diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
new file mode 100644
index 00000000000..2338c9eef67
--- /dev/null
+++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { __ } from '~/locale';
+import { MR_FILTER_OPTIONS } from '~/notes/constants';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ GlButton,
+ GlButtonGroup,
+ GlIcon,
+ GlSprintf,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ selectedFilters: MR_FILTER_OPTIONS.map((f) => f.value),
+ };
+ },
+ computed: {
+ ...mapState({
+ mergeRequestFilters: (state) => state.notes.mergeRequestFilters,
+ discussionSortOrder: (state) => state.notes.discussionSortOrder,
+ }),
+ selectedFilterText() {
+ const { length } = this.mergeRequestFilters;
+
+ if (length === 0) return __('None');
+
+ const firstSelected = MR_FILTER_OPTIONS.find(
+ ({ value }) => this.mergeRequestFilters[0] === value,
+ );
+
+ if (length === MR_FILTER_OPTIONS.length) {
+ return __('All activity');
+ } else if (length > 1) {
+ return `%{strongStart}${firstSelected.text}%{strongEnd} +${length - 1} more`;
+ }
+
+ return firstSelected.text;
+ },
+ isSortAsc() {
+ return this.discussionSortOrder === 'asc';
+ },
+ sortIcon() {
+ return this.isSortAsc ? 'sort-lowest' : 'sort-highest';
+ },
+ },
+ methods: {
+ ...mapActions(['updateMergeRequestFilters', 'setDiscussionSortDirection']),
+ updateSortDirection() {
+ this.setDiscussionSortDirection({
+ direction: this.isSortAsc ? 'desc' : 'asc',
+ });
+ },
+ applyFilters() {
+ this.updateMergeRequestFilters(this.selectedFilters);
+ },
+ localSyncFilters(filters) {
+ this.updateMergeRequestFilters(filters);
+ this.selectedFilters = filters;
+ },
+ },
+ MR_FILTER_OPTIONS,
+};
+</script>
+
+<template>
+ <div>
+ <local-storage-sync
+ :value="discussionSortOrder"
+ storage-key="sort_direction_merge_request"
+ as-string
+ @input="setDiscussionSortDirection({ direction: $event })"
+ />
+ <local-storage-sync
+ :value="mergeRequestFilters"
+ storage-key="mr_activity_filters"
+ @input="localSyncFilters"
+ />
+ <gl-button-group>
+ <gl-collapsible-listbox
+ v-model="selectedFilters"
+ :items="$options.MR_FILTER_OPTIONS"
+ multiple
+ placement="right"
+ @hidden="applyFilters"
+ >
+ <template #toggle>
+ <gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!">
+ <gl-sprintf :message="selectedFilterText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-icon name="chevron-down" />
+ </gl-button>
+ </template>
+ <template #list-item="{ item }">
+ <strong v-if="item.value === '*'">{{ item.text }}</strong>
+ <span v-else>{{ item.text }}</span>
+ </template>
+ </gl-collapsible-listbox>
+ <gl-button :icon="sortIcon" @click="updateSortDirection" />
+ </gl-button-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index abed95a9706..27fb116d213 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,11 +1,16 @@
<script>
-import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlIcon,
+ GlButton,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
@@ -21,7 +26,7 @@ export default {
editCommentLabel: __('Edit comment'),
deleteCommentLabel: __('Delete comment'),
moreActionsLabel: __('More actions'),
- reportAbuse: __('Report abuse to administrator'),
+ reportAbuse: __('Report abuse'),
},
name: 'NoteActions',
components: {
@@ -29,7 +34,8 @@ export default {
ReplyButton,
TimelineEventButton,
GlButton,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
UserAccessRoleBadge,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
AbuseCategorySelector,
@@ -208,18 +214,23 @@ export default {
methods: {
...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
+ this.closeMoreActionsDropdown();
this.$emit('handleEdit');
},
onDelete() {
+ this.closeMoreActionsDropdown();
this.$emit('handleDelete');
},
onResolve() {
this.$emit('handleResolve');
},
- closeTooltip() {
- this.$nextTick(() => {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- });
+ onAbuse() {
+ this.closeMoreActionsDropdown();
+ this.toggleReportAbuseDrawer(true);
+ },
+ onCopyUrl() {
+ this.closeMoreActionsDropdown();
+ this.$toast.show(__('Link copied to clipboard.'));
},
handleAssigneeUpdate(assignees) {
this.$emit('updateAssignees', assignees);
@@ -230,6 +241,8 @@ export default {
let { assignees } = this;
const { project_id, iid } = this.getNoteableData;
+ this.closeMoreActionsDropdown();
+
if (this.isUserAssigned) {
assignees = assignees.filter((assignee) => assignee.id !== this.author.id);
} else {
@@ -258,6 +271,11 @@ export default {
toggleReportAbuseDrawer(isOpen) {
this.isReportAbuseDrawerOpen = isOpen;
},
+ closeMoreActionsDropdown() {
+ if (this.shouldShowActionsDropdown && this.$refs.moreActionsDropdown) {
+ this.$refs.moreActionsDropdown.close();
+ }
+ },
},
};
</script>
@@ -354,48 +372,61 @@ export default {
class="note-action-button js-note-delete"
@click="onDelete"
/>
- <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions">
- <!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <gl-button
+ <div v-else-if="shouldShowActionsDropdown" class="more-actions dropdown">
+ <gl-disclosure-dropdown
+ ref="moreActionsDropdown"
v-gl-tooltip
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
icon="ellipsis_v"
category="tertiary"
+ placement="right"
class="note-action-button more-actions-toggle"
- data-toggle="dropdown"
- @click="closeTooltip"
- />
- <!-- eslint-enable @gitlab/vue-no-data-toggle -->
- <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
- <gl-dropdown-item
+ no-caret
+ >
+ <gl-disclosure-dropdown-item
v-if="canEdit"
class="js-note-edit gl-sm-display-none!"
- @click.prevent="onEdit"
+ @action="onEdit"
>
- {{ __('Edit comment') }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ __('Edit comment') }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="canReportAsAbuse"
data-testid="report-abuse-button"
- @click="toggleReportAbuseDrawer(true)"
+ @action="onAbuse"
>
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ $options.i18n.reportAbuse }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="noteUrl"
class="js-btn-copy-note-link"
:data-clipboard-text="noteUrl"
+ @action="onCopyUrl"
+ >
+ <template #list-item>
+ {{ __('Copy link') }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
+ v-if="canAssign"
+ data-testid="assign-user"
+ @action="assignUser"
>
- {{ __('Copy link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canAssign" data-testid="assign-user" @click="assignUser">
- {{ displayAssignUserText }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canEdit" class="js-note-delete" @click.prevent="onDelete">
- <span class="text-danger">{{ __('Delete comment') }}</span>
- </gl-dropdown-item>
- </ul>
+ <template #list-item>
+ {{ displayAssignUserText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item v-if="canEdit" class="js-note-delete" @action="onDelete">
+ <template #list-item>
+ <span class="text-danger">{{ __('Delete comment') }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</div>
<!-- IMPORTANT: show this component lazily because it causes layout thrashing -->
<!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 -->
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 9d59994788e..9c04a72375b 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import AwardsList from '~/vue_shared/components/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 20cf21cd1b6..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([
@@ -208,7 +194,7 @@ export default {
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
- action-text="Edited"
+ :action-text="__('Edited')"
class="note_edited_ago"
/>
<note-awards-list
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index e0c3ed0c67a..25c82c29a29 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,10 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlSprintf, GlLink } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { EDITED_TEXT } from '../i18n';
export default {
name: 'EditedNoteText',
components: {
+ GlSprintf,
+ GlLink,
TimeAgoTooltip,
},
props: {
@@ -33,19 +36,42 @@ export default {
default: 'edited-text',
},
},
+ i18n: EDITED_TEXT,
};
</script>
<template>
<div :class="className">
- {{ actionText }}
- <template v-if="editedBy">
- by
- <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link">
- {{ editedBy.name }}
- </a>
- </template>
- {{ actionDetailText }}
- <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ <gl-sprintf v-if="editedBy" :message="$options.i18n.actionWithAuthor">
+ <template #actionText>
+ {{ actionText }}
+ </template>
+ <template #actionDetail>
+ {{ actionDetailText }}
+ </template>
+ <template #timeago>
+ <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ </template>
+ <template #author>
+ <gl-link
+ :href="editedBy.path"
+ :data-user-id="editedBy.id"
+ class="js-user-link author-link gl-hover-text-decoration-underline"
+ >
+ {{ editedBy.name }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.i18n.actionWithoutAuthor">
+ <template #actionText>
+ {{ actionText }}
+ </template>
+ <template #actionDetail>
+ {{ actionDetailText }}
+ </template>
+ <template #timeago>
+ <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b6ede10d02b..2cf6e9bb180 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;
@@ -331,55 +338,49 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :is-internal-note="discussion.internal"
+ :is-internal-note="discussionNote.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/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index c83b3d870d7..5e776639a7a 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -68,6 +68,16 @@ export default {
required: false,
default: false,
},
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emailParticipant: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -85,11 +95,18 @@ export default {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
noteTimestampLink() {
- return this.noteId ? `#note_${this.noteId}` : undefined;
+ if (this.noteUrl) return this.noteUrl;
+
+ return this.noteId ? `#note_${getIdFromGraphQLId(this.noteId)}` : undefined;
},
hasAuthor() {
return this.author && Object.keys(this.author).length;
},
+ isServiceDeskEmailParticipant() {
+ return (
+ !this.isInternalNote && this.author.username === 'support-bot' && this.emailParticipant
+ );
+ },
authorLinkClasses() {
return {
hover: this.isUsernameLinkHovered,
@@ -101,7 +118,7 @@ export default {
};
},
authorName() {
- return this.author.name;
+ return this.isServiceDeskEmailParticipant ? this.emailParticipant : this.author.name;
},
internalNoteTooltip() {
return s__('Notes|This internal note will always remain confidential');
@@ -152,22 +169,31 @@ export default {
</button>
</div>
<template v-if="hasAuthor">
+ <span
+ v-if="emailParticipant"
+ class="note-header-author-name gl-font-weight-bold"
+ data-testid="author-name"
+ v-text="authorName"
+ ></span>
<a
+ v-else
ref="authorNameLink"
:href="authorHref"
:class="authorLinkClasses"
:data-user-id="authorId"
:data-username="author.username"
>
- <span class="note-header-author-name gl-font-weight-bold">
- {{ authorName }}
- </span>
+ <span
+ class="note-header-author-name gl-font-weight-bold"
+ data-testid="author-name"
+ v-text="authorName"
+ ></span>
</a>
- <span v-if="!isSystemNote" class="text-nowrap author-username">
+ <span v-if="!isSystemNote && !emailParticipant" class="text-nowrap author-username">
<a
ref="authorUsernameLink"
class="author-username-link"
- :href="author.path"
+ :href="authorHref"
@mouseenter="handleUsernameMouseEnter"
@mouseleave="handleUsernameMouseLeave"
><span class="note-headline-light">@{{ author.username }}</span>
@@ -175,6 +201,9 @@ export default {
<slot name="note-header-info"></slot>
<gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
</span>
+ <span v-if="emailParticipant" class="note-headline-light">{{
+ __('(external participant)')
+ }}</span>
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ff801cdccea..375b16f6ce2 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import DraftNote from '~/batch_comments/components/draft_note.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -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,
@@ -309,7 +319,7 @@ export default {
/>
<li
v-else-if="canShowReplyActions && showReplies"
- :class="{ 'is-replying': isReplying }"
+ :class="{ 'is-replying gl-bg-white! gl-pt-0!': isReplying }"
class="discussion-reply-holder gl-border-t-0! clearfix"
>
<discussion-actions
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 93575ad57ff..5929e419247 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -6,13 +6,14 @@ import { mapGetters, mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_GONE } from '~/lib/utils/http_status';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
import { 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;
@@ -470,6 +477,7 @@ export default {
:note-id="note.id"
:is-internal-note="note.internal"
:noteable-type="noteableType"
+ :email-participant="note.external_author"
>
<template #note-header-info>
<slot name="note-header-info"></slot>
diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue
index 9c3b2139a5d..a91c825710d 100644
--- a/app/assets/javascripts/notes/components/notes_activity_header.vue
+++ b/app/assets/javascripts/notes/components/notes_activity_header.vue
@@ -1,15 +1,24 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DiscussionFilter from './discussion_filter.vue';
export default {
components: {
TimelineToggle: () => import('./timeline_toggle.vue'),
DiscussionFilter,
+ AiSummarizeNotes: () =>
+ import('ee_component/notes/components/note_actions/ai_summarize_notes.vue'),
+ MrDiscussionFilter: () => import('./mr_discussion_filter.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
showTimelineViewToggle: {
default: false,
},
+ resourceGlobalId: { default: null },
+ mrFilter: {
+ default: false,
+ },
},
props: {
notesFilters: {
@@ -21,18 +30,34 @@ export default {
default: undefined,
required: false,
},
+ aiLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ showAiActions() {
+ return this.resourceGlobalId && this.glFeatures.summarizeComments;
+ },
},
};
</script>
<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">
+ <ai-summarize-notes
+ v-if="showAiActions"
+ :resource-global-id="resourceGlobalId"
+ :loading="aiLoading"
+ />
<timeline-toggle v-if="showTimelineViewToggle" />
- <discussion-filter :filters="notesFilters" :selected-value="notesFilterValue" />
+ <mr-discussion-filter v-if="mrFilter && glFeatures.mrActivityFilters" />
+ <discussion-filter v-else :filters="notesFilters" :selected-value="notesFilterValue" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index fcf37217902..06c925002b6 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -35,6 +35,7 @@ export default {
SidebarSubscription,
DraftNote,
TimelineEntryItem,
+ AiSummary: () => import('ee_component/notes/components/ai_summary.vue'),
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -70,6 +71,8 @@ export default {
return {
currentFilter: null,
renderSkeleton: !this.shouldShow,
+ aiLoading: null,
+ isInitialEventTriggered: false,
};
},
computed: {
@@ -211,6 +214,9 @@ export default {
.then(this.$nextTick)
.then(() => eventHub.$emit('startReplying', discussionId));
},
+ setAiLoading(loading) {
+ this.aiLoading = loading;
+ },
},
systemNote: constants.SYSTEM_NOTE,
};
@@ -219,7 +225,13 @@ export default {
<template>
<div v-show="shouldShow" id="notes">
<sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
- <notes-activity-header :notes-filters="notesFilters" :notes-filter-value="notesFilterValue" />
+ <notes-activity-header
+ :notes-filters="notesFilters"
+ :notes-filter-value="notesFilterValue"
+ :ai-loading="aiLoading"
+ @set-ai-loading="setAiLoading"
+ />
+ <ai-summary v-if="aiLoading !== null" @set-ai-loading="setAiLoading" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 4437d461308..b0f7a4a4732 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -39,7 +39,7 @@ export default {
},
liClasses() {
return this.collapsed
- ? 'gl-text-gray-500 gl-rounded-bottom-left-base gl-rounded-bottom-right-base'
+ ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base! replies-widget-collapsed'
: 'gl-border-b';
},
buttonIcon() {
@@ -76,7 +76,7 @@ export default {
v-for="author in uniqueAuthors"
:key="author.username"
class="gl-mr-3 reply-author-avatar"
- :link-href="author.path"
+ :link-href="author.path || author.webUrl"
:img-alt="author.name"
img-css-classes="gl-mr-0!"
:img-src="author.avatar_url || author.avatarUrl"
@@ -95,7 +95,7 @@ export default {
<gl-sprintf :message="$options.i18n.lastReplyBy">
<template #name>
<gl-link
- :href="lastReply.author.path"
+ :href="lastReply.author.path || lastReply.author.webUrl"
class="gl-text-body! gl-text-decoration-none! gl-mx-2"
>
{{ lastReply.author.name }}
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 88f438975f6..419b427682e 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,4 +1,5 @@
-import { __ } from '~/locale';
+import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
+import { __, s__ } from '~/locale';
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
@@ -6,10 +7,6 @@ export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment';
-export const OPENED = 'opened';
-export const REOPENED = 'reopened';
-export const CLOSED = 'closed';
-export const MERGED = 'merged';
export const ISSUE_NOTEABLE_TYPE = 'Issue';
export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
@@ -43,13 +40,80 @@ export const DISCUSSION_FILTER_TYPES = {
export const toggleStateErrorMessage = {
Epic: {
- [CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'),
- [OPENED]: __('Something went wrong while closing the epic. Please try again later.'),
- [REOPENED]: __('Something went wrong while closing the epic. Please try again later.'),
+ [STATUS_CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'),
+ [STATUS_OPEN]: __('Something went wrong while closing the epic. Please try again later.'),
+ [STATUS_REOPENED]: __('Something went wrong while closing the epic. Please try again later.'),
},
MergeRequest: {
- [CLOSED]: __('Something went wrong while reopening the merge request. Please try again later.'),
- [OPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
- [REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
+ [STATUS_CLOSED]: __(
+ 'Something went wrong while reopening the merge request. Please try again later.',
+ ),
+ [STATUS_OPEN]: __(
+ 'Something went wrong while closing the merge request. Please try again later.',
+ ),
+ [STATUS_REOPENED]: __(
+ 'Something went wrong while closing the merge request. Please try again later.',
+ ),
},
};
+
+export const MR_FILTER_OPTIONS = [
+ {
+ text: __('Approvals'),
+ value: 'approval',
+ systemNoteIcons: ['approval', 'unapproval', 'check'],
+ },
+ {
+ text: __('Assignees & reviewers'),
+ value: 'assignees_reviewers',
+ noteText: [
+ s__('IssuableEvents|requested review from'),
+ s__('IssuableEvents|removed review request for'),
+ s__('IssuableEvents|assigned to'),
+ s__('IssuableEvents|unassigned'),
+ ],
+ },
+ {
+ text: __('Comments'),
+ value: 'comments',
+ noteType: ['DiscussionNote', 'DiffNote'],
+ individualNote: true,
+ noteText: [s__('IssuableEvents|resolved all threads')],
+ },
+ {
+ text: __('Commits & branches'),
+ value: 'commit_branches',
+ systemNoteIcons: ['commit', 'fork'],
+ },
+ {
+ text: __('Edits'),
+ value: 'edits',
+ systemNoteIcons: ['pencil', 'task-done'],
+ },
+ {
+ text: __('Labels'),
+ value: 'labels',
+ systemNoteIcons: ['label'],
+ },
+ {
+ text: __('Lock status'),
+ value: 'lock_status',
+ systemNoteIcons: ['lock', 'lock-open'],
+ },
+ {
+ text: __('Mentions'),
+ value: 'mentions',
+ systemNoteIcons: ['comment-dots'],
+ },
+ {
+ text: __('Merge request status'),
+ value: 'status',
+ systemNoteIcons: ['git-merge', 'issue-close', 'issues', 'merge-request-close'],
+ },
+ {
+ text: __('Tracking'),
+ value: 'tracking',
+ noteType: ['MilestoneNote'],
+ systemNoteIcons: ['timer'],
+ },
+];
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index a758a55014a..4bf2a8d70a7 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -49,3 +49,8 @@ export const COMMENT_FORM = {
'Notes|Attachments are sent by email. Attachments over 10 MB are sent as links to your GitLab instance, and only accessible to project members.',
),
};
+
+export const EDITED_TEXT = {
+ actionWithAuthor: __('%{actionText} %{actionDetail} %{timeago} by %{author}'),
+ actionWithoutAuthor: __('%{actionText} %{actionDetail}'),
+};
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2e09c9f2288..724b47bf44b 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,16 +1,21 @@
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';
import { store } from './stores';
import { getNotesFilterData } from './utils/get_notes_filter_data';
-export default () => {
+export default ({ editorAiActions = [] } = {}) => {
const el = document.getElementById('js-vue-notes');
if (!el) {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle);
@@ -50,9 +55,13 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
showTimelineViewToggle,
reportAbusePath: notesDataset.reportAbusePath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id),
+ editorAiActions: editorAiActions.map((factory) => factory(noteableData)),
},
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/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 9a140029c07..0509ff24959 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,7 +1,7 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
import { s__ } from '~/locale';
import { formatLineRange } from '~/notes/components/multiline_comment_utils';
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/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 44751020173..63822a31cd1 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
export default {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index f6b9be6ee9b..dc7f1577bbb 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -2,9 +2,9 @@ import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import Api from '~/api';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { STATUS_CLOSED, STATUS_REOPENED, TYPE_ISSUE } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
@@ -19,7 +19,6 @@ import { mergeUrlParams } from '~/lib/utils/url_utility';
import sidebarTimeTrackingEventHub from '~/sidebar/event_hub';
import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
-import SidebarStore from '~/sidebar/stores/sidebar_store';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_NOTE } from '~/graphql_shared/constants';
import notesEventHub from '../event_hub';
@@ -407,7 +406,7 @@ export const emitStateChangedEvent = ({ getters }, data) => {
const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, {
detail: {
data,
- isClosed: getters.openState === constants.CLOSED,
+ isClosed: getters.openState === STATUS_CLOSED,
},
});
@@ -415,9 +414,9 @@ export const emitStateChangedEvent = ({ getters }, data) => {
};
export const toggleIssueLocalState = ({ commit }, newState) => {
- if (newState === constants.CLOSED) {
+ if (newState === STATUS_CLOSED) {
commit(types.CLOSE_ISSUE);
- } else if (newState === constants.REOPENED) {
+ } else if (newState === STATUS_REOPENED) {
commit(types.REOPEN_ISSUE);
}
};
@@ -467,12 +466,17 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const processQuickActions = (res) => {
const {
- errors: { commands_only: commandsOnly, command_names: commandNames } = {
+ errors: { commands_only: commandsOnly } = {
commands_only: null,
command_names: [],
},
+ command_names: commandNames,
} = res;
- let message = commandsOnly;
+ const message = commandsOnly;
+
+ if (commandNames?.indexOf('submit_review') >= 0) {
+ dispatch('batchComments/clearDrafts');
+ }
/*
The following reply means that quick actions have been successfully applied:
@@ -491,13 +495,6 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
confidentialWidget.setConfidentiality();
}
- const commands = ['approve', 'merge', 'assign_reviewer', 'assign'];
- const commandUpdatesAttentionRequest = commandNames[0].some((c) => commands.includes(c));
-
- if (commandUpdatesAttentionRequest && SidebarStore.singleton.currentUserHasAttention) {
- message = sprintf(__('%{message}. Your attention request was removed.'), { message });
- }
-
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
createAlert({
@@ -759,10 +756,10 @@ export const submitSuggestion = (
const errorMessage = err.response.data?.message;
- const flashMessage = errorMessage || defaultMessage;
+ const alertMessage = errorMessage || defaultMessage;
createAlert({
- message: flashMessage,
+ message: alertMessage,
parent: flashContainer,
});
})
@@ -795,10 +792,10 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl
const errorMessage = err.response.data?.message;
- const flashMessage = errorMessage || defaultMessage;
+ const alertMessage = errorMessage || defaultMessage;
createAlert({
- message: flashMessage,
+ message: alertMessage,
parent: flashContainer,
});
})
@@ -900,3 +897,6 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
+
+export const updateMergeRequestFilters = ({ commit }, newFilters) =>
+ commit(types.SET_MERGE_REQUEST_FILTERS, newFilters);
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index f6373f24b74..3fb9913bdcb 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -22,10 +22,51 @@ const getDraftComments = (state) => {
.sort((a, b) => a.id - b.id);
};
+const hideActivity = (filters, discussion) => {
+ const firstNote = discussion.notes[0];
+
+ return constants.MR_FILTER_OPTIONS.some((f) => {
+ if (filters.includes(f.value) || f.value === '*') return false;
+
+ if (
+ // For all of the below firstNote is the first note of a discussion, whether that be
+ // the first in a discussion or a single note
+ // If the filter option filters based on icon check against the first notes system note icon
+ f.systemNoteIcons?.includes(firstNote.system_note_icon_name) ||
+ // If the filter option filters based on note type user the first notes type
+ f.noteType?.includes(firstNote.type) ||
+ // If the filter option filters based on the note text then check if it is sytem
+ // and filter based on the text of the system note
+ (firstNote.system && f.noteText?.some((t) => firstNote.note.includes(t))) ||
+ // For individual notes we filter if the discussion is a single note and is not a sytem
+ (f.individualNote === discussion.individual_note && !firstNote.system)
+ ) {
+ return true;
+ }
+
+ return false;
+ });
+};
+
export const discussions = (state, getters, rootState) => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
+ if (
+ state.noteableData.targetType === 'merge_request' &&
+ window.gon?.features?.mrActivityFilters
+ ) {
+ discussionsInState = discussionsInState.reduce((acc, discussion) => {
+ if (hideActivity(state.mergeRequestFilters, discussion)) {
+ return acc;
+ }
+
+ acc.push(discussion);
+
+ return acc;
+ }, []);
+ }
+
if (state.isTimelineEnabled) {
discussionsInState = discussionsInState
.reduce((acc, discussion) => {
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 81c4c42a49a..317fe6442d4 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -1,4 +1,4 @@
-import { ASC } from '../../constants';
+import { ASC, MR_FILTER_OPTIONS } from '../../constants';
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
@@ -51,6 +51,7 @@ export default () => ({
isTimelineEnabled: false,
isFetching: false,
isPollingInitialized: false,
+ mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value),
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index bc1d5b5bba4..4008b40b57f 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -61,3 +61,5 @@ export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPT
// Incidents
export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS';
+
+export const SET_MERGE_REQUEST_FILTERS = 'SET_MERGE_REQUEST_FILTERS';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 5d532b68f1b..c3407936847 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,4 +1,5 @@
import { isEqual } from 'lodash';
+import { STATUS_CLOSED, STATUS_REOPENED } from '~/issues/constants';
import { isInMRPage } from '~/lib/utils/common_utils';
import * as constants from '../constants';
import * as types from './mutation_types';
@@ -319,11 +320,11 @@ export default {
},
[types.CLOSE_ISSUE](state) {
- Object.assign(state.noteableData, { state: constants.CLOSED });
+ Object.assign(state.noteableData, { state: STATUS_CLOSED });
},
[types.REOPEN_ISSUE](state) {
- Object.assign(state.noteableData, { state: constants.REOPENED });
+ Object.assign(state.noteableData, { state: STATUS_REOPENED });
},
[types.TOGGLE_STATE_BUTTON_LOADING](state, value) {
@@ -431,4 +432,7 @@ export default {
[types.SET_IS_POLLING_INITIALIZED](state, value) {
state.isPollingInitialized = value;
},
+ [types.SET_MERGE_REQUEST_FILTERS](state, value) {
+ state.mergeRequestFilters = value;
+ },
};
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index 14e97fcef46..ed1c80e7a6e 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,5 +1,5 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { marked } from 'marked';
+import markedBidi from 'marked-bidi';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
@@ -8,12 +8,14 @@ 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,
});
+marked.use(markedBidi());
+
export const renderMarkdown = (rawMarkdown) => {
return sanitize(marked(rawMarkdown), markdownConfig);
};
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/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue
index ff9cf6ff6c5..36cbe715149 100644
--- a/app/assets/javascripts/observability/components/observability_app.vue
+++ b/app/assets/javascripts/observability/components/observability_app.vue
@@ -2,7 +2,7 @@
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
-import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '../constants';
+import { MESSAGE_EVENT_TYPE, FULL_APP_DIMENSIONS } from '../constants';
import ObservabilitySkeleton from './skeleton/index.vue';
export default {
@@ -14,25 +14,33 @@ export default {
type: String,
required: true,
},
+ inlineEmbed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ skeletonVariant: {
+ type: String,
+ required: false,
+ default: 'dashboards',
+ },
+ height: {
+ type: String,
+ required: false,
+ default: FULL_APP_DIMENSIONS.HEIGHT,
+ },
+ width: {
+ type: String,
+ required: false,
+ default: FULL_APP_DIMENSIONS.WIDTH,
+ },
},
computed: {
iframeSrcWithParams() {
- return setUrlParams(
+ return `${setUrlParams(
{ theme: darkModeEnabled() ? 'dark' : 'light', username: gon?.current_username },
this.observabilityIframeSrc,
- );
- },
- getSkeletonVariant() {
- const [, variant] =
- Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) =>
- this.$route.path.endsWith(path),
- ) || [];
-
- const DEFAULT_SKELETON = 'dashboards';
-
- if (!variant) return DEFAULT_SKELETON;
-
- return variant;
+ )}${this.inlineEmbed ? '&kiosk=inline-embed' : ''}`;
},
},
mounted() {
@@ -54,38 +62,24 @@ export default {
this.$refs.observabilitySkeleton.onContentLoaded();
break;
case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE:
- this.routeUpdateHandler(payload);
+ this.$emit('route-update', payload);
break;
default:
break;
}
},
- routeUpdateHandler(payload) {
- const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url;
-
- const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath;
-
- if (shouldNotHandleMessage) {
- return;
- }
-
- // this will update the `observability_path` query param on each route change inside Observability UI
- this.$router.replace({
- name: this.$route.pathname,
- query: { ...this.$route.query, observability_path: payload.url },
- });
- },
},
};
</script>
<template>
- <observability-skeleton ref="observabilitySkeleton" :variant="getSkeletonVariant">
+ <observability-skeleton ref="observabilitySkeleton" :variant="skeletonVariant">
<iframe
id="observability-ui-iframe"
data-testid="observability-ui-iframe"
frameborder="0"
- height="100%"
+ :width="width"
+ :height="height"
:src="iframeSrcWithParams"
sandbox="allow-same-origin allow-forms allow-scripts"
></iframe>
diff --git a/app/assets/javascripts/observability/components/skeleton/embed.vue b/app/assets/javascripts/observability/components/skeleton/embed.vue
new file mode 100644
index 00000000000..7abaf2b1bc7
--- /dev/null
+++ b/app/assets/javascripts/observability/components/skeleton/embed.vue
@@ -0,0 +1,15 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+<template>
+ <gl-skeleton-loader>
+ <rect y="5" width="400" height="30" rx="2" ry="2" />
+ <rect y="50" width="400" height="80" rx="2" ry="2" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue
index c8f196a43f4..d91f2874943 100644
--- a/app/assets/javascripts/observability/components/skeleton/index.vue
+++ b/app/assets/javascripts/observability/components/skeleton/index.vue
@@ -8,10 +8,12 @@ import {
OBSERVABILITY_ROUTES,
TIMEOUT_ERROR_LABEL,
TIMEOUT_ERROR_MESSAGE,
+ SKELETON_VARIANT_EMBED,
} from '../../constants';
import DashboardsSkeleton from './dashboards.vue';
import ExploreSkeleton from './explore.vue';
import ManageSkeleton from './manage.vue';
+import EmbedSkeleton from './embed.vue';
export default {
components: {
@@ -19,11 +21,13 @@ export default {
DashboardsSkeleton,
ExploreSkeleton,
ManageSkeleton,
+ EmbedSkeleton,
GlAlert,
},
SKELETON_VARIANTS_BY_ROUTE,
SKELETON_STATE,
OBSERVABILITY_ROUTES,
+ SKELETON_VARIANT_EMBED,
i18n: {
TIMEOUT_ERROR_LABEL,
TIMEOUT_ERROR_MESSAGE,
@@ -102,6 +106,7 @@ export default {
<dashboards-skeleton v-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.DASHBOARDS)" />
<explore-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.EXPLORE)" />
<manage-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.MANAGE)" />
+ <embed-skeleton v-else-if="variant === $options.SKELETON_VARIANT_EMBED" />
<gl-skeleton-loader v-else>
<rect y="2" width="10" height="8" />
@@ -122,12 +127,14 @@ export default {
{{ $options.i18n.TIMEOUT_ERROR_MESSAGE }}
</gl-alert>
- <div
- v-show="state === $options.SKELETON_STATE.HIDDEN"
- data-testid="observability-wrapper"
- class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
- >
- <slot></slot>
- </div>
+ <transition>
+ <div
+ v-show="state === $options.SKELETON_STATE.HIDDEN"
+ data-testid="observability-wrapper"
+ class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
+ >
+ <slot></slot>
+ </div>
+ </transition>
</div>
</template>
diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js
index e4827dd169f..6b97c51e997 100644
--- a/app/assets/javascripts/observability/constants.js
+++ b/app/assets/javascripts/observability/constants.js
@@ -17,6 +17,8 @@ export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({
[OBSERVABILITY_ROUTES.MANAGE]: 'manage',
});
+export const SKELETON_VARIANT_EMBED = 'embed';
+
export const SKELETON_STATE = Object.freeze({
ERROR: 'error',
VISIBLE: 'visible',
@@ -30,3 +32,13 @@ export const DEFAULT_TIMERS = Object.freeze({
export const TIMEOUT_ERROR_LABEL = __('Unable to load the page');
export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.');
+
+export const INLINE_EMBED_DIMENSIONS = Object.freeze({
+ HEIGHT: '366px',
+ WIDTH: '768px',
+});
+
+export const FULL_APP_DIMENSIONS = Object.freeze({
+ HEIGHT: '100%',
+ WIDTH: '100%',
+});
diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js
index cd342ebee3e..72ff1357551 100644
--- a/app/assets/javascripts/observability/index.js
+++ b/app/assets/javascripts/observability/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import ObservabilityApp from './components/observability_app.vue';
+import { SKELETON_VARIANTS_BY_ROUTE } from './constants';
Vue.use(VueRouter);
@@ -17,10 +18,41 @@ export default () => {
return new Vue({
el,
router,
+ computed: {
+ skeletonVariant() {
+ const [, variant] =
+ Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) =>
+ this.$route.path.endsWith(path),
+ ) || [];
+
+ return variant;
+ },
+ },
+ methods: {
+ routeUpdateHandler(payload) {
+ const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url;
+
+ const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath;
+
+ if (shouldNotHandleMessage) {
+ return;
+ }
+
+ // this will update the `observability_path` query param on each route change inside Observability UI
+ this.$router.replace({
+ name: this.$route?.pathname,
+ query: { ...this.$route.query, observability_path: payload.url },
+ });
+ },
+ },
render(h) {
return h(ObservabilityApp, {
props: {
observabilityIframeSrc: el.dataset.observabilityIframeSrc,
+ skeletonVariant: this.skeletonVariant,
+ },
+ on: {
+ 'route-update': (payload) => this.routeUpdateHandler(payload),
},
});
},
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
index faddf9c7b81..e56583963ad 100644
--- a/app/assets/javascripts/operation_settings/index.js
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -5,6 +5,8 @@ import store from './store';
export default () => {
const el = document.querySelector('.js-operation-settings');
+ if (!el) return false;
+
return new Vue({
el,
store: store(el.dataset),
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
index 5f60cab8bdd..7fa79da59c4 100644
--- a/app/assets/javascripts/operation_settings/store/actions.js
+++ b/app/assets/javascripts/operation_settings/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -26,7 +26,7 @@ export const saveChanges = ({ state, dispatch }) =>
export const receiveSaveChangesSuccess = () => {
/**
* The operations_controller currently handles successful requests
- * by creating a flash banner messsage to notify the user.
+ * by creating an alert banner message to notify the user.
*/
refreshCurrentPage();
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_modal.vue
index 2da8ca2d8a8..7922ff9cce3 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_modal.vue
@@ -1,12 +1,13 @@
<script>
import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
-import { n__ } from '~/locale';
+import { __, n__ } from '~/locale';
import {
REMOVE_TAG_CONFIRMATION_TEXT,
REMOVE_TAGS_CONFIRMATION_TEXT,
DELETE_IMAGE_CONFIRMATION_TITLE,
DELETE_IMAGE_CONFIRMATION_TEXT,
-} from '../../constants';
+} from '../constants';
+import { getImageName } from '../utils';
export default {
components: {
@@ -28,12 +29,13 @@ export default {
},
data() {
return {
- projectPath: '',
+ inputImageName: '',
};
},
computed: {
- imageProjectPath() {
- return this.itemsToBeDeleted[0]?.project?.path;
+ imageName() {
+ const [item] = this.itemsToBeDeleted;
+ return getImageName(item);
},
modalTitle() {
if (this.deleteImage) {
@@ -49,7 +51,7 @@ export default {
if (this.deleteImage) {
return {
message: DELETE_IMAGE_CONFIRMATION_TEXT,
- item: this.imageProjectPath,
+ item: this.imageName,
};
}
if (this.itemsToBeDeleted.length > 1) {
@@ -66,7 +68,13 @@ export default {
};
},
disablePrimaryButton() {
- return this.deleteImage && this.projectPath !== this.imageProjectPath;
+ return this.deleteImage && this.inputImageName !== this.imageName;
+ },
+ primaryActionProps() {
+ return {
+ text: __('Delete'),
+ attributes: { variant: 'danger', disabled: this.disablePrimaryButton },
+ };
},
},
methods: {
@@ -74,25 +82,25 @@ export default {
this.$refs.deleteModal.show();
},
},
+ modal: {
+ cancelAction: {
+ text: __('Cancel'),
+ },
+ },
};
</script>
<template>
<gl-modal
ref="deleteModal"
- modal-id="delete-tag-modal"
+ modal-id="delete-modal"
ok-variant="danger"
size="sm"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: __('Delete'),
- attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: __('Cancel'),
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :action-primary="primaryActionProps"
+ :action-cancel="$options.modal.cancelAction"
@primary="$emit('confirmDelete')"
@cancel="$emit('cancelDelete')"
- @change="projectPath = ''"
+ @change="inputImageName = ''"
>
<template #modal-title>{{ modalTitle }}</template>
<p v-if="modalDescription" data-testid="description">
@@ -106,7 +114,7 @@ export default {
</gl-sprintf>
</p>
<div v-if="deleteImage">
- <gl-form-input v-model="projectPath" />
+ <gl-form-input v-model="inputImageName" />
</div>
</gl-modal>
</template>
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..da88f768c03 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,
@@ -24,6 +25,7 @@ import {
} from '../../constants/index';
import getContainerRepositoryMetadata from '../../graphql/queries/get_container_repository_metadata.query.graphql';
+import { getImageName } from '../../utils';
export default {
name: 'DetailsHeader',
@@ -65,11 +67,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) {
@@ -99,7 +101,7 @@ export default {
return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
},
imageName() {
- return this.imageDetails.name || this.imageDetails.project?.path;
+ return getImageName(this.imageDetails);
},
formattedSize() {
const { size } = this.imageDetails;
@@ -145,9 +147,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 c10d8be69a0..9ea1958a0d1 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
@@ -1,14 +1,18 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+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 DeleteModal from '../delete_modal.vue';
import TagsListRow from './tags_list_row.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,
};
},
@@ -87,6 +96,9 @@ export default {
tags() {
return this.containerRepository?.tags?.nodes || [];
},
+ hideBulkDelete() {
+ return !this.containerRepository?.canDelete;
+ },
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
},
@@ -98,14 +110,16 @@ export default {
sort: this.sort,
};
},
- showMultiDeleteButton() {
- return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
- },
hasNoTags() {
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 }) {
@@ -186,13 +241,14 @@ export default {
/>
<template v-else>
<registry-list
+ :hidden-delete="hideBulkDelete"
:title="listTitle"
:pagination="tagsPageInfo"
:items="tags"
id-property="name"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
- @delete="$emit('delete', $event)"
+ @delete="deleteTags"
>
<template #default="{ selectItem, isSelected, item, first }">
<tags-list-row
@@ -202,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/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index 38b601ac3ec..8e89128a382 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -109,9 +109,6 @@ export default {
isInvalidTag() {
return !this.tag.digest;
},
- isDeleteDisabled() {
- return this.disabled || !this.tag.canDelete;
- },
},
};
</script>
@@ -179,16 +176,16 @@ export default {
</gl-sprintf>
</span>
</template>
- <template #right-action>
+ <template v-if="tag.canDelete" #right-action>
<gl-dropdown
- :disabled="isDeleteDisabled"
+ :disabled="disabled"
icon="ellipsis_v"
:text="$options.i18n.MORE_ACTIONS_TEXT"
:text-sr-only="true"
category="tertiary"
no-caret
right
- :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }"
data-testid="additional-actions"
data-qa-selector="more_actions_menu"
>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index 4f89d217623..f6f816f435c 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
import Tracking from '~/tracking';
@@ -28,7 +28,6 @@ export default {
DeleteButton,
GlSprintf,
GlButton,
- GlIcon,
ListItem,
GlSkeletonLoader,
CleanupStatus,
@@ -80,8 +79,8 @@ export default {
},
tagsCountText() {
return n__(
- 'ContainerRegistry|%{count} Tag',
- 'ContainerRegistry|%{count} Tags',
+ 'ContainerRegistry|%{count} tag',
+ 'ContainerRegistry|%{count} tags',
this.item.tagsCount,
);
},
@@ -152,7 +151,6 @@ export default {
<span v-if="deleting">{{ $options.i18n.ROW_SCHEDULED_FOR_DELETION }}</span>
<template v-else>
<span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
{{ item.tagsCount }}
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/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index f2aa4916f48..89cdbf6acba 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -4,6 +4,7 @@ import { NAME_SORT_FIELD } from './common';
// Translations strings
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const SETTINGS_TEXT = s__('ContainerRegistry|Configure in settings');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
`ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
@@ -15,9 +16,6 @@ export const LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION = s__(
`ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}`,
);
export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
-export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
- 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
-);
export const ROW_SCHEDULED_FOR_DELETION = s__(
`ContainerRegistry|This image repository is scheduled for deletion`,
);
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/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
index e57ac2a9efe..a0a80600603 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -12,6 +12,7 @@ query getContainerRepositoryTags(
containerRepository(id: $id) {
id
tagsCount
+ canDelete
tags(after: $after, before: $before, first: $first, last: $last, name: $name, sort: $sort) {
nodes {
digest
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
index a558550c91f..afddf78203d 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
@@ -36,6 +36,7 @@ export default () => {
isGroupPage,
isAdmin,
showCleanupPolicyLink,
+ showContainerRegistrySettings,
showUnfinishedTagCleanupCallout,
connectionError,
invalidPathError,
@@ -69,6 +70,7 @@ export default () => {
isGroupPage: parseBoolean(isGroupPage),
isAdmin: parseBoolean(isAdmin),
showCleanupPolicyLink: parseBoolean(showCleanupPolicyLink),
+ showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
connectionError: parseBoolean(connectionError),
invalidPathError: parseBoolean(invalidPathError),
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 83c0d2cdfca..3126af69c2c 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
@@ -1,36 +1,28 @@
<script>
import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
-import DeleteModal from '../components/details_page/delete_modal.vue';
+import DeleteModal from '../components/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
import {
- 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/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 8a038d7c974..fe29fa8fdd7 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -1,8 +1,8 @@
<script>
import {
+ GlButton,
GlEmptyState,
GlTooltipDirective,
- GlModal,
GlSprintf,
GlLink,
GlAlert,
@@ -10,31 +10,33 @@ import {
} from '@gitlab/ui';
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import Tracking from '~/tracking';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import DeleteImage from '../components/delete_image.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
+import DeleteModal from '../components/delete_modal.vue';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- REMOVE_REPOSITORY_MODAL_TEXT,
- REMOVE_REPOSITORY_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
SORT_FIELDS,
+ SETTINGS_TEXT,
} from '../constants/index';
import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
export default {
name: 'RegistryListPage',
components: {
+ GlButton,
GlEmptyState,
ProjectEmptyState: () =>
import(
@@ -52,7 +54,7 @@ export default {
import(
/* webpackChunkName: 'container_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue'
),
- GlModal,
+ DeleteModal,
GlSprintf,
GlLink,
GlAlert,
@@ -74,10 +76,9 @@ export default {
i18n: {
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- REMOVE_REPOSITORY_MODAL_TEXT,
- REMOVE_REPOSITORY_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ SETTINGS_TEXT,
},
searchConfig: SORT_FIELDS,
apollo: {
@@ -144,8 +145,11 @@ export default {
}
return [];
},
+ itemsToBeDeleted() {
+ return this.itemToDelete?.id ? [this.itemToDelete] : [];
+ },
graphqlResource() {
- return this.config.isGroupPage ? 'group' : 'project';
+ return this.config.isGroupPage ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
queryVariables() {
return {
@@ -306,6 +310,13 @@ export default {
:docker-push-command="dockerPushCommand"
:docker-login-command="dockerLoginCommand"
/>
+ <gl-button
+ v-if="config.showContainerRegistrySettings"
+ v-gl-tooltip="$options.i18n.SETTINGS_TEXT"
+ icon="settings"
+ :href="config.cleanupPoliciesSettingsPath"
+ :aria-label="$options.i18n.SETTINGS_TEXT"
+ />
</template>
</registry-header>
<persisted-search
@@ -367,26 +378,13 @@ export default {
@end="mutationLoading = false"
>
<template #default="{ doDelete }">
- <gl-modal
+ <delete-modal
ref="deleteModal"
- size="sm"
- modal-id="delete-image-modal"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: __('Remove'),
- attributes: { variant: 'danger' },
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- @primary="doDelete"
+ :items-to-be-deleted="itemsToBeDeleted"
+ delete-image
+ @confirmDelete="doDelete"
@cancel="track('cancel_delete')"
- >
- <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
- <p>
- <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
- <template #title>
- <b>{{ itemToDelete.path }}</b>
- </template>
- </gl-sprintf>
- </p>
- </gl-modal>
+ />
</template>
</delete-image>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
index ffdaf9f2f17..751ab5180a1 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
@@ -1,5 +1,9 @@
import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
+export const getImageName = (image = {}) => {
+ return image.name || image.project?.path;
+};
+
export const timeTilRun = (time) => {
if (!time) return '';
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 45dc217b9e3..732d544816b 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,6 +1,7 @@
<script>
import {
GlAlert,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -8,8 +9,8 @@ import {
GlFormInputGroup,
GlModal,
GlModalDirective,
- GlSkeletonLoader,
GlSprintf,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__, n__, sprintf } from '~/locale';
import Api from '~/api';
@@ -24,13 +25,13 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency
export default {
components: {
GlAlert,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
GlFormGroup,
GlFormInputGroup,
GlModal,
- GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
@@ -38,8 +39,9 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
- inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache'],
+ inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache', 'settingsPath'],
i18n: {
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
@@ -50,12 +52,13 @@ export default {
'DependencyProxy|All items in the cache are scheduled for removal.',
),
clearCache: s__('DependencyProxy|Clear cache'),
+ settingsText: s__('DependencyProxy|Configure in settings'),
},
confirmClearCacheModal: 'confirm-clear-cache-modal',
modalButtons: {
primary: {
text: s__('DependencyProxy|Clear cache'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
secondary: {
text: __('Cancel'),
@@ -114,10 +117,13 @@ export default {
);
},
showDeleteDropdown() {
- return this.group.dependencyProxyManifests?.nodes.length > 0 && this.canClearCache;
+ return this.manifests?.length > 0 && this.canClearCache;
+ },
+ dependencyProxyImagePrefix() {
+ return this.group.dependencyProxyImagePrefix;
},
showDependencyProxyImagePrefix() {
- return this.group.dependencyProxyImagePrefix?.length > 0;
+ return this.dependencyProxyImagePrefix?.length > 0;
},
},
methods: {
@@ -167,8 +173,9 @@ export default {
{{ deleteCacheAlertMessage }}
</gl-alert>
<title-area :title="$options.i18n.pageTitle">
- <template v-if="showDeleteDropdown" #right-actions>
+ <template #right-actions>
<gl-dropdown
+ v-if="showDeleteDropdown"
icon="ellipsis_v"
text="More actions"
:text-sr-only="true"
@@ -181,6 +188,14 @@ export default {
>{{ $options.i18n.clearCache }}</gl-dropdown-item
>
</gl-dropdown>
+ <gl-button
+ v-if="canClearCache"
+ v-gl-tooltip="$options.i18n.settingsText"
+ icon="settings"
+ data-testid="settings-link"
+ :href="settingsPath"
+ :aria-label="$options.i18n.settingsText"
+ />
</template>
</title-area>
@@ -208,23 +223,21 @@ export default {
</template>
</gl-form-group>
- <gl-skeleton-loader v-if="$apollo.queries.group.loading" />
-
- <div v-else data-testid="main-area">
- <manifests-list
- v-if="manifests && manifests.length"
- :manifests="manifests"
- :pagination="pageInfo"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
- />
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :dependency-proxy-image-prefix="dependencyProxyImagePrefix"
+ :loading="$apollo.queries.group.loading"
+ :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/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
index 1bbd0c32dc4..254fd578cf1 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
@@ -3,11 +3,16 @@ import { GlIcon, GlSprintf } from '@gitlab/ui';
import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { s__ } from '~/locale';
+const SHORT_DIGEST_START_INDEX = 7;
+const SHORT_DIGEST_END_INDEX = 14;
+
export default {
name: 'ManifestRow',
components: {
+ ClipboardButton,
GlIcon,
GlSprintf,
ListItem,
@@ -18,13 +23,25 @@ export default {
type: Object,
required: true,
},
+ dependencyProxyImagePrefix: {
+ type: String,
+ default: '',
+ required: false,
+ },
},
computed: {
name() {
- return this.manifest?.imageName.split(':')[0];
+ if (this.containsDigestInImageName) {
+ return this.manifest?.imageName.split(':')[0];
+ }
+ return this.manifest?.imageName;
},
- version() {
- return this.manifest?.imageName.split(':')[1];
+ imageCopyText() {
+ const name = this.manifest?.imageName.replace(':sha256:', '@sha256:') ?? '';
+ return `${this.dependencyProxyImagePrefix}/${name}`;
+ },
+ containsDigestInImageName() {
+ return this.manifest?.imageName.includes(':sha256:');
},
isErrorStatus() {
return this.manifest?.status === MANIFEST_PENDING_DESTRUCTION_STATUS;
@@ -32,9 +49,16 @@ export default {
disabledRowStyle() {
return this.isErrorStatus ? 'gl-font-weight-normal gl-text-gray-500' : '';
},
+ shortDigest() {
+ // digest is in the format `sha256:995efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089`
+ // for short digest, remove sha256: from the string, and show only the first 7 char
+ return this.manifest.digest?.substring(SHORT_DIGEST_START_INDEX, SHORT_DIGEST_END_INDEX);
+ },
},
i18n: {
cachedAgoMessage: s__('DependencyProxy|Cached %{time}'),
+ copyImagePathTitle: s__('DependencyProxy|Copy image path'),
+ digestLabel: s__('DependencyProxy|Digest: %{shortDigest}'),
scheduledForDeletion: s__('DependencyProxy|Scheduled for deletion'),
},
};
@@ -44,9 +68,21 @@ export default {
<list-item :disabled="isErrorStatus">
<template #left-primary>
<span :class="disabledRowStyle">{{ name }}</span>
+ <clipboard-button
+ class="gl-ml-2"
+ :text="imageCopyText"
+ :title="$options.i18n.copyImagePathTitle"
+ category="tertiary"
+ />
</template>
<template #left-secondary>
- {{ version }}
+ <span data-testid="manifest-row-short-digest">
+ <gl-sprintf :message="$options.i18n.digestLabel">
+ <template #shortDigest>
+ {{ shortDigest }}
+ </template>
+ </gl-sprintf>
+ </span>
<span v-if="isErrorStatus" class="gl-ml-4" data-testid="status"
><gl-icon name="clock" /> {{ $options.i18n.scheduledForDeletion }}</span
>
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..9870841f1ff 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,16 @@ export default {
type: Object,
required: true,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: () => false,
+ },
+ dependencyProxyImagePrefix: {
+ type: String,
+ default: '',
+ required: false,
+ },
},
i18n: {
listTitle: s__('DependencyProxy|Image list'),
@@ -34,19 +45,27 @@ 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"
+ :dependency-proxy-image-prefix="dependencyProxyImagePrefix"
+ :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/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
index c1597625964..db0e596ba64 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -19,6 +19,7 @@ query getDependencyProxyDetails(
nodes {
id
createdAt
+ digest
imageName
status
}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
index 428d6d6cd75..74444d2c7ec 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -11,7 +11,7 @@ export const initDependencyProxyApp = () => {
if (!el) {
return null;
}
- const { groupPath, groupId, noManifestsIllustration, canClearCache } = el.dataset;
+ const { groupPath, groupId, noManifestsIllustration, canClearCache, settingsPath } = el.dataset;
return new Vue({
el,
apolloProvider,
@@ -20,6 +20,7 @@ export const initDependencyProxyApp = () => {
groupId,
noManifestsIllustration,
canClearCache: parseBoolean(canClearCache),
+ settingsPath,
},
render(createElement) {
return createElement(app);
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
index bafcd78ad5d..bff32a124bc 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
@@ -9,7 +9,7 @@ import {
TAG_LABEL,
} from '~/packages_and_registries/harbor_registry/constants/index';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue';
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
index 1323d347d10..8bc1ecba5fe 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
@@ -4,7 +4,7 @@ import TagsList from '~/packages_and_registries/harbor_registry/components/tags/
import { getHarborTags } from '~/rest_api';
import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { formatPagination } from '~/packages_and_registries/harbor_registry/utils';
export default {
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
index 931a99649cb..1d8cb0f1360 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -12,7 +12,7 @@ import {
dockerPushCommand,
dockerLoginCommand,
} from '~/packages_and_registries/harbor_registry/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
SORT_FIELDS,
CONNECTION_ERROR_TITLE,
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index fd099ee4e69..fdc58e4bd05 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -122,15 +122,15 @@ export default {
modal: {
packageDeletePrimaryAction: {
text: __('Delete'),
- attributes: [
- { variant: 'danger' },
- { category: 'primary' },
- { 'data-qa-selector': 'delete_modal_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ category: 'primary',
+ 'data-qa-selector': 'delete_modal_button',
+ },
},
fileDeletePrimaryAction: {
text: __('Delete'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
index 223f427ce0e..62c4f96eff7 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import {
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
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/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 0aeeb2c3d15..6ea1fff9ef0 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
index 7af3fc1c2db..05673215a66 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
@@ -6,7 +6,6 @@ export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __(
export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully');
export const DEFAULT_PAGE = 1;
-export const DEFAULT_PAGE_SIZE = 20;
export const GROUP_PAGE_TYPE = 'groups';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 7a452abdc26..122123f49cd 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -1,13 +1,13 @@
import Api from '~/api';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import {
FETCH_PACKAGES_LIST_ERROR_MESSAGE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
DEFAULT_PAGE,
- DEFAULT_PAGE_SIZE,
MISSING_DELETE_PATH_ERROR,
TERRAFORM_SEARCH_TYPE,
} from '../constants';
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 011a2668a8b..0c3494ea812 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,71 @@
<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,
- },
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() {
+ return this.itemsToBeDeleted.length === 1 ? this.itemsToBeDeleted[0] : null;
+ },
+ title() {
+ return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE;
+ },
+ packageDescription() {
+ return this.showRequestForwardingContent
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_MODAL_CONTENT;
+ },
+ packagesDescription() {
+ return this.showRequestForwardingContent
+ ? DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_PACKAGES_MODAL_DESCRIPTION;
+ },
+ 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' },
+ };
},
},
modal: {
- packagesDeletePrimaryAction: {
- text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
- },
cancelAction: {
text: __('Cancel'),
},
@@ -43,6 +75,9 @@ export default {
this.$refs.deleteModal.show();
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -51,12 +86,33 @@ 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>
+ <gl-sprintf v-if="itemToBeDeleted" :message="packageDescription">
+ <template v-if="showRequestForwardingContent" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ <template #version>
+ <strong>{{ itemToBeDeleted.version }}</strong>
+ </template>
+ <template #name>
+ <strong>{{ itemToBeDeleted.name }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="packagesDescription">
+ <template v-if="showRequestForwardingContent" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+
+ <template #count>
+ {{ itemsToBeDeleted.length }}
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
index 4510c7a7322..95b83d87792 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
@@ -13,6 +13,7 @@ import {
TRACKING_LABEL_CODE_INSTRUCTION,
TRACKING_LABEL_MAVEN_INSTALLATION,
MAVEN_HELP_PATH,
+ MAVEN_INSTALLATION_COMMAND,
} from '~/packages_and_registries/package_registry/constants';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -55,11 +56,6 @@ export default {
<version>${this.appVersion}</version>
</dependency>`;
},
-
- mavenInstallationCommand() {
- return `mvn dependency:get -Dartifact=${this.appGroup}:${this.appName}:${this.appVersion}`;
- },
-
mavenSetupXml() {
return `<repositories>
<repository>
@@ -135,6 +131,7 @@ export default {
{ value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') },
{ value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') },
],
+ MAVEN_INSTALLATION_COMMAND,
};
</script>
@@ -164,8 +161,9 @@ export default {
/>
<code-instruction
+ class="gl-w-20 gl-mt-5"
:label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
+ :instruction="$options.MAVEN_INSTALLATION_COMMAND"
:copy-text="s__('PackageRegistry|Copy Maven command')"
:tracking-action="$options.tracking.TRACKING_ACTION_COPY_MAVEN_COMMAND"
:tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
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 d982df4f984..482249bc252 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,19 +1,29 @@
<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';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
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,
+ GlAlert,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -25,49 +35,139 @@ export default {
required: false,
default: false,
},
- versions: {
- type: Array,
- required: true,
- default: () => [],
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
},
- pageInfo: {
- type: Object,
- required: true,
+ isMutationLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- isLoading: {
+ isRequestForwardingEnabled: {
type: Boolean,
required: false,
default: false,
},
+ packageId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
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: {
+ itemToBeDeleted() {
+ return this.itemsToBeDeleted.length === 1 ? this.itemsToBeDeleted[0] : null;
+ },
+ 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
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
+ : undefined;
+ return {
+ category,
+ };
+ },
+ versions() {
+ return this.packageVersions?.nodes ?? [];
},
},
methods: {
deleteItemsCanceled() {
- this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ }
+
this.itemsToBeDeleted = [];
},
deleteItemsConfirmation() {
this.$emit('delete', this.itemsToBeDeleted);
- this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ }
this.itemsToBeDeleted = [];
},
setItemsToBeDeleted(items) {
this.itemsToBeDeleted = items;
- this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (items.length === 1) {
+ this.track(REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ 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>
@@ -76,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
@@ -85,17 +188,15 @@ 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 }">
- <!-- `first` prop is used to decide whether to show the top border
- for the first element. We want to show the top border only when
- user has permission to bulk delete versions. -->
<version-row
- :first="canDestroy && first"
+ :first="first"
:package-entity="item"
:selected="isSelected(item)"
+ @delete="setItemsToBeDeleted([item])"
@select="selectItem(item)"
/>
</template>
@@ -104,6 +205,7 @@ export default {
<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/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
index fdc6e75c932..ea6ebb614f4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
@@ -28,6 +28,9 @@ export default {
},
},
computed: {
+ isPrivatePackage() {
+ return !this.packageEntity.publicPackage;
+ },
pypiPipCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `pip install ${this.packageEntity.name} --index-url ${this.packageEntity.pypiUrl}`;
@@ -75,7 +78,7 @@ password = <your personal access token>`;
:tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
:tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
/>
- <template #description>
+ <template v-if="isPrivatePackage" #description>
<gl-sprintf :message="$options.i18n.tokenText">
<template #link="{ content }">
<gl-link
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 9f8f6328970..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
@@ -1,5 +1,7 @@
<script>
import {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -13,6 +15,7 @@ import PublishMethod from '~/packages_and_registries/shared/components/publish_m
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -22,6 +25,8 @@ import {
export default {
name: 'PackageVersionRow',
components: {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -58,6 +63,7 @@ export default {
},
},
i18n: {
+ deletePackage: DELETE_PACKAGE_TEXT,
erroredPackageText: ERRORED_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warningText: WARNING_TEXT,
@@ -121,5 +127,20 @@ export default {
</gl-sprintf>
</span>
</template>
+
+ <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 data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
+ $options.i18n.deletePackage
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
index 0914c013108..b7e66d20e78 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
@@ -1,6 +1,6 @@
<script>
import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import {
DELETE_PACKAGE_ERROR_MESSAGE,
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 16f21bfe61d..4ec83a869b3 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -8,9 +8,10 @@ import {
GlTooltipDirective,
GlTruncate,
} from '@gitlab/ui';
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -21,7 +22,6 @@ import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/
import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
-import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -38,7 +38,6 @@ export default {
PackagePath,
PublishMethod,
ListItem,
- PackageIconAndName,
TimeagoTooltip,
},
directives: {
@@ -91,7 +90,7 @@ export default {
i18n: {
erroredPackageText: ERRORED_PACKAGE_TEXT,
createdAt: __('Created %{timestamp}'),
- deletePackage: s__('PackageRegistry|Delete package'),
+ deletePackage: DELETE_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warning: WARNING_TEXT,
moreActions: __('More actions'),
@@ -150,9 +149,7 @@ export default {
</gl-sprintf>
</div>
- <package-icon-and-name>
- {{ packageType }}
- </package-icon-and-name>
+ <span class="gl-ml-2" data-testid="package-type">&middot; {{ packageType }}</span>
<package-path
v-if="isGroupPage"
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 d979ae5c08c..b4276d69ed6 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -27,15 +27,8 @@ export const PACKAGE_TYPE_DEBIAN = 'DEBIAN';
export const PACKAGE_TYPE_HELM = 'HELM';
export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction';
-export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation';
export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation';
-export const TRACKING_LABEL_NPM_INSTALLATION = 'npm_installation';
-export const TRACKING_LABEL_NUGET_INSTALLATION = 'nuget_installation';
-export const TRACKING_LABEL_PYPI_INSTALLATION = 'pypi_installation';
-export const TRACKING_LABEL_COMPOSER_INSTALLATION = 'composer_installation';
-
-export const TRACKING_ACTION_INSTALLATION = 'installation';
-export const TRACKING_ACTION_REGISTRY_SETUP = 'registry_setup';
+export const MAVEN_INSTALLATION_COMMAND = 'mvn install';
export const TRACKING_ACTION_COPY_CONAN_COMMAND = 'copy_conan_command';
export const TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND = 'copy_conan_setup_command';
@@ -68,7 +61,6 @@ export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
export const TRACKING_LABEL_PACKAGE_ASSET = 'package_assets';
-export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
@@ -119,6 +111,14 @@ export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'
export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions';
export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions';
+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.',
);
@@ -126,7 +126,23 @@ 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 %{name} version %{version} 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');
export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
@@ -142,8 +158,6 @@ export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
export const PACKAGE_ERROR_STATUS = 'ERROR';
export const PACKAGE_DEFAULT_STATUS = 'DEFAULT';
-export const PACKAGE_HIDDEN_STATUS = 'HIDDEN';
-export const PACKAGE_PROCESSING_STATUS = 'PROCESSING';
export const NPM_PACKAGE_MANAGER = 'npm';
export const YARN_PACKAGE_MANAGER = 'yarn';
@@ -151,8 +165,6 @@ export const YARN_PACKAGE_MANAGER = 'yarn';
export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project';
export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
-export const PROJECT_RESOURCE_TYPE = 'project';
-export const GROUP_RESOURCE_TYPE = 'group';
export const GRAPHQL_PAGE_SIZE = 20;
export const LIST_KEY_NAME = 'name';
@@ -214,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_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
index 2d405f3e9cc..bcd90b7bee5 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -12,7 +12,7 @@ fragment PackageData on Package {
name
}
}
- pipelines(last: 1) {
+ pipelines(first: 1) {
nodes {
id
sha
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 109d535469b..984996b829a 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,6 @@
-query getPackageDetails(
- $id: PackagesPackageID!
- $first: Int
- $last: Int
- $after: String
- $before: String
-) {
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql"
+
+query getPackageDetails($id: PackagesPackageID!) {
package(id: $id) {
id
name
@@ -15,6 +11,7 @@ query getPackageDetails(
updatedAt
status
canDestroy
+ publicPackage
npmUrl
mavenUrl
conanUrl
@@ -28,6 +25,9 @@ query getPackageDetails(
path
name
fullPath
+ group {
+ ...GroupPackageSettings
+ }
}
tags(first: 10) {
nodes {
@@ -61,31 +61,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 4591c2eca87..6d4979ac785 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
@@ -2,6 +2,7 @@
import {
GlBadge,
GlButton,
+ GlLink,
GlModal,
GlModalDirective,
GlTooltipDirective,
@@ -10,7 +11,7 @@ import {
GlTabs,
GlSprintf,
} from '@gitlab/ui';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -37,6 +38,7 @@ import {
DELETE_PACKAGE_FILE_TRACKING_ACTION,
DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
@@ -44,6 +46,7 @@ import {
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
DELETE_MODAL_TITLE,
DELETE_MODAL_CONTENT,
@@ -54,6 +57,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 {
@@ -63,6 +67,7 @@ export default {
GlButton,
GlEmptyState,
GlModal,
+ GlLink,
GlTab,
GlTabs,
GlSprintf,
@@ -123,6 +128,11 @@ export default {
},
},
computed: {
+ deleteModalContent() {
+ return this.isRequestForwardingEnabled
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : this.deletePackageModalContent;
+ },
projectName() {
return this.packageEntity.project?.name;
},
@@ -135,7 +145,6 @@ export default {
queryVariables() {
return {
id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
- first: GRAPHQL_PAGE_SIZE,
};
},
packageFiles() {
@@ -147,9 +156,6 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
- isVersionsLoading() {
- return this.isLoading || this.versionsMutationLoading;
- },
packageFilesLoading() {
return this.isLoading || this.mutationLoading;
},
@@ -161,18 +167,24 @@ 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;
},
showFiles() {
return this.packageType !== PACKAGE_TYPE_COMPOSER;
},
+ groupSettings() {
+ return this.packageEntity.project?.group?.packageSettings ?? {};
+ },
+ isRequestForwardingEnabled() {
+ return this.groupSettings[`${this.packageType.toLowerCase()}PackageRequestsForwarding`];
+ },
showMetadata() {
return [
PACKAGE_TYPE_COMPOSER,
@@ -190,6 +202,17 @@ export default {
},
];
},
+ refetchVersionsQueryData() {
+ return [
+ {
+ query: getPackageVersionsQuery,
+ variables: {
+ id: this.queryVariables.id,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ },
+ ];
+ },
},
methods: {
formatSize(size) {
@@ -274,34 +297,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,
@@ -311,22 +306,25 @@ export default {
),
otherVersionsTabTitle: s__('PackageRegistry|Other versions'),
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
modal: {
packageDeletePrimaryAction: {
text: s__('PackageRegistry|Permanently delete'),
- attributes: [
- { variant: 'danger' },
- { category: 'primary' },
- { 'data-qa-selector': 'delete_modal_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ category: 'primary',
+ 'data-qa-selector': 'delete_modal_button',
+ },
},
fileDeletePrimaryAction: {
text: __('Delete'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
filesDeletePrimaryAction: {
text: s__('PackageRegistry|Permanently delete assets'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
@@ -403,12 +401,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 +414,11 @@ 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"
+ :is-request-forwarding-enabled="isRequestForwardingEnabled"
+ :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">
@@ -451,15 +448,23 @@ export default {
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
>
<template #modal-title>{{ $options.i18n.DELETE_MODAL_TITLE }}</template>
- <gl-sprintf :message="deletePackageModalContent">
- <template #version>
- <strong>{{ packageEntity.version }}</strong>
- </template>
+ <p>
+ <gl-sprintf :message="deleteModalContent">
+ <template v-if="isRequestForwardingEnabled" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{
+ content
+ }}</gl-link>
+ </template>
- <template #name>
- <strong>{{ packageEntity.name }}</strong>
- </template>
- </gl-sprintf>
+ <template #version>
+ <strong>{{ packageEntity.version }}</strong>
+ </template>
+
+ <template #name>
+ <strong>{{ packageEntity.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
</delete-packages>
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 31c76c95e45..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,12 +1,11 @@
<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+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';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
import {
- PROJECT_RESOURCE_TYPE,
- GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
EMPTY_LIST_HELP_URL,
@@ -20,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
GlSprintf,
@@ -28,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;
@@ -52,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,
@@ -64,7 +75,7 @@ export default {
};
},
graphqlResource() {
- return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
+ return this.isGroupPage ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
pageInfo() {
return this.packages?.pageInfo ?? {};
@@ -84,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 [
@@ -124,7 +135,7 @@ export default {
after: this.pageInfo?.endCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -137,7 +148,7 @@ export default {
before: this.pageInfo?.startCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -150,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,
@@ -160,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
@@ -171,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/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 36eb65c623b..4c25c0f97de 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -72,7 +72,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="packages-and-registries-group-settings">
<gl-alert v-if="alertMessage" variant="warning" class="gl-mt-4" @dismiss="dismissAlert">
{{ alertMessage }}
</gl-alert>
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 c93cd7f7d78..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.',
@@ -78,8 +81,8 @@ export const MAVEN_FORWARDING_FIELDS = {
// Parameters
-export const PACKAGES_DOCS_PATH = helpPagePath('user/packages/index');
-export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
-export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
-
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/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
index 11d8732426d..c13d49b5379 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
@@ -225,9 +225,6 @@ export default {
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
- <template #secondStrong="{ content }">
- <strong>{{ content }}</strong>
- </template>
</gl-sprintf>
</p>
<expiration-dropdown
@@ -264,9 +261,6 @@ export default {
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
- <template #secondStrong="{ content }">
- <strong>{{ content }}</strong>
- </template>
</gl-sprintf>
</p>
<expiration-dropdown
@@ -312,7 +306,7 @@ export default {
>
{{ __('Cancel') }}
</gl-button>
- <span class="gl-font-style-italic gl-text-gray-400">{{
+ <span class="gl-font-style-italic gl-text-gray-500">{{
$options.i18n.EXPIRATION_POLICY_FOOTER_NOTE
}}</span>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index f06e3a41bd0..0bbb501011a 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -64,7 +64,7 @@ export default {
</gl-form-select>
</div>
<template v-if="description" #description>
- <span data-testid="description" class="gl-text-gray-400">
+ <span data-testid="description" class="gl-text-gray-500">
{{ description }}
</span>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
index 3fbbfd75ffb..749650e1060 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
@@ -101,7 +101,7 @@ export default {
trim
/>
<template #description>
- <span data-testid="description" class="gl-text-gray-400">
+ <span data-testid="description" class="gl-text-gray-500">
<gl-sprintf :message="description">
<template #link="{ content }">
<gl-link :href="tagsRegexHelpPagePath">{{ content }}</gl-link>
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/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 2c1368262f2..4cc9cc190e8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -42,7 +42,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="packages-and-registries-project-settings">
<gl-alert
v-if="showAlert"
variant="success"
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..05616a0a4f6 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}`,
);
@@ -29,7 +29,7 @@ export const TEXT_AREA_INVALID_FEEDBACK = s__(
export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags');
export const KEEP_INFO_TEXT = s__(
- 'ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept.',
+ 'ContainerRegistry|Tags that match %{strongStart}any of%{strongEnd} these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{strongStart}latest%{strongEnd} tag is always kept.',
);
export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:');
export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:');
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
deleted file mode 100644
index 105f7bbe132..00000000000
--- a/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
+++ /dev/null
@@ -1,17 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- name: 'PackageIconAndName',
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-align-items-center">
- <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
- <span><slot></slot></span>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index 7485f8282ee..1c8f80972df 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -125,7 +125,7 @@ export default {
:select-item="selectItem"
:is-selected="isSelected"
:item="item"
- :first="index === 0"
+ :first="!hiddenDelete && index === 0"
></slot>
</div>
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/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index ab29f9149f7..7634f131e4d 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,3 +1,4 @@
+import { initAbuseReportsApp } from '~/admin/abuse_reports';
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
@@ -6,3 +7,4 @@ new AbuseReports(); /* eslint-disable-line no-new */
new UsersSelect(); /* eslint-disable-line no-new */
initDeprecatedRemoveRowBehavior();
+initAbuseReportsApp();
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/show/index.js b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js
new file mode 100644
index 00000000000..13475f9560f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js
@@ -0,0 +1,3 @@
+import { initAbuseReportApp } from '~/admin/abuse_report';
+
+initAbuseReportApp();
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
index 96477b9f476..cde46c3da50 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -58,8 +58,6 @@ export default {
'emailRestrictions',
'afterSignUpText',
'pendingUserCount',
- 'projectSharingHelpLink',
- 'groupSharingHelpLink',
],
data() {
return {
@@ -83,8 +81,6 @@ export default {
supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl,
emailRestrictions: this.emailRestrictions,
afterSignUpText: this.afterSignUpText,
- projectSharingHelpLink: this.projectSharingHelpLink,
- groupSharingHelpLink: this.groupSharingHelpLink,
},
};
},
@@ -207,6 +203,10 @@ export default {
emailConfirmationSettingsOffHelpText: s__(
'ApplicationSettings|New users can sign up without confirming their email address.',
),
+ emailConfirmationSettingsSoftLabel: s__('ApplicationSettings|Soft'),
+ emailConfirmationSettingsSoftHelpText: s__(
+ 'ApplicationSettings|Send a confirmation email during sign up. New users can log in immediately, but must confirm their email within three days.',
+ ),
emailConfirmationSettingsHardLabel: s__('ApplicationSettings|Hard'),
emailConfirmationSettingsHardHelpText: s__(
'ApplicationSettings|Send a confirmation email during sign up. New users must confirm their email address before they can log in.',
@@ -220,7 +220,7 @@ export default {
),
userCapLabel: s__('ApplicationSettings|User cap'),
userCapDescription: s__(
- 'ApplicationSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave blank for an unlimited user cap. If you change the user cap to unlimited, you must re-enable %{projectSharingLinkStart}project sharing%{projectSharingLinkEnd} and %{groupSharingLinkStart}group sharing%{groupSharingLinkEnd}.',
+ 'ApplicationSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave blank for unlimited.',
),
domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'),
domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign-ups'),
@@ -286,23 +286,29 @@ export default {
v-model="form.emailConfirmationSetting"
name="application_setting[email_confirmation_setting]"
>
- <gl-form-radio value="hard">
- {{ $options.i18n.emailConfirmationSettingsHardLabel }}
-
- <template #help> {{ $options.i18n.emailConfirmationSettingsHardHelpText }} </template>
- </gl-form-radio>
<gl-form-radio value="off">
{{ $options.i18n.emailConfirmationSettingsOffLabel }}
<template #help> {{ $options.i18n.emailConfirmationSettingsOffHelpText }} </template>
</gl-form-radio>
+
+ <gl-form-radio value="soft">
+ {{ $options.i18n.emailConfirmationSettingsSoftLabel }}
+
+ <template #help> {{ $options.i18n.emailConfirmationSettingsSoftHelpText }} </template>
+ </gl-form-radio>
+
+ <gl-form-radio value="hard">
+ {{ $options.i18n.emailConfirmationSettingsHardLabel }}
+
+ <template #help> {{ $options.i18n.emailConfirmationSettingsHardHelpText }} </template>
+ </gl-form-radio>
</gl-form-radio-group>
</gl-form-group>
<gl-form-group
:label="$options.i18n.userCapLabel"
:description="$options.i18n.userCapDescription"
- data-testid="user-cap-form-group"
>
<gl-form-input
v-model="form.userCap"
@@ -310,17 +316,6 @@ export default {
name="application_setting[new_user_signups_cap]"
data-testid="user-cap-input"
/>
-
- <template #description>
- <gl-sprintf :message="$options.i18n.userCapDescription">
- <template #projectSharingLink="{ content }">
- <gl-link :href="projectSharingHelpLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #groupSharingLink="{ content }">
- <gl-link :href="groupSharingHelpLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
</gl-form-group>
<gl-form-group :label="$options.i18n.minimumPasswordLengthLabel">
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 366be334e87..6c38615c16c 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,10 +1,8 @@
import initVariableList from '~/ci/ci_variable_list';
import initSearchSettings from '~/search_settings';
-import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
initVariableList('js-instance-variables');
-selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
initSearchSettings();
diff --git a/app/assets/javascripts/pages/admin/application_settings/network/index.js b/app/assets/javascripts/pages/admin/application_settings/network/index.js
new file mode 100644
index 00000000000..841c68c5cd0
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/network/index.js
@@ -0,0 +1,3 @@
+import initNetworkOutbound from '~/admin/application_settings/network_outbound';
+
+initNetworkOutbound();
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
index 97fb64f9971..54c1e37d899 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 1cd19fc09a8..41862789185 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
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 d5857294617..b2c5326fefd 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
@@ -1,8 +1,8 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import {
CANCEL_TEXT,
CANCEL_JOBS_FAILED_TEXT,
@@ -31,7 +31,7 @@ export default {
.post(this.url)
.then((response) => {
// follow the rediect to refresh the page
- redirectTo(response.request.responseURL);
+ redirectTo(response.request.responseURL); // eslint-disable-line import/no-deprecated
})
.catch((error) => {
createAlert({
@@ -43,7 +43,7 @@ export default {
},
primaryAction: {
text: PRIMARY_ACTION_TEXT,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
cancelAction: {
text: CANCEL_TEXT,
diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js
new file mode 100644
index 00000000000..8c4ea2cde92
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js
@@ -0,0 +1,29 @@
+import { s__, __ } from '~/locale';
+import { DEFAULT_FIELDS, RAW_TEXT_WARNING } from '~/jobs/components/table/constants';
+
+export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.');
+export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.');
+export const LOADING_ARIA_LABEL = __('Loading');
+export const CANCELABLE_JOBS_ERROR_MSG = __('There was an error fetching the cancelable jobs.');
+export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
+export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
+export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs');
+export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs');
+export const CANCEL_TEXT = __('Cancel');
+export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed');
+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?",
+);
+export const RUNNER_EMPTY_TEXT = __('None');
+export const RUNNER_NO_DESCRIPTION = s__('Runners|No description');
+
+/* 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),
+];
+
+export const RAW_TEXT_WARNING_ADMIN = RAW_TEXT_WARNING;
diff --git a/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue b/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue
new file mode 100644
index 00000000000..c305e09af0d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <gl-skeleton-loader :width="1248" :height="73">
+ <circle cx="748.031" cy="37.7193" r="15.0307" />
+ <circle cx="787.241" cy="37.7193" r="15.0307" />
+ <circle cx="827.759" cy="37.7193" r="15.0307" />
+ <circle cx="866.969" cy="37.7193" r="15.0307" />
+ <circle cx="380" cy="37" r="18" />
+ <rect x="432" y="19" width="126.587" height="15" />
+ <rect x="432" y="41" width="247" height="15" />
+ <rect x="158" y="19" width="86.1" height="15" />
+ <rect x="158" y="41" width="168" height="15" />
+ <rect x="22" y="19" width="96" height="36" />
+ <rect x="924" y="30" width="96" height="15" />
+ <rect x="1057" y="20" width="166" height="35" />
+ </gl-skeleton-loader>
+</template>
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..daa4119f44d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
@@ -0,0 +1,259 @@
+<script>
+import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
+import { setUrlParams, updateHistory, 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 JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue';
+import { createAlert } from '~/alert';
+import JobsSkeletonLoader from '../jobs_skeleton_loader.vue';
+import {
+ DEFAULT_FIELDS_ADMIN,
+ RAW_TEXT_WARNING_ADMIN,
+ JOBS_COUNT_ERROR_MESSAGE,
+ JOBS_FETCH_ERROR_MSG,
+ LOADING_ARIA_LABEL,
+ CANCELABLE_JOBS_ERROR_MSG,
+} from '../constants';
+import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql';
+import GetAllJobsCount from './graphql/queries/get_all_jobs_count.query.graphql';
+import CancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql';
+
+export default {
+ i18n: {
+ jobsCountErrorMsg: JOBS_COUNT_ERROR_MESSAGE,
+ jobsFetchErrorMsg: JOBS_FETCH_ERROR_MSG,
+ loadingAriaLabel: LOADING_ARIA_LABEL,
+ cancelableJobsErrorMsg: CANCELABLE_JOBS_ERROR_MSG,
+ },
+ filterSearchBoxStyles:
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100',
+ components: {
+ JobsSkeletonLoader,
+ JobsTableEmptyState,
+ GlAlert,
+ JobsFilteredSearch,
+ JobsTable,
+ JobsTableTabs,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ },
+ inject: {
+ jobStatuses: {
+ default: null,
+ required: false,
+ },
+ url: {
+ default: '',
+ required: false,
+ },
+ emptyStateSvgPath: {
+ default: '',
+ required: false,
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetAllJobs,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data || {};
+ return {
+ list,
+ pageInfo,
+ };
+ },
+ error() {
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ jobsCount: {
+ query: GetAllJobsCount,
+ update(data) {
+ return data?.jobs?.count || 0;
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ error() {
+ this.error = this.$options.i18n.jobsCountErrorMsg;
+ },
+ },
+ cancelable: {
+ query: CancelableJobs,
+ update(data) {
+ this.isCancelable = data.cancelable.count !== 0;
+ },
+ error() {
+ this.error = this.$options.i18n.cancelableJobsErrorMsg;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ list: [],
+ },
+ error: '',
+ count: 0,
+ scope: null,
+ infiniteScrollingTriggered: false,
+ filterSearchTriggered: false,
+ DEFAULT_FIELDS_ADMIN,
+ isCancelable: false,
+ jobsCount: null,
+ };
+ },
+ 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
+ );
+ },
+ hasNextPage() {
+ return this.jobs?.pageInfo?.hasNextPage;
+ },
+ variables() {
+ return { ...this.validatedQueryString };
+ },
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
+ showFilteredSearch() {
+ return !this.scope;
+ },
+ showLoadingSpinner() {
+ return this.loading && this.infiniteScrollingTriggered;
+ },
+ showSkeletonLoader() {
+ return this.loading && !this.showLoadingSpinner;
+ },
+ },
+ 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;
+ },
+ },
+ methods: {
+ updateHistoryAndFetchCount(status = null) {
+ this.$apollo.queries.jobsCount.refetch({ statuses: status });
+
+ updateHistory({
+ url: setUrlParams({ statuses: status }, window.location.href, true),
+ });
+ },
+ fetchJobsByStatus(scope) {
+ this.infiniteScrollingTriggered = false;
+
+ if (this.scope === scope) return;
+
+ this.scope = scope;
+
+ if (!this.scope) this.updateHistoryAndFetchCount();
+
+ this.$apollo.queries.jobs.refetch({ statuses: scope });
+ },
+ fetchMoreJobs() {
+ if (!this.loading) {
+ this.infiniteScrollingTriggered = true;
+
+ const parameters = this.variables;
+ parameters.after = this.jobs?.pageInfo?.endCursor;
+
+ this.$apollo.queries.jobs.fetchMore({
+ variables: parameters,
+ });
+ }
+ },
+ filterJobsBySearch(filters) {
+ this.infiniteScrollingTriggered = false;
+ this.filterSearchTriggered = true;
+
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ this.updateHistoryAndFetchCount();
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+
+ return;
+ }
+
+ // Eventually there will be more tokens available
+ // this code is written to scale for those tokens
+ filters.forEach((filter) => {
+ // Raw text input in filtered search does not have a type
+ // when a user enters raw text we alert them that it is
+ // not supported and we do not make an additional API call
+ if (!filter.type) {
+ createAlert({
+ message: RAW_TEXT_WARNING_ADMIN,
+ type: 'warning',
+ });
+ }
+
+ if (filter.type === 'status') {
+ this.updateHistoryAndFetchCount(filter.value.data);
+ this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
+ }
+ });
+ },
+ },
+};
+</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"
+ :show-cancel-all-jobs-button="isCancelable"
+ @fetchJobsByStatus="fetchJobsByStatus"
+ />
+
+ <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles">
+ <jobs-filtered-search
+ :query-string="validatedQueryString"
+ @filterJobsBySearch="filterJobsBySearch"
+ />
+ </div>
+
+ <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" />
+
+ <jobs-table-empty-state v-else-if="showEmptyState" />
+
+ <jobs-table
+ v-else
+ :jobs="jobs.list"
+ :table-fields="DEFAULT_FIELDS_ADMIN"
+ admin
+ class="gl-table-no-top-border"
+ />
+
+ <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
+ <gl-loading-icon
+ v-if="showLoadingSpinner"
+ size="lg"
+ :aria-label="$options.i18n.loadingAriaLabel"
+ />
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue b/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue
new file mode 100644
index 00000000000..cbb80a5175f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ projectName() {
+ return this.job.pipeline?.project?.fullPath;
+ },
+ projectUrl() {
+ return this.job.pipeline?.project?.webUrl;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-text-truncate">
+ <gl-link :href="projectUrl"> {{ projectName }}</gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue b/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue
new file mode 100644
index 00000000000..33bcee5b34b
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '~/pages/admin/jobs/components/constants';
+
+export default {
+ i18n: {
+ emptyRunnerText: RUNNER_EMPTY_TEXT,
+ noRunnerDescription: RUNNER_NO_DESCRIPTION,
+ },
+ components: {
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ adminUrl() {
+ return this.job.runner?.adminUrl;
+ },
+ description() {
+ return this.job.runner?.description
+ ? this.job.runner.description
+ : this.$options.i18n.noRunnerDescription;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-truncate">
+ <gl-link v-if="adminUrl" :href="adminUrl">
+ {{ description }}
+ </gl-link>
+ <span v-else data-testid="empty-runner-text"> {{ $options.i18n.emptyRunnerText }}</span>
+ </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..9e2795966e0
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
@@ -0,0 +1,85 @@
+query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ runner {
+ id
+ description
+ adminUrl
+ }
+ 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/components/table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql
new file mode 100644
index 00000000000..8c59230b2b8
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql
@@ -0,0 +1,5 @@
+query getAllJobsCount($statuses: [CiJobStatus!]) {
+ jobs(statuses: $statuses) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql
new file mode 100644
index 00000000000..9b90abebbf7
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql
@@ -0,0 +1,5 @@
+query canelableJobs {
+ cancelable: jobs(statuses: [PENDING, RUNNING]) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
deleted file mode 100644
index cfde1fc0a2b..00000000000
--- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { s__, __ } from '~/locale';
-
-export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
-export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
-export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs');
-export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs');
-export const CANCEL_TEXT = __('Cancel');
-export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed');
-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?",
-);
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/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index 48241a213ef..3a91f8e2c55 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -72,7 +72,7 @@ export default {
primaryProps() {
return {
text: __('Delete project'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.canSubmit }],
+ attributes: { variant: 'danger', category: 'primary', disabled: !this.canSubmit },
};
},
},
diff --git a/app/assets/javascripts/pages/admin/runners/register/index.js b/app/assets/javascripts/pages/admin/runners/register/index.js
new file mode 100644
index 00000000000..d7ee2ee369a
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initAdminRegisterRunner } from '~/ci/runner/admin_register_runner';
+
+initAdminRegisterRunner();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 2fdf3c42935..f57b6144b69 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import { getGroups } from '~/api/groups_api';
import { getProjects } from '~/api/projects_api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/groups/achievements/index.js b/app/assets/javascripts/pages/groups/achievements/index.js
new file mode 100644
index 00000000000..d964b0feb96
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/achievements/index.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
+import createDefaultClient from '~/lib/graphql';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AchievementsApp from '~/achievements/components/achievements_app.vue';
+import routes from '~/achievements/routes';
+
+Vue.use(VueApollo);
+Vue.use(VueRouter);
+
+const init = () => {
+ const el = document.getElementById('js-achievements-app');
+
+ if (!el) {
+ return false;
+ }
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const { basePath, viewModel } = el.dataset;
+ const provide = JSON.parse(viewModel);
+
+ const router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ provide: convertObjectPropsToCamelCase(provide),
+ render(createElement) {
+ return createElement(AchievementsApp);
+ },
+ });
+};
+
+init();
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 f01e5e595a3..513f4968dbd 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -2,7 +2,7 @@
import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg';
import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
import createGroupDescriptionDetails from './create_group_description_details.vue';
@@ -11,6 +11,19 @@ export default {
NewNamespacePage,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ groupsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
parentGroupName: {
type: String,
required: false,
@@ -28,8 +41,17 @@ export default {
},
},
computed: {
- initialBreadcrumb() {
- return this.parentGroupName || __('New group');
+ initialBreadcrumbs() {
+ return this.parentGroupUrl
+ ? [
+ { text: this.parentGroupName, href: this.parentGroupUrl },
+ { 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: '#' },
+ ];
},
panels() {
return [
@@ -68,7 +90,7 @@ export default {
<template>
<new-namespace-page
:jump-to-last-persisted-panel="hasErrors"
- :initial-breadcrumb="initialBreadcrumb"
+ :initial-breadcrumbs="initialBreadcrumbs"
:panels="panels"
:title="s__('GroupsNew|Create new group')"
persistence-key="new_group_last_active_tab"
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index fa111032b2e..16f4f7b7f7e 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
import { getGroupPathAvailability } from '~/rest_api';
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index a555038ed5c..2e53324717c 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -22,14 +22,17 @@ initFilePickers();
function initNewGroupCreation(el) {
const {
hasErrors,
+ rootPath,
+ groupsUrl,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
- verificationRequired,
- verificationFormUrl,
- subscriptionsUrl,
} = el.dataset;
const props = {
+ groupsUrl,
+ rootPath,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
@@ -42,11 +45,6 @@ function initNewGroupCreation(el) {
return new Vue({
el,
apolloProvider,
- provide: {
- verificationRequired: parseBoolean(verificationRequired),
- verificationFormUrl,
- subscriptionsUrl,
- },
render(h) {
return h(NewGroupCreationApp, { props });
},
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/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 3dcababb4fd..582aee3c9a3 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -10,11 +10,12 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { getBulkImportsHistory } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
@@ -131,15 +132,15 @@ export default {
},
getPresentationUrl(item) {
- const suffix = item.entity_type === 'group' ? '/' : '';
+ const suffix = item.entity_type === WORKSPACE_GROUP ? '/' : '';
return `${item.destination_full_path}${suffix}`;
},
getEntityTooltip(item) {
switch (item.entity_type) {
- case 'project':
+ case WORKSPACE_PROJECT:
return __('Project');
- case 'group':
+ case WORKSPACE_GROUP:
return __('Group');
default:
return '';
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/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
index 30ee468734d..e9c98d0adb5 100644
--- a/app/assets/javascripts/pages/import/github/status/index.js
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -1,5 +1,12 @@
import mountImportProjectsTable from '~/import_entities/import_projects';
+import GithubStatusTable from '~/import_entities/import_projects/components/github_status_table.vue';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable({ mountElement });
+mountImportProjectsTable({
+ mountElement,
+ Component: GithubStatusTable,
+ extraProvide: (dataset) => ({
+ statusImportGithubGroupPath: dataset.statusImportGithubGroupPath,
+ }),
+});
diff --git a/app/assets/javascripts/pages/import/history/components/import_error_details.vue b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
index 6af137cd722..9c26804f73d 100644
--- a/app/assets/javascripts/pages/import/history/components/import_error_details.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import API from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DEFAULT_ERROR } from '../utils/error_messages';
export default {
diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
index 09b1b3a9c0f..938c2be89c5 100644
--- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getProjects } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
diff --git a/app/assets/javascripts/pages/import/phabricator/new/index.js b/app/assets/javascripts/pages/import/phabricator/new/index.js
deleted file mode 100644
index 0bb70a7364e..00000000000
--- a/app/assets/javascripts/pages/import/phabricator/new/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initNewProjectUrlSelect } from '~/projects/new';
-
-initNewProjectUrlSelect();
diff --git a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
index 3fe238dcb35..6e92f136e4d 100644
--- a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
+++ b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
@@ -1,3 +1,5 @@
+import { OAUTH_CALLBACK_MESSAGE_TYPE } from '~/jira_connect/subscriptions/constants';
+
function getOriginURL() {
const origin = new URL(window.opener.location);
origin.hash = '';
@@ -7,7 +9,10 @@ function getOriginURL() {
}
function postMessageToJiraConnectApp(data) {
- window.opener.postMessage(data, getOriginURL().toString());
+ window.opener.postMessage(
+ { ...data, type: OAUTH_CALLBACK_MESSAGE_TYPE },
+ getOriginURL().toString(),
+ );
}
function initOAuthCallbacks() {
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/index.js b/app/assets/javascripts/pages/profiles/index.js
index 91b20a05196..b576aab9291 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
+import LengthValidator from '~/validators/length_validator';
import initPasswordPrompt from './password_prompt';
import { initTimezoneDropdown } from './init_timezone_dropdown';
@@ -19,6 +20,7 @@ $(document).on('input.ssh_key', '#key_key', function () {
});
new Profile(); // eslint-disable-line no-new
+new LengthValidator(); // eslint-disable-line no-new
initSearchSettings();
initPasswordPrompt();
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
index 44728ea9cdf..7db94ea435e 100644
--- a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
+++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
@@ -33,7 +33,7 @@ export default {
primaryProps() {
return {
text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
- attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
+ attributes: { variant: 'danger', category: 'primary', disabled: !this.isValid },
};
},
},
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/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 96c4d0e0670..ea6bca644ed 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,4 +1,5 @@
import { mount2faRegistration } from '~/authentication/mount_2fa';
+import { initWebAuthnRegistration } from '~/authentication/webauthn/registration';
import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -15,6 +16,7 @@ if (skippable) {
}
mount2faRegistration();
+initWebAuthnRegistration();
initRecoveryCodes();
diff --git a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
deleted file mode 100644
index 1d7cf4a5b8e..00000000000
--- a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-import AirflowDags from '~/airflow/dags/components/dags.vue';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-
-const initShowDags = () => {
- const element = document.querySelector('#js-show-airflow-dags');
- if (!element) {
- return null;
- }
-
- const dags = JSON.parse(element.dataset.dags);
- const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination));
-
- return new Vue({
- el: element,
- render(h) {
- return h(AirflowDags, {
- props: {
- dags,
- pagination,
- },
- });
- },
- });
-};
-
-initShowDags();
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/blame/streaming/index.js b/app/assets/javascripts/pages/projects/blame/streaming/index.js
new file mode 100644
index 00000000000..82cc09c1e76
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blame/streaming/index.js
@@ -0,0 +1,5 @@
+import initBlob from '~/pages/projects/init_blob';
+import { renderBlamePageStreams } from '~/blame/streaming';
+
+renderBlamePageStreams(window.blamePageStream);
+initBlob();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index e45f9a10294..f8dcf1a5c9c 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -8,11 +8,16 @@ 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';
import '~/sourcegraph/load';
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);
@@ -26,8 +31,45 @@ const router = new VueRouter({ mode: 'history' });
const viewBlobEl = document.querySelector('#js-view-blob-app');
+const initRefSwitcher = () => {
+ const refSwitcherEl = document.getElementById('js-tree-ref-switcher');
+
+ if (!refSwitcherEl) return false;
+
+ const { projectId, projectRootPath, ref, refType } = refSwitcherEl.dataset;
+
+ return new Vue({
+ el: refSwitcherEl,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: refType ? joinPaths('refs', refType, ref) : ref,
+ useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef));
+ },
+ },
+ });
+ },
+ });
+};
+
+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({
@@ -38,6 +80,9 @@ if (viewBlobEl) {
provide: {
targetBranch,
originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
},
render(createElement) {
return createElement(BlobContentViewer, {
@@ -56,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,
+ createMrPath,
+ viewMrPath,
+ } = forkEl.dataset;
+ return new Vue({
+ el: forkEl,
+ apolloProvider,
+ render(h) {
+ return h(ForkInfo, {
+ props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ aheadComparePath,
+ behindComparePath,
+ createMrPath,
+ viewMrPath,
+ },
+ });
+ },
+ });
+};
+
+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/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
index bde0007ec6a..23f5b083589 100644
--- a/app/assets/javascripts/pages/projects/boards/index.js
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
-import UsersSelect from '~/users_select';
-new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initBoards();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 667fd89af55..c9f5895c7a3 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import initDeprecatedNotes from '~/init_deprecated_notes';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import axios from '~/lib/utils/axios_utils';
@@ -18,9 +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 + performanceHeight);
+initDiffStatsDropdown();
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..328b5596e1a 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();
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/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 85fe3477d7c..9f7a7b436df 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -12,10 +12,10 @@ import {
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__, __ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import {
@@ -261,7 +261,7 @@ export default {
try {
const { data } = await axios.post(url, postParams);
- redirectTo(data.web_url);
+ redirectTo(data.web_url); // eslint-disable-line import/no-deprecated
return;
} catch (error) {
createAlert({
@@ -332,7 +332,7 @@ export default {
</div>
</div>
- <p class="gl-mt-n5 gl-text-gray-500">
+ <p class="gl-mt-n3 gl-text-gray-500">
{{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }}
<gl-link :href="newGroupPath" target="_blank">
{{ s__('ForkProject|Create a group') }}
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
index 5e0c5735bc0..84796954cf1 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlCollapsibleListbox } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -97,7 +97,7 @@ export default {
:no-results-text="__('No matches found')"
:searchable="true"
:searching="loading"
- toggle-class="gl-flex-direction-column gl-align-items-stretch!"
+ toggle-class="gl-flex-direction-column gl-align-items-stretch! gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-w-full!"
:toggle-text="dropdownText"
@search="searchNamespaces"
@select="setNamespace"
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index 10bfcdc2294..b2e96471769 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlListbox, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -12,7 +12,7 @@ export default {
GlAlert,
GlAreaChart,
GlButton,
- GlListbox,
+ GlCollapsibleListbox,
GlSprintf,
},
props: {
@@ -98,7 +98,7 @@ export default {
mappedCoverages() {
return this.dailyCoverageData?.map((item, index) => ({
// A numerical index makes an item into a group header, so
- // convert these to strings to get non-header GlListbox items
+ // convert these to strings to get non-header GlCollapsibleListbox items
value: index.toString(),
text: item.group_name,
}));
@@ -182,7 +182,7 @@ export default {
{{ __('It seems that there is currently no available data for code coverage') }}
</span>
</gl-alert>
- <gl-listbox
+ <gl-collapsible-listbox
v-if="canShowData"
:items="mappedCoverages"
:selected="selectedCoverageIndex.toString()"
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index 097b2f33aa9..244d1d5590e 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -22,7 +22,6 @@ export default () => {
// eslint-disable-next-line no-new
new ShortcutsBlob({
- skipResetBindings: true,
fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
});
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 406959c80ea..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 '~/flash';
+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/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index af75c05b300..3ae8018714a 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -3,10 +3,9 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
-import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST);
+initBulkUpdateSidebar('merge_request_');
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration');
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/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js
index a8699b350f8..552e75da9b8 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/page.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/page.js
@@ -1,7 +1,10 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import initMrNotes from 'ee_else_ce/mr_notes';
import StickyHeader from '~/merge_requests/components/sticky_header.vue';
import { initIssuableHeaderWarnings } from '~/issuable';
-import initMrNotes from '~/mr_notes';
+import { start as startCodeReviewMessaging } from '~/code_review/signals';
+import diffsEventHub from '~/diffs/event_hub';
import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import { apolloProvider } from '~/graphql_shared/issuable_client';
@@ -9,9 +12,12 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import initShow from './init_merge_request_show';
import getStateQuery from './queries/get_state.query.graphql';
+Vue.use(VueApollo);
+
export function initMrPage() {
initMrNotes();
initShow();
+ startCodeReviewMessaging({ signalBus: diffsEventHub });
}
requestIdleCallback(() => {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 91394755367..38cc4337047 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,7 +1,7 @@
-import initNotesApp from '~/mr_notes/init_notes';
+import mountNotesApp from 'ee_else_ce/mr_notes/mount_app';
import { initReportAbuse } from '~/projects/report_abuse';
import { initMrPage } from '../page';
initMrPage();
-initNotesApp();
+mountNotesApp();
initReportAbuse();
diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
index fee6258eddc..9dc85cded0e 100644
--- a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
@@ -1,4 +1,4 @@
import { initSimpleApp } from '~/helpers/init_simple_app_helper';
-import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue';
+import MlCandidateShow from '~/ml/experiment_tracking/routes/candidates/show';
-initSimpleApp('#js-show-ml-candidate', MlCandidate);
+initSimpleApp('#js-show-ml-candidate', MlCandidateShow);
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
index e9ffd4b528b..b054022b6d6 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
@@ -8,9 +8,11 @@ const initIndexMlExperiments = () => {
return undefined;
}
+ const { experiments, pageInfo, emptyStateSvgPath } = element.dataset;
const props = {
- experiments: JSON.parse(element.dataset.experiments),
- pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)),
+ experiments: JSON.parse(experiments),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ emptyStateSvgPath,
};
return new Vue({
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 0e64d8c17db..b3f15a9f65e 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -1,32 +1,28 @@
import Vue from 'vue';
-import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue';
+import MlExperimentsShow from '~/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
const initShowExperiment = () => {
const element = document.querySelector('#js-show-ml-experiment');
if (!element) {
- return;
+ return undefined;
}
- const container = document.createElement('div');
- element.appendChild(container);
+ const { experiment, candidates, metrics, params, pageInfo, emptyStateSvgPath } = element.dataset;
- const candidates = JSON.parse(element.dataset.candidates);
- const metricNames = JSON.parse(element.dataset.metrics);
- const paramNames = JSON.parse(element.dataset.params);
- const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo));
+ const props = {
+ experiment: JSON.parse(experiment),
+ candidates: JSON.parse(candidates),
+ metricNames: JSON.parse(metrics),
+ paramNames: JSON.parse(params),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ emptyStateSvgPath,
+ };
- // eslint-disable-next-line no-new
- new Vue({
- el: container,
- provide: {
- candidates,
- metricNames,
- paramNames,
- pageInfo,
- },
+ return new Vue({
+ el: element,
render(h) {
- return h(MlExperiment);
+ return h(MlExperimentsShow, { props });
},
});
};
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 a4b3b83a855..c566490dcb5 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 '~/flash';
-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');
@@ -43,131 +25,16 @@ export default class Project {
$(this).parents('.auto-devops-implicitly-enabled-banner').remove();
return e.preventDefault();
});
+ $('.hide-mobile-devops-promo').on('click', function (e) {
+ const projectId = $(this).data('project-id');
+ const cookieKey = `hide_mobile_devops_promo_${projectId}`;
+ setCookie(cookieKey, 'false');
+ $(this).parents('#mobile-devops-promo-banner').remove();
+ return e.preventDefault();
+ });
}
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 if (params.group === TAG_GROUP_NAME) {
- 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/runners/new/index.js b/app/assets/javascripts/pages/projects/runners/new/index.js
new file mode 100644
index 00000000000..e67de424496
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initProjectNewRunner } from '~/ci/runner/project_new_runner';
+
+initProjectNewRunner();
diff --git a/app/assets/javascripts/pages/projects/runners/register/index.js b/app/assets/javascripts/pages/projects/runners/register/index.js
new file mode 100644
index 00000000000..a55ff95f84d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initProjectRegisterRunner } from '~/ci/runner/project_register_runner';
+
+initProjectRegisterRunner();
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 964c6ca9792..731b1373987 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -6,11 +6,13 @@ import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
+import initRefSwitcherBadges from '~/projects/settings/mount_ref_switcher_badges';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
import { initCiSecureFiles } from '~/ci_secure_files';
import initDeployTokens from '~/deploy_tokens';
import { initProjectRunners } from '~/ci/runner/project_runners';
+import { initProjectRunnersRegistrationDropdown } from '~/ci/runner/project_runners/register';
// Initialize expandable settings panels
initSettingsPanels();
@@ -41,7 +43,9 @@ initSettingsPipelinesTriggers();
initArtifactsSettings();
initProjectRunners();
+initProjectRunnersRegistrationDropdown();
initSharedRunnersToggle();
+initRefSwitcherBadges();
initInstallRunner();
initTokenAccess();
initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
index 380091a3501..f64de693188 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/form.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -10,8 +10,8 @@ import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
import initSettingsPanels from '~/settings_panels';
export default () => {
- new ProtectedTagCreate();
- new ProtectedTagEditList();
+ new ProtectedTagCreate({ hasLicense: false });
+ new ProtectedTagEditList({ hasLicense: false });
initDeployKeys();
initSettingsPanels();
new ProtectedBranchCreate({ hasLicense: false });
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/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index f2bc4796324..64c363dd721 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -65,7 +65,7 @@ export default {
releasesHelpText: s__(
'ProjectSettings|Combine git tags with release notes, release evidence, and assets to create a release.',
),
- securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
+ securityAndComplianceLabel: s__('ProjectSettings|Security and Compliance'),
snippetsLabel: s__('ProjectSettings|Snippets'),
wikiLabel: s__('ProjectSettings|Wiki'),
pucWarningLabel: s__('ProjectSettings|Warn about Potentially Unwanted Characters'),
@@ -825,7 +825,7 @@ export default {
</project-setting-row>
<project-setting-row
:label="$options.i18n.securityAndComplianceLabel"
- :help-text="s__('ProjectSettings|Security & Compliance for this project')"
+ :help-text="s__('ProjectSettings|Security and compliance for this project.')"
>
<project-feature-setting
v-model="securityAndComplianceAccessLevel"
@@ -930,7 +930,10 @@ export default {
name="project[project_feature_attributes][monitor_access_level]"
/>
</project-setting-row>
- <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
+ <div
+ v-if="!glFeatures.removeMonitorMetrics"
+ class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"
+ >
<project-setting-row
ref="metrics-visibility-settings"
:label="__('Metrics Dashboard')"
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
index 84ff802c268..43ff617dabe 100644
--- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
@@ -1,15 +1,7 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import WebIdeButton from '~/vue_shared/components/web_ide_link.vue';
-import createDefaultClient from '~/lib/graphql';
-
-Vue.use(VueApollo);
-
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
export default ({ el, router }) => {
if (!el) return;
@@ -23,7 +15,6 @@ export default ({ el, router }) => {
new Vue({
el,
router,
- apolloProvider,
render(h) {
return h(WebIdeButton, {
props: {
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/projects/wikis/diff/index.js b/app/assets/javascripts/pages/projects/wikis/diff/index.js
index 73440db761f..067ffb3dca9 100644
--- a/app/assets/javascripts/pages/projects/wikis/diff/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/diff/index.js
@@ -1,3 +1,7 @@
+import syntaxHighlight from '~/syntax_highlight';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
+import Diff from '~/diff';
+new Diff(); // eslint-disable-line no-new
initDiffStatsDropdown();
+syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index eaafc0235a8..84050c3cb0f 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -1,19 +1,17 @@
import { trackNewRegistrations } from '~/google_tag_manager';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
-import LengthValidator from '~/pages/sessions/new/length_validator';
+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 { initPasswordInput } from '~/authentication/password';
import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
new LengthValidator(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
-
-if (gon.features.trialEmailValidation) {
- new EmailFormatValidator(); // eslint-disable-line no-new
-}
+new EmailFormatValidator(); // eslint-disable-line no-new
trackNewRegistrations();
@@ -22,3 +20,4 @@ Tracking.enableFormTracking({
});
initLanguageSwitcher();
+initPasswordInput();
diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js
index 8d8534ec556..fdd846a9476 100644
--- a/app/assets/javascripts/pages/sessions/index.js
+++ b/app/assets/javascripts/pages/sessions/index.js
@@ -1,3 +1,5 @@
import { mount2faAuthentication } from '~/authentication/mount_2fa';
+import { initPasswordInput } from '~/authentication/password';
mount2faAuthentication();
+initPasswordInput();
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index a84ed5f01ad..a8b4dca0845 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import initVueAlerts from '~/vue_alerts';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import { initLanguageSwitcher } from '~/language_switcher';
-import LengthValidator from './length_validator';
+import LengthValidator from '~/validators/length_validator';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
import SigninTabsMemoizer from './signin_tabs_memoizer';
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/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index 1848aa70cf0..664909a9012 100644
--- a/app/assets/javascripts/pages/sessions/new/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index b19809aff53..8491d667213 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { handleLocationHash } from '~/lib/utils/common_utils';
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 0d2bbfbbc43..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,9 @@ 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"
@keydown.ctrl.enter="submitFormShortcut"
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 8d0105bc681..ec085eae199 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -16,6 +16,17 @@ export default class Wikis {
sidebarToggles[i].addEventListener('click', (e) => this.handleToggleSidebar(e));
}
+ const listToggles = document.querySelectorAll('.js-wiki-list-toggle');
+
+ listToggles.forEach((listToggle) => {
+ listToggle.querySelector('.js-wiki-list-expand-button')?.addEventListener('click', () => {
+ listToggle.classList.remove('collapsed');
+ });
+ listToggle.querySelector('.js-wiki-list-collapse-button')?.addEventListener('click', () => {
+ listToggle.classList.add('collapsed');
+ });
+ });
+
window.addEventListener('resize', () => this.renderSidebar());
this.renderSidebar();
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/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index fb761725c43..13bba06d425 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -1,7 +1,7 @@
import { select } from 'd3-selection';
import $ from 'jquery';
import { last } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import dateFormat from '~/lib/dateformat';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
@@ -58,7 +58,7 @@ export const getLevelFromContributions = (count) => {
};
export default class ActivityCalendar {
- constructor(
+ constructor({
container,
activitiesContainer,
timestamps,
@@ -66,7 +66,8 @@ export default class ActivityCalendar {
utcOffset = 0,
firstDayOfWeek = firstDayOfWeekChoices.sunday,
monthsAgo = 12,
- ) {
+ onClickDay,
+ }) {
this.calendarActivitiesPath = calendarActivitiesPath;
this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
@@ -91,6 +92,7 @@ export default class ActivityCalendar {
this.firstDayOfWeek = firstDayOfWeek;
this.activitiesContainer = activitiesContainer;
this.container = container;
+ this.onClickDay = onClickDay;
// Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are
@@ -152,7 +154,8 @@ export default class ActivityCalendar {
.append('svg')
.attr('width', width)
.attr('height', 169)
- .attr('class', 'contrib-calendar');
+ .attr('class', 'contrib-calendar')
+ .attr('data-testid', 'contrib-calendar');
}
dayYPos(day) {
@@ -181,6 +184,7 @@ export default class ActivityCalendar {
});
return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`;
})
+ .attr('data-testid', 'user-contrib-cell-group')
.selectAll('rect')
.data((stamp) => stamp)
.enter()
@@ -192,6 +196,7 @@ export default class ActivityCalendar {
.attr('data-level', (stamp) => getLevelFromContributions(stamp.count))
.attr('title', (stamp) => formatTooltipText(stamp))
.attr('class', 'user-contrib-cell has-tooltip')
+ .attr('data-testid', 'user-contrib-cell')
.attr('data-html', true)
.attr('data-container', 'body')
.on('click', this.clickDay);
@@ -281,6 +286,12 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
+ if (this.onClickDay) {
+ this.onClickDay(date);
+
+ return;
+ }
+
$(this.activitiesContainer)
.empty()
.append(loadingIconForLegacyJS({ size: 'lg' }));
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
index f1b4e00c810..c213753257d 100644
--- a/app/assets/javascripts/pages/users/show/index.js
+++ b/app/assets/javascripts/pages/users/show/index.js
@@ -1,16 +1,7 @@
-import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { initProfileTabs, initUserAchievements } from '~/profile';
-if (window.gon.features?.profileTabsVue) {
- import('~/profile')
- .then(({ initProfileTabs }) => {
- initProfileTabs();
- })
- .catch(() => {
- createAlert({
- message: s__(
- 'UserProfile|An error occurred loading the profile. Please refresh the page to try again.',
- ),
- });
- });
+if (gon.features?.profileTabsVue) {
+ initProfileTabs();
}
+
+initUserAchievements();
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 90eafa85886..430022f9a9b 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -247,15 +247,15 @@ export default class UserTabs {
$calendarWrap.find('.calendar-hint').text(calendarHint);
// eslint-disable-next-line no-new
- new ActivityCalendar(
- '.tab-pane.active .js-contrib-calendar',
- '.tab-pane.active .user-calendar-activities',
- data,
+ new ActivityCalendar({
+ container: '.tab-pane.active .js-contrib-calendar',
+ activitiesContainer: '.tab-pane.active .user-calendar-activities',
+ timestamps: data,
calendarActivitiesPath,
utcOffset,
- gon.first_day_of_week,
+ firstDayOfWeek: gon.first_day_of_week,
monthsAgo,
- );
+ });
}
toggleLoading(status) {
diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue
index 9ac6b0e6403..6702c49030b 100644
--- a/app/assets/javascripts/performance_bar/components/add_request.vue
+++ b/app/assets/javascripts/performance_bar/components/add_request.vue
@@ -1,7 +1,12 @@
<script>
import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: __('Add request manually'),
+ inputLabel: __('URL or request ID'),
+ },
components: {
GlForm,
GlButton,
@@ -37,14 +42,16 @@ export default {
variant="link"
icon="plus"
size="small"
- :title="__('Add request manually')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
@click="toggleInput"
/>
<gl-form-input
v-if="inputEnabled"
v-model="urlOrRequestId"
type="text"
- :placeholder="__(`URL or request ID`)"
+ :placeholder="$options.i18n.inputLabel"
+ :aria-label="$options.i18n.inputLabel"
class="gl-ml-2"
@keyup.enter="addRequest"
@keyup.esc="clearForm"
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_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 6ee33902a01..71dc8c3d020 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index e37f63d4053..3130fe42c3c 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -22,6 +22,8 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-ultimate-feature-removal-banner',
'.js-geo-enable-hashed-storage-callout',
'.js-geo-migrate-hashed-storage-callout',
+ '.js-unlimited-members-during-trial-alert',
+ '.js-branch-rules-info-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
index 0c063241173..c08d825e6af 100644
--- a/app/assets/javascripts/pipeline_wizard/components/editor.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -5,6 +5,7 @@ import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
export default {
name: 'YamlEditor',
@@ -43,11 +44,13 @@ export default {
},
},
mounted() {
- this.editor = new SourceEditor().createInstance({
- el: this.$el,
- blobPath: this.filename,
- language: 'yaml',
- });
+ this.editor = markRaw(
+ new SourceEditor().createInstance({
+ el: this.$el,
+ blobPath: this.filename,
+ language: 'yaml',
+ }),
+ );
[, this.yamlEditorExtension] = this.editor.use([
{ definition: SourceEditorExtension },
{
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/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 992e3d2f552..22895a31082 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -39,6 +39,9 @@ export default {
confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'),
i18n: {
bridgeBadgeText: __('Trigger job'),
+ bridgeRetryText: s__(
+ 'PipelineGraph|Downstream pipeline might not display in the graph while the new downstream pipeline is being created.',
+ ),
unauthorizedTooltip: __('You are not authorized to run this manual job'),
confirmationModal: {
title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'),
@@ -288,6 +291,10 @@ export default {
},
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
+
+ if (this.isBridge) {
+ this.$toast.show(this.$options.i18n.bridgeRetryText);
+ }
},
executePendingAction() {
this.shouldTriggerActionClick = true;
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 3da792cb9df..c888c8a5537 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,11 +1,12 @@
import { isEmpty } from 'lodash';
-import Visibility from 'visibilityjs';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, etagQueryHeaders } from '~/graphql_shared/utils';
import { reportToSentry } from '../../utils';
import { listByLayers } from '../parsing_utils';
import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils';
+export { toggleQueryPollingByVisibility } from '~/graphql_shared/utils';
+
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return {
...linkedPipeline,
@@ -35,23 +36,13 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => {
return layers;
};
-/* eslint-disable @gitlab/require-i18n-strings */
-const getQueryHeaders = (etagResource) => {
- return {
- fetchOptions: {
- method: 'GET',
- },
- headers: {
- 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
- 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
- 'X-Requested-With': 'XMLHttpRequest',
- },
- };
-};
+const getQueryHeaders = (etagResource) =>
+ etagQueryHeaders('verify/ci/pipeline-graph', etagResource);
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
return `
${message}.
Locations: ${locations
@@ -74,27 +65,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()) {
- query.startPolling(interval);
- } else {
- query.stopPolling();
- }
- };
-
- stopStartQuery(queryRef);
- Visibility.change(stopStartQuery.bind(null, queryRef));
-};
-
const transformId = (linkedPipeline) => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
@@ -135,7 +111,6 @@ export {
getQueryHeaders,
serializeGqlErr,
serializeLoadErrors,
- toggleQueryPollingByVisibility,
unwrapPipelineData,
validateConfigPaths,
};
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index a36d5d9b58f..27119419060 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -7,7 +7,7 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import {
@@ -16,6 +16,7 @@ import {
DELETE_FAILURE,
DEFAULT,
BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
} from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
@@ -29,6 +30,7 @@ const POLL_INTERVAL = 10000;
export default {
name: 'PipelineHeaderSection',
BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
pipelineCancel: 'pipelineCancel',
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
@@ -231,7 +233,7 @@ export default {
this.reportFailure(DELETE_FAILURE, errors);
this.isDeleting = false;
} else {
- redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success'));
+ redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated
}
} catch {
this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
@@ -275,6 +277,9 @@ export default {
<gl-button
v-if="canCancelPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
+ :title="$options.BUTTON_TOOLTIP_CANCEL"
:loading="isCanceling"
:disabled="isCanceling"
class="gl-ml-3"
@@ -282,7 +287,7 @@ export default {
data-testid="cancelPipeline"
@click="cancelPipeline()"
>
- {{ __('Cancel running') }}
+ {{ __('Cancel pipeline') }}
</gl-button>
<gl-button
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 605d40eddee..c24862f828b 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
@@ -1,10 +1,8 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql';
-import { prepareFailedJobs } from './utils';
import FailedJobsTable from './failed_jobs_table.vue';
export default {
@@ -13,38 +11,33 @@ export default {
FailedJobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
default: '',
},
},
- props: {
- failedJobsSummary: {
- type: Array,
- required: true,
- },
- },
apollo: {
failedJobs: {
query: GetFailedJobsQuery,
variables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
update({ project }) {
- if (project?.pipeline?.jobs?.nodes) {
- return project.pipeline.jobs.nodes.map((job) => {
- return { normalizedId: getIdFromGraphQLId(job.id), ...job };
- });
- }
- return [];
- },
- result() {
- this.preparedFailedJobs = prepareFailedJobs(this.failedJobs, this.failedJobsSummary);
+ const jobNodes = project?.pipeline?.jobs?.nodes || [];
+
+ return jobNodes.map((job) => {
+ return {
+ ...job,
+ // this field is needed for the slot row-details
+ // on the failed_jobs_table.vue component
+ _showDetails: true,
+ };
+ });
},
error() {
createAlert({ message: s__('Jobs|There was a problem fetching the failed jobs.') });
@@ -54,7 +47,6 @@ export default {
data() {
return {
failedJobs: [],
- preparedFailedJobs: [],
};
},
computed: {
@@ -68,6 +60,6 @@ export default {
<template>
<div>
<gl-loading-icon v-if="loading" size="lg" class="gl-mt-4" />
- <failed-jobs-table v-else :failed-jobs="preparedFailedJobs" />
+ <failed-jobs-table v-else :failed-jobs="failedJobs" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index 041b62e02ec..ec7000120f1 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -2,8 +2,8 @@
import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql';
import { DEFAULT_FIELDS } from '../../constants';
@@ -40,7 +40,7 @@ export default {
if (errors.length > 0) {
this.showErrorMessage();
} else {
- redirectTo(job.detailedStatus.detailsPath);
+ redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated
}
} catch {
this.showErrorMessage();
@@ -52,6 +52,9 @@ export default {
showErrorMessage() {
createAlert({ message: s__('Job|There was a problem retrying the failed job.') });
},
+ failureSummary(trace) {
+ return trace ? trace.htmlSummary : s__('Job|No job log');
+ },
},
};
</script>
@@ -90,8 +93,8 @@ export default {
</div>
</template>
- <template #cell(failure)="{ item }">
- <span>{{ item.failure }}</span>
+ <template #cell(failureMessage)="{ item }">
+ <span data-testid="job-failure-message">{{ item.failureMessage }}</span>
</template>
<template #cell(actions)="{ item }">
@@ -110,7 +113,7 @@ export default {
class="gl-w-full gl-text-left gl-border-none"
data-testid="job-log"
>
- <code v-safe-html="item.failureSummary" class="gl-reset-bg gl-p-0" >
+ <code v-safe-html="failureSummary(item.trace)" class="gl-reset-bg gl-p-0" data-testid="job-trace-summary">
</code>
</pre>
</template>
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index f1ad312dcaa..61748860983 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import eventHub from '~/jobs/components/table/event_hub';
import JobsTable from '~/jobs/components/table/jobs_table.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/jobs/utils.js b/app/assets/javascripts/pipelines/components/jobs/utils.js
deleted file mode 100644
index c8414d44d14..00000000000
--- a/app/assets/javascripts/pipelines/components/jobs/utils.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- We get the failure and failure summary from Rails which has
- a summary failure log. Here we combine that data with the data
- from GraphQL to display the log.
-
- failedJobs is from GraphQL
- failedJobsSummary is from Rails
- */
-
-export const prepareFailedJobs = (failedJobs = [], failedJobsSummary = []) => {
- const combinedJobs = [];
-
- if (failedJobs.length > 0 && failedJobsSummary.length > 0) {
- failedJobs.forEach((failedJob) => {
- const foundJob = failedJobsSummary.find(
- (failedJobSummary) => failedJob.normalizedId === failedJobSummary.id,
- );
-
- if (foundJob) {
- combinedJobs.push({
- ...failedJob,
- failure: foundJob?.failure,
- failureSummary: foundJob?.failure_summary,
- // this field is needed for the slot row-details
- // on the failed_jobs_table.vue component
- _showDetails: true,
- });
- }
- });
- }
-
- return combinedJobs;
-};
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 7020bfc1e65..ffb6ab71b22 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { dasherize } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
index ec42b738e03..936cd6f0be5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
@@ -14,7 +14,7 @@
import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index 3798863ae60..d2ec3c352fe 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -31,13 +31,7 @@ export default {
GlTab,
GlTabs,
},
- inject: [
- 'defaultTabValue',
- 'failedJobsCount',
- 'failedJobsSummary',
- 'totalJobCount',
- 'testsCount',
- ],
+ inject: ['defaultTabValue', 'failedJobsCount', 'totalJobCount', 'testsCount'],
data() {
return {
activeTab: this.defaultTabValue,
@@ -110,7 +104,7 @@ export default {
<span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span>
<gl-badge size="sm" data-testid="failed-builds-counter">{{ failedJobsCount }}</gl-badge>
</template>
- <router-view :failed-jobs-summary="failedJobsSummary" />
+ <router-view />
</gl-tab>
<gl-tab
:active="isActive($options.tabNames.tests)"
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_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index eb70b5fbb7a..9f38be668f2 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -42,7 +42,7 @@ export default {
primaryProps() {
return {
text: s__('Pipeline|Stop pipeline'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
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 4111823e0bb..bcbf655a737 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { isEqual } from 'lodash';
-import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -189,6 +189,10 @@ export default {
);
},
+ shouldRenderPagination() {
+ return !this.isLoading && !this.hasError;
+ },
+
emptyTabMessage() {
if (this.scope === this.$options.scopes.finished) {
return s__('Pipelines|There are currently no finished pipelines.');
@@ -346,7 +350,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"
@@ -381,10 +385,8 @@ export default {
<gl-empty-state
v-else-if="stateToRender === $options.stateMap.error"
:svg-path="errorStateSvgPath"
- :title="
- s__(`Pipelines|There was an error fetching the pipelines.
- Try again in a few moments or contact your support team.`)
- "
+ :title="s__('Pipelines|There was an error fetching the pipelines.')"
+ :description="s__('Pipelines|Try again in a few moments or contact your support team.')"
/>
<gl-empty-state
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 f34b3f56c5b..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,6 +1,6 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+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';
import { s__, __, sprintf } from '~/locale';
@@ -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..d068eb16ed4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -20,7 +20,7 @@ export default {
return this.pipeline?.details?.duration;
},
durationFormatted() {
- return durationTimeFormatted(this.duration);
+ return formatTime(this.duration * 1000);
},
finishedTime() {
return this.pipeline?.details?.finished_at;
@@ -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/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index b57d0ac1fd7..81f46d5f2f9 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -2,7 +2,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
export default {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
index 5846a1f6ed9..b32f5de2d7e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
@@ -2,7 +2,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
export default {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index 73f7d3f52c3..a89354c671a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -8,7 +8,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
ANY_TRIGGER_AUTHOR,
FETCH_AUTHOR_ERROR_MESSAGE,
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
index 2d1f1945e5a..10db3e1c56b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -66,7 +66,7 @@ export default {
},
modalCloseButton: {
text: __('Close'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
};
</script>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 1cd28e027f3..2974bd2dd37 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -100,7 +100,7 @@ export default {
{{ __('Duration') }}
</div>
<div role="rowheader" class="table-section section-10">
- {{ __('Details'), }}
+ {{ __('Details') }}
</div>
</div>
@@ -162,7 +162,7 @@ export default {
</div>
<div class="table-section section-10 section-wrap">
- <div role="rowheader" class="table-mobile-header">{{ __('Details'), }}</div>
+ <div role="rowheader" class="table-mobile-header">{{ __('Details') }}</div>
<div class="table-mobile-content">
<gl-button v-gl-modal-directive="`test-case-details-${index}`">{{
__('View details')
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 7ab48da1a9d..2b7b2d78424 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -50,13 +50,13 @@ export default {
{{ __('Failed') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Errors'), }}
+ {{ __('Errors') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Skipped'), }}
+ {{ __('Skipped') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Passed'), }}
+ {{ __('Passed') }}
</div>
<div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right">
{{ __('Total') }}
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 820501089ed..d092c3ca630 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
-export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source'];
@@ -35,8 +34,6 @@ export const RAW_TEXT_WARNING = s__(
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
-export const EMPTY_PIPELINE_DATA = 'empty_data';
-export const INVALID_CI_CONFIG = 'invalid_ci_config';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
@@ -82,7 +79,7 @@ export const PipelineKeyOptions = [
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
-export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
+export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline');
export const DEFAULT_FIELDS = [
{
@@ -96,7 +93,7 @@ export const DEFAULT_FIELDS = [
columnClass: 'gl-w-20p',
},
{
- key: 'failure',
+ key: 'failureMessage',
label: __('Failure'),
columnClass: 'gl-w-40p',
},
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
index 14e9a838f4b..5bdafa15f72 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
@@ -3,7 +3,7 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
id
pipeline(iid: $pipelineIid) {
id
- jobs(statuses: FAILED) {
+ jobs(statuses: FAILED, retried: false) {
nodes {
status
detailedStatus {
@@ -34,6 +34,10 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
readBuild
updateBuild
}
+ trace {
+ htmlSummary
+ }
+ failureMessage
}
}
}
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/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index e6770b71113..481953608e9 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index ba51347ad69..61847affa1f 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,5 +1,5 @@
import VueRouter from 'vue-router';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { pipelineTabName } from './constants';
import { createPipelineHeaderApp } from './pipeline_details_header';
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 6360ccc41bc..33bdedee764 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -2,11 +2,13 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import createTestReportsStore from './stores/test_reports';
import { getPipelineDefaultTab, reportToSentry } from './utils';
+Vue.use(GlToast);
Vue.use(VueApollo);
Vue.use(VueRouter);
Vue.use(Vuex);
@@ -26,14 +28,12 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeSecurityDashboard,
exposeLicenseScanningData,
failedJobsCount,
- failedJobsSummary,
- fullPath,
+ projectPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
totalJobCount,
licenseManagementApiUrl,
- licenseManagementSettingsPath,
licenseScanCount,
licensesApiPath,
canManageLicenses,
@@ -45,11 +45,10 @@ export const createAppOptions = (selector, apolloProvider, router) => {
emptyStateImagePath,
artifactsExpiredImagePath,
isFullCodequalityReportAvailable,
+ securityPoliciesPath,
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 {
@@ -80,14 +79,11 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard),
exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData),
failedJobsCount,
- failedJobsSummary: JSON.parse(failedJobsSummary),
- fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
totalJobCount,
licenseManagementApiUrl,
- licenseManagementSettingsPath,
licenseScanCount,
licensesApiPath,
canManageLicenses: parseBoolean(canManageLicenses),
@@ -98,6 +94,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
emptyDagSvgPath,
emptyStateImagePath,
artifactsExpiredImagePath,
+ securityPoliciesPath,
testsCount,
},
errorCaptured(err, _vm, info) {
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/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index c77b4813e33..1b51bb804d0 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
index bff30acfe36..466574157f5 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 3cb2dce87d3..c64fbc91d12 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -38,11 +38,12 @@ export default {
primaryProps() {
return {
text: __('Delete account'),
- attributes: [
- { variant: 'danger', 'data-qa-selector': 'confirm_delete_account_button' },
- { category: 'primary' },
- { disabled: !this.canSubmit },
- ],
+ attributes: {
+ variant: 'danger',
+ 'data-qa-selector': 'confirm_delete_account_button',
+ category: 'primary',
+ disabled: !this.canSubmit,
+ },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 51e62984715..d96b5748abc 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -2,7 +2,7 @@
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { escape } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, s__, sprintf } from '~/locale';
@@ -60,11 +60,7 @@ Please update your Git repository remotes as soon as possible.`),
primaryProps() {
return {
text: __('Update username'),
- attributes: [
- { variant: 'confirm' },
- { category: 'primary' },
- { disabled: this.isRequestPending },
- ],
+ attributes: { variant: 'confirm', category: 'primary', disabled: this.isRequestPending },
};
},
cancelProps() {
@@ -117,6 +113,7 @@ Please update your Git repository remotes as soon as possible.`),
<input
:id="$options.inputId"
v-model="newUsername"
+ data-testid="new-username-input"
:disabled="isRequestPending"
class="form-control"
required="required"
diff --git a/app/assets/javascripts/profile/components/activity_calendar.vue b/app/assets/javascripts/profile/components/activity_calendar.vue
new file mode 100644
index 00000000000..d359b478d35
--- /dev/null
+++ b/app/assets/javascripts/profile/components/activity_calendar.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { debounce } from 'lodash';
+
+import { __ } from '~/locale';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import ActivityCalendar from '~/pages/users/activity_calendar';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { getVisibleCalendarPeriod } from '../utils';
+
+export default {
+ i18n: {
+ errorAlertTitle: __('There was an error loading users activity calendar.'),
+ retry: __('Retry'),
+ calendarHint: __('Issues, merge requests, pushes, and comments.'),
+ },
+ components: { GlLoadingIcon, GlAlert },
+ inject: ['userCalendarPath', 'utcOffset'],
+ data() {
+ return {
+ isLoading: true,
+ showCalendar: true,
+ hasError: false,
+ };
+ },
+ mounted() {
+ this.renderActivityCalendar();
+ window.addEventListener('resize', this.handleResize);
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.handleResize);
+ },
+ methods: {
+ async renderActivityCalendar() {
+ if (bp.getBreakpointSize() === 'xs') {
+ this.showCalendar = false;
+
+ return;
+ }
+
+ this.showCalendar = true;
+ this.isLoading = true;
+ this.hasError = false;
+
+ try {
+ const data = await AjaxCache.retrieve(this.userCalendarPath);
+
+ this.isLoading = false;
+
+ // Wait for `calendarContainer` to render
+ await this.$nextTick();
+ const monthsAgo = getVisibleCalendarPeriod(this.$refs.calendarContainer);
+
+ // eslint-disable-next-line no-new
+ new ActivityCalendar({
+ container: this.$refs.calendarSvgContainer,
+ timestamps: data,
+ utcOffset: this.utcOffset,
+ firstDayOfWeek: gon.first_day_of_week,
+ monthsAgo,
+ onClickDay: this.handleClickDay,
+ });
+ } catch {
+ this.isLoading = false;
+ this.hasError = true;
+ }
+ },
+ handleResize: debounce(function debouncedHandleResize() {
+ this.renderActivityCalendar();
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ handleClickDay() {
+ // Render activities for specific day.
+ // Blocked by https://gitlab.com/gitlab-org/gitlab/-/issues/378695
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="showCalendar" ref="calendarContainer">
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-alert
+ v-else-if="hasError"
+ :title="$options.i18n.errorAlertTitle"
+ :dismissible="false"
+ variant="danger"
+ :primary-button-text="$options.i18n.retry"
+ @primaryAction="renderActivityCalendar"
+ />
+ <div v-else class="gl-text-center">
+ <div class="gl-display-inline-block gl-relative">
+ <div ref="calendarSvgContainer"></div>
+ <p class="gl-absolute gl-right-0 gl-bottom-0 gl-mb-0 gl-font-sm">
+ {{ $options.i18n.calendarHint }}
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue
index 47651c33eb8..5b69f835294 100644
--- a/app/assets/javascripts/profile/components/followers_tab.vue
+++ b/app/assets/javascripts/profile/components/followers_tab.vue
@@ -1,17 +1,24 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
title: s__('UserProfile|Followers'),
},
- components: { GlTab },
+ components: {
+ GlBadge,
+ GlTab,
+ },
+ inject: ['followers'],
};
</script>
<template>
- <gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <gl-tab>
+ <template #title>
+ <span>{{ $options.i18n.title }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ followers }}</gl-badge>
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue
index 6d9631c5e89..d39d15a08f3 100644
--- a/app/assets/javascripts/profile/components/following_tab.vue
+++ b/app/assets/javascripts/profile/components/following_tab.vue
@@ -1,17 +1,24 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
title: s__('UserProfile|Following'),
},
- components: { GlTab },
+ components: {
+ GlBadge,
+ GlTab,
+ },
+ inject: ['followees'],
};
</script>
<template>
- <gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <gl-tab>
+ <template #title>
+ <span>{{ $options.i18n.title }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ followees }}</gl-badge>
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql b/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql
new file mode 100644
index 00000000000..e60f383ad1c
--- /dev/null
+++ b/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql
@@ -0,0 +1,21 @@
+query getUserAchievements($id: UserID!) {
+ user(id: $id) {
+ id
+ userAchievements {
+ nodes {
+ id
+ createdAt
+ achievement {
+ id
+ name
+ description
+ avatarUrl
+ namespace {
+ id
+ fullPath
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index e884c2d7083..21f8a2d3500 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,17 +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 },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <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 2425d56c52a..8e52a98803d 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,11 +71,34 @@ 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>
<template>
- <gl-tabs>
- <component :is="component" v-for="{ key, component } in $options.tabs" :key="key" />
+ <gl-tabs nav-class="gl-bg-gray-10" align="center">
+ <component
+ :is="component"
+ v-for="{ key, component } in $options.tabs"
+ :key="key"
+ class="container-fluid container-limited gl-text-left"
+ :personal-projects="personalProjects"
+ :personal-projects-loading="personalProjectsLoading"
+ />
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue
new file mode 100644
index 00000000000..fd42b64f4c5
--- /dev/null
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlPopover, GlSprintf } from '@gitlab/ui';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { s__ } from '~/locale';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import getUserAchievements from './graphql/get_user_achievements.query.graphql';
+
+export default {
+ name: 'UserAchievements',
+ components: { GlPopover, GlSprintf },
+ mixins: [timeagoMixin],
+ inject: ['rootUrl', 'userId'],
+ apollo: {
+ userAchievements: {
+ query: getUserAchievements,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_USER, this.userId),
+ };
+ },
+ update(data) {
+ return this.processNodes(data.user.userAchievements.nodes);
+ },
+ error() {
+ return [];
+ },
+ },
+ },
+ methods: {
+ processNodes(nodes) {
+ return nodes.slice(0, 3).map(({ achievement, createdAt, achievement: { namespace } }) => {
+ return {
+ id: `user-achievement-${getIdFromGraphQLId(achievement.id)}`,
+ name: achievement.name,
+ timeAgo: this.timeFormatted(createdAt),
+ avatarUrl: achievement.avatarUrl || gon.gitlab_logo,
+ description: achievement.description,
+ namespace: namespace && {
+ fullPath: namespace.fullPath,
+ webUrl: this.rootUrl + namespace.fullPath,
+ },
+ };
+ });
+ },
+ achievementAwardedMessage(userAchievement) {
+ return userAchievement.namespace
+ ? this.$options.i18n.awardedBy
+ : this.$options.i18n.awardedByUnknownNamespace;
+ },
+ },
+ i18n: {
+ awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'),
+ awardedByUnknownNamespace: s__('Achievements|Awarded %{timeAgo} by a private namespace'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-3">
+ <div
+ v-for="userAchievement in userAchievements"
+ :key="userAchievement.id"
+ class="gl-display-inline-block"
+ data-testid="user-achievement"
+ >
+ <img
+ :id="userAchievement.id"
+ :src="userAchievement.avatarUrl"
+ :alt="''"
+ tabindex="0"
+ class="gl-avatar gl-avatar-s32 gl-mx-2"
+ />
+ <gl-popover triggers="hover focus" placement="top" :target="userAchievement.id">
+ <div class="gl-font-weight-bold">{{ userAchievement.name }}</div>
+ <div>
+ <gl-sprintf :message="achievementAwardedMessage(userAchievement)">
+ <template #timeAgo>
+ <span>{{ userAchievement.timeAgo }}</span>
+ </template>
+ <template v-if="userAchievement.namespace" #namespace>
+ <a :href="userAchievement.namespace.webUrl">{{
+ userAchievement.namespace.fullPath
+ }}</a>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div
+ v-if="userAchievement.description"
+ class="gl-mt-5"
+ data-testid="achievement-description"
+ >
+ {{ userAchievement.description }}
+ </div>
+ </gl-popover>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/constants.js b/app/assets/javascripts/profile/constants.js
new file mode 100644
index 00000000000..e19994c6784
--- /dev/null
+++ b/app/assets/javascripts/profile/constants.js
@@ -0,0 +1,7 @@
+export const CALENDAR_PERIOD_6_MONTHS = 6;
+export const CALENDAR_PERIOD_12_MONTHS = 12;
+/* computation based on
+ * width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
+ * (see activity_calendar.js)
+ */
+export const OVERVIEW_CALENDAR_BREAKPOINT = 918;
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 5378ed3d743..101e52c873e 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -1,16 +1,54 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import createDefaultClient from '~/lib/graphql';
import ProfileTabs from './components/profile_tabs.vue';
+import UserAchievements from './components/user_achievements.vue';
+
+Vue.use(VueApollo);
export const initProfileTabs = () => {
const el = document.getElementById('js-profile-tabs');
if (!el) return false;
+ 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);
},
});
};
+
+export const initUserAchievements = () => {
+ const el = document.getElementById('js-user-achievements');
+
+ if (!el) return false;
+
+ const { rootUrl, userId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ name: 'UserAchievements',
+ provide: { rootUrl, userId: parseInt(userId, 10) },
+ render(createElement) {
+ return createElement(UserAchievements);
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
index 1992819ab82..5a87ed7996e 100644
--- a/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
+++ b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
@@ -1,7 +1,9 @@
<script>
-import { validateHexColor, hexToRgb } from '~/lib/utils/color_utils';
+import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
+
import { s__ } from '~/locale';
import { getCssVariable } from '~/lib/utils/css_utils';
+import { validateHexColor } from '~/lib/utils/color_utils';
import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
import DiffsColorsPreview from './diffs_colors_preview.vue';
@@ -48,17 +50,15 @@ export default {
previewStyle() {
let style = {};
if (this.isValidColor(this.deletionColor)) {
- const colorRgb = hexToRgb(this.deletionColor).join();
style = {
...style,
- '--diff-deletion-color': `rgba(${colorRgb},0.2)`,
+ '--diff-deletion-color': hexToRgba(this.deletionColor, 0.2),
};
}
if (this.isValidColor(this.additionColor)) {
- const colorRgb = hexToRgb(this.additionColor).join();
style = {
...style,
- '--diff-addition-color': `rgba(${colorRgb},0.2)`,
+ '--diff-addition-color': hexToRgba(this.additionColor, 0.2),
};
}
return style;
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index a33a20b49f6..164ec46cdb9 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert';
import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
import IntegrationView from './integration_view.vue';
@@ -131,6 +131,10 @@ export default {
:message-url="view.message_url"
:config="$options.integrationViewConfigs[view.name]"
/>
+ </div>
+
+ <div class="col-lg-4"></div>
+ <div class="col-lg-8">
<hr />
</div>
<div class="col-sm-12 js-hide-when-nothing-matches-search">
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index c031c5e5e8e..e21f8557d68 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
-import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
diff --git a/app/assets/javascripts/profile/utils.js b/app/assets/javascripts/profile/utils.js
new file mode 100644
index 00000000000..5b757120544
--- /dev/null
+++ b/app/assets/javascripts/profile/utils.js
@@ -0,0 +1,13 @@
+import {
+ OVERVIEW_CALENDAR_BREAKPOINT,
+ CALENDAR_PERIOD_6_MONTHS,
+ CALENDAR_PERIOD_12_MONTHS,
+} from './constants';
+
+export const getVisibleCalendarPeriod = (calendarContainer) => {
+ const { width } = calendarContainer.getBoundingClientRect();
+
+ return width < OVERVIEW_CALENDAR_BREAKPOINT
+ ? CALENDAR_PERIOD_6_MONTHS
+ : CALENDAR_PERIOD_12_MONTHS;
+};
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index 0ed154c47dd..77e809e88ce 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { debounce } from 'lodash';
+import { debounce, uniqBy } from 'lodash';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_BRANCH_HEADER,
@@ -19,11 +19,6 @@ export default {
required: false,
default: '',
},
- blanked: {
- type: Boolean,
- required: false,
- default: false,
- },
},
i18n: {
noResultsMessage: I18N_NO_RESULTS_MESSAGE,
@@ -32,26 +27,26 @@ export default {
},
data() {
return {
- searchTerm: this.blanked ? '' : this.value,
+ searchTerm: '',
};
},
computed: {
...mapGetters(['joinedBranches']),
- ...mapState(['isFetching']),
+ ...mapState(['isFetching', 'branch']),
listboxItems() {
- return this.joinedBranches.map((value) => ({ value, text: value }));
- },
- },
- watch: {
- // Parent component can set the branch value (e.g. when the user selects a different project)
- // and we need to keep the search term in sync with the selected value
- value(val) {
- this.searchTerm = val;
- this.fetchBranches(this.searchTerm);
+ const selectedItem = { value: this.branch, text: this.branch };
+ const transformedList = this.joinedBranches.map((value) => ({ value, text: value }));
+
+ if (this.searchTerm) {
+ return transformedList;
+ }
+
+ // Add selected item to top of list if not searching
+ return uniqBy([selectedItem].concat(transformedList), 'value');
},
},
mounted() {
- this.fetchBranches(this.searchTerm);
+ this.fetchBranches();
},
methods: {
...mapActions(['fetchBranches']),
@@ -70,8 +65,10 @@ export default {
</script>
<template>
<gl-collapsible-listbox
+ class="gl-max-w-full"
:header-text="$options.i18n.branchHeaderTitle"
:toggle-text="value"
+ toggle-class="gl-w-full"
:items="listboxItems"
searchable
:search-placeholder="$options.i18n.branchSearchPlaceholder"
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/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index f78afef1c17..28bbf67c090 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -41,11 +41,6 @@ export default {
required: false,
default: false,
},
- isRevert: {
- type: Boolean,
- required: false,
- default: false,
- },
primaryActionEventName: {
type: String,
required: false,
@@ -57,16 +52,16 @@ export default {
checked: true,
actionPrimary: {
text: this.i18n.actionPrimaryText,
- attributes: [
- { variant: 'confirm' },
- { category: 'primary' },
- { 'data-testid': 'submit-commit' },
- { 'data-qa-selector': 'submit_commit_button' },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ 'data-testid': 'submit-commit',
+ 'data-qa-selector': 'submit_commit_button',
+ },
},
actionCancel: {
text: this.i18n.actionCancelText,
- attributes: [{ 'data-testid': 'cancel-commit' }],
+ attributes: { 'data-testid': 'cancel-commit' },
},
};
},
@@ -85,7 +80,6 @@ export default {
]),
},
mounted() {
- this.setSelectedProject(this.targetProjectId);
eventHub.$on(this.openModal, this.show);
},
methods: {
@@ -141,7 +135,7 @@ export default {
:value="targetProjectId"
/>
- <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" />
+ <projects-dropdown :value="targetProjectName" @input="setSelectedProject" />
</gl-form-group>
<gl-form-group
@@ -151,7 +145,7 @@ export default {
>
<input id="start_branch" type="hidden" name="start_branch" :value="branch" />
- <branches-dropdown :value="branch" :blanked="isRevert" @input="setBranch" />
+ <branches-dropdown :value="branch" @input="setBranch" />
</gl-form-group>
<gl-form-checkbox
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
index d43f5b99e2c..fe54b62e2c8 100644
--- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
+import { debounce, uniqBy } from 'lodash';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_PROJECT_HEADER,
@@ -26,7 +27,7 @@ export default {
},
data() {
return {
- filterTerm: this.value,
+ filterTerm: '',
};
},
computed: {
@@ -39,7 +40,18 @@ export default {
);
},
listboxItems() {
- return this.filteredResults.map(({ id, name }) => ({ value: id, text: name }));
+ const selectedItem = { value: this.selectedProject.id, text: this.selectedProject.name };
+ const transformedList = this.filteredResults.map(({ id, name }) => ({
+ value: id,
+ text: name,
+ }));
+
+ if (this.filterTerm) {
+ return transformedList;
+ }
+
+ // Add selected item to top of list if not searching
+ return uniqBy([selectedItem].concat(transformedList), 'value');
},
selectedProject() {
return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
@@ -47,28 +59,26 @@ export default {
},
methods: {
selectProject(value) {
- this.$emit('selectProject', value);
-
- // when we select a project, we want the dropdown to filter to the selected project
- const project = this.listboxItems.find((x) => x.value === value);
- this.filterTerm = project?.text || '';
- },
- filterTermChanged(value) {
- this.filterTerm = value;
+ this.$emit('input', value);
},
+ debouncedSearch: debounce(function debouncedSearch(value) {
+ this.filterTerm = value.trim();
+ }, 250),
},
};
</script>
<template>
<gl-collapsible-listbox
+ class="gl-max-w-full"
:header-text="$options.i18n.projectHeaderTitle"
:items="listboxItems"
searchable
:search-placeholder="$options.i18n.projectSearchPlaceholder"
:selected="selectedProject.id"
:toggle-text="selectedProject.name"
+ toggle-class="gl-w-full"
:no-results-text="$options.i18n.noResultsMessage"
- @search="filterTermChanged"
+ @search="debouncedSearch"
@select="selectProject"
/>
</template>
diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
index 41be71932e5..849b2f4858c 100644
--- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
@@ -49,7 +49,6 @@ export default function initInviteMembersModal(primaryActionEventName) {
i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL },
openModal: OPEN_REVERT_MODAL,
modalId: REVERT_MODAL_ID,
- isRevert: true,
primaryActionEventName,
},
}),
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index cfff93eac5a..501006a8be5 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { PROJECT_BRANCHES_ERROR } from '../constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index dafc4bc5abf..54d13ecc9c8 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import {
getQueryHeaders,
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
index 62b1209131c..71f53613a3b 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 7500c152b6a..7c4b76fd62f 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,6 +1,5 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
-import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
import initCommitPipelineStatus from './init_commit_pipeline_status';
@@ -14,7 +13,5 @@ export const initCommitBoxInfo = () => {
// Display pipeline mini graph for this commit
initCommitPipelineMiniGraph();
- initDetailsButton();
-
initCommitPipelineStatus();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
index bc2c16b9e83..520b20fcb86 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_details_button.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
@@ -1,7 +1,17 @@
export const initDetailsButton = () => {
- document.querySelector('.commit-info').addEventListener('click', function expand(e) {
- e.preventDefault();
- this.querySelector('.js-details-content').classList.remove('hide');
- this.querySelector('.js-details-expand').classList.add('gl-display-none');
+ const expandButton = document.querySelector('.js-details-expand');
+
+ if (!expandButton) {
+ return;
+ }
+
+ expandButton.addEventListener('click', (event) => {
+ const btn = event.currentTarget;
+ const contentEl = btn.parentElement.querySelector('.js-details-content');
+
+ if (contentEl) {
+ contentEl.classList.remove('hide');
+ btn.classList.add('gl-display-none');
+ }
});
};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
index d1136817cb3..8333e70b951 100644
--- a/app/assets/javascripts/projects/commit_box/info/load_branches.js
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -1,6 +1,7 @@
import axios from 'axios';
import { sanitize } from '~/lib/dompurify';
import { __ } from '~/locale';
+import { initDetailsButton } from './init_details_button';
export const loadBranches = (containerSelector = '.js-commit-box-info') => {
const containerEl = document.querySelector(containerSelector);
@@ -14,6 +15,8 @@ export const loadBranches = (containerSelector = '.js-commit-box-info') => {
.get(commitPath)
.then(({ data }) => {
branchesEl.innerHTML = sanitize(data);
+
+ initDetailsButton();
})
.catch(() => {
branchesEl.textContent = __('Failed to load branches. Please try again.');
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index f85be67d4b3..2966214e051 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
-import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
+import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
const tooltipMessage = __('Searching by both author and message is currently not supported.');
@@ -89,10 +89,10 @@ export default {
commitListElement.style.transition = 'opacity 200ms';
if (!user) {
- return redirectTo(this.commitsPath);
+ return redirectTo(this.commitsPath); // eslint-disable-line import/no-deprecated
}
- return redirectTo(`${this.commitsPath}?author=${user}`);
+ return redirectTo(`${this.commitsPath}?author=${user}`); // eslint-disable-line import/no-deprecated
},
searchAuthors() {
this.fetchAuthors(this.authorInput);
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index f56884f605f..d37c1800718 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,
@@ -42,21 +46,17 @@ export const initCommitsRefSwitcher = () => {
return createElement(RefSelector, {
props: {
projectId,
+ queryParams: { sort: 'updated_desc' },
value: useSymbolicRefNames ? `refs/${refType}/${ref}` : ref,
useSymbolicRefNames,
- refType,
},
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/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 9365066418b..5175f7f9151 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index 10531e950f9..8af1667e26b 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
@@ -149,6 +149,7 @@ export default {
:key="branch"
is-check-item
:is-checked="selectedRevision === branch"
+ data-testid="branches-dropdown-item"
@click="onClick(branch)"
>
{{ branch }}
@@ -161,6 +162,7 @@ export default {
:key="tag"
is-check-item
:is-checked="selectedRevision === tag"
+ data-testid="tags-dropdown-item"
@click="onClick(tag)"
>
{{ tag }}
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
index 1e1677e947c..034bae3066d 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
@@ -132,6 +132,7 @@ export default {
:key="`branch${index}`"
is-check-item
:is-checked="selectedRevision === branch"
+ data-testid="branches-dropdown-item"
@click="onClick(branch)"
>
{{ branch }}
@@ -144,6 +145,7 @@ export default {
:key="`tag${index}`"
is-check-item
:is-checked="selectedRevision === tag"
+ data-testid="tags-dropdown-item"
@click="onClick(tag)"
>
{{ tag }}
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 64a16b462f5..06c0230c8e0 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -62,11 +62,11 @@ export default {
return {
primary: {
text: __('Yes, delete project'),
- attributes: [
- { variant: 'danger' },
- { disabled: this.confirmDisabled },
- { 'data-qa-selector': 'confirm_delete_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ disabled: this.confirmDisabled,
+ 'data-qa-selector': 'confirm_delete_button',
+ },
},
cancel: {
text: __('Cancel, keep project'),
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index a44855c14d5..5bbc881952f 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -53,10 +53,6 @@ export default {
text: s__('ProjectTemplates|Pages/Plain HTML'),
icon: '.template-option .icon-plainhtml',
},
- gitbook: {
- text: s__('ProjectTemplates|Pages/GitBook'),
- icon: '.template-option .icon-gitbook',
- },
hexo: {
text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
@@ -121,4 +117,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 3100029eb31..2f58d4468be 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -9,6 +9,7 @@ import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
const CI_CD_PANEL = 'cicd_for_external_repo';
+const IMPORT_PROJECT_PANEL = 'import_project';
const PANELS = [
{
key: 'blank',
@@ -32,7 +33,7 @@ const PANELS = [
},
{
key: 'import',
- name: 'import_project',
+ name: IMPORT_PROJECT_PANEL,
selector: '#import-project-pane',
title: s__('ProjectsNew|Import project'),
description: s__(
@@ -59,6 +60,24 @@ export default {
SafeHtml,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ projectsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasErrors: {
type: Boolean,
required: false,
@@ -74,11 +93,40 @@ export default {
required: false,
default: '',
},
+ canImportProjects: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
+ initialBreadcrumbs() {
+ 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);
+ if (this.isCiCdAvailable && this.canImportProjects) {
+ return PANELS;
+ }
+
+ return PANELS.filter((panel) => {
+ if (!this.canImportProjects && panel.name === IMPORT_PROJECT_PANEL) {
+ return false;
+ }
+
+ if (!this.isCiCdAvailable && panel.name === CI_CD_PANEL) {
+ return false;
+ }
+
+ return true;
+ });
},
},
@@ -95,7 +143,7 @@ export default {
<template>
<new-namespace-page
- :initial-breadcrumb="__('New project')"
+ :initial-breadcrumbs="initialBreadcrumbs"
:panels="availablePanels"
:jump-to-last-persisted-panel="hasErrors"
:title="s__('ProjectsNew|Create new project')"
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 910244c657b..a5a833dc73b 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -15,12 +15,22 @@ export function initNewProjectCreation() {
newProjectGuidelines,
hasErrors,
isCiCdAvailable,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
+ rootPath,
+ canImportProjects,
} = el.dataset;
const props = {
hasErrors: parseBoolean(hasErrors),
isCiCdAvailable: parseBoolean(isCiCdAvailable),
newProjectGuidelines,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
+ rootPath,
+ canImportProjects: parseBoolean(canImportProjects),
};
const provide = {
diff --git a/app/assets/javascripts/projects/project_find_file.js b/app/assets/javascripts/projects/project_find_file.js
index 71329c4f461..a8b884a68a0 100644
--- a/app/assets/javascripts/projects/project_find_file.js
+++ b/app/assets/javascripts/projects/project_find_file.js
@@ -2,7 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index dcf7415a444..d8675a851ea 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -1,7 +1,7 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { n__, s__, __, sprintf } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
@@ -408,14 +408,16 @@ export default class AccessDropdown {
// Has to be checked against server response
// because the selected item can be in filter results
- usersResponse.forEach((response) => {
- // Add is it has not been added
- if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
- const user = { ...response };
- user.type = LEVEL_TYPES.USER;
- users.push(user);
- }
- });
+ if (gon.current_project_id) {
+ usersResponse.forEach((response) => {
+ // Add is it has not been added
+ if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
+ const user = { ...response };
+ user.type = LEVEL_TYPES.USER;
+ users.push(user);
+ }
+ });
+ }
if (groups.length) {
if (roles.length) {
@@ -469,6 +471,14 @@ export default class AccessDropdown {
}
}
+ if (this.accessLevel === ACCESS_LEVELS.CREATE && deployKeys.length) {
+ consolidatedData = consolidatedData.concat(
+ [{ type: 'divider' }],
+ [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }],
+ deployKeys,
+ );
+ }
+
return consolidatedData;
}
@@ -506,7 +516,10 @@ export default class AccessDropdown {
break;
case LEVEL_TYPES.DEPLOY_KEY:
groupRowEl =
- this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : '';
+ this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE
+ ? this.deployKeyRowHtml(item, isActive)
+ : '';
+
break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
index f2b1c749abc..3dcacf9eb34 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
@@ -7,7 +7,7 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import branchesQuery from '../../queries/branches.query.graphql';
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
index a98c2439cde..b71c33d2b91 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
@@ -14,11 +14,6 @@ export const I18N = {
wildcardsHelpText: s__(
'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/ are supported',
),
- forcePushTitle: s__('BranchRules|Force push'),
- allowForcePushDescription: s__(
- 'BranchRules|All users with push access are allowed to force push.',
- ),
- disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'),
approvalsTitle: s__('BranchRules|Approvals'),
manageApprovalsLinkTitle: s__('BranchRules|Manage in merge request approvals'),
approvalsDescription: s__(
@@ -33,6 +28,19 @@ export const I18N = {
allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'),
allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'),
approvalsHeader: s__('BranchRules|Required approvals (%{total})'),
+ allowForcePushTitle: s__('BranchRules|Allows force push'),
+ doesNotAllowForcePushTitle: s__('BranchRules|Does not allow force push'),
+ forcePushDescription: s__('BranchRules|From users with push access.'),
+ requiresCodeOwnerApprovalTitle: s__('BranchRules|Requires approval from code owners'),
+ doesNotRequireCodeOwnerApprovalTitle: s__(
+ 'BranchRules|Does not require approval from code owners',
+ ),
+ requiresCodeOwnerApprovalDescription: s__(
+ 'BranchRules|Also rejects code pushes that change files listed in CODEOWNERS file.',
+ ),
+ doesNotRequireCodeOwnerApprovalDescription: s__(
+ 'BranchRules|Also accepts code pushes that change files listed in CODEOWNERS file.',
+ ),
noData: s__('BranchRules|No data to display'),
};
@@ -48,3 +56,9 @@ export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches';
export const APPROVALS_HELP_PATH = 'user/project/merge_requests/approvals/index.md';
export const STATUS_CHECKS_HELP_PATH = 'user/project/merge_requests/status_checks.md';
+
+export const REQUIRED_ICON = 'check-circle-filled';
+export const NOT_REQUIRED_ICON = 'status-failed';
+
+export const REQUIRED_ICON_CLASS = 'gl-fill-green-500';
+export const NOT_REQUIRED_ICON_CLASS = 'gl-text-red-500';
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 740868e1d75..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
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale';
import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -12,6 +12,10 @@ import {
BRANCH_PARAM_NAME,
WILDCARDS_HELP_PATH,
PROTECTED_BRANCHES_HELP_PATH,
+ REQUIRED_ICON,
+ NOT_REQUIRED_ICON,
+ REQUIRED_ICON_CLASS,
+ NOT_REQUIRED_ICON_CLASS,
} from './constants';
const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH);
@@ -22,7 +26,7 @@ export default {
i18n: I18N,
wildcardsHelpDocLink,
protectedBranchesHelpDocLink,
- components: { Protection, GlSprintf, GlLink, GlLoadingIcon },
+ components: { Protection, GlSprintf, GlLink, GlLoadingIcon, GlIcon },
inject: {
projectPath: {
default: '',
@@ -33,6 +37,9 @@ export default {
branchesPath: {
default: '',
},
+ showStatusChecks: { default: false },
+ showApprovers: { default: false },
+ showCodeOwners: { default: false },
},
apollo: {
project: {
@@ -63,10 +70,28 @@ export default {
};
},
computed: {
- forcePushDescription() {
- return this.branchProtection?.allowForcePush
- ? this.$options.i18n.allowForcePushDescription
- : this.$options.i18n.disallowForcePushDescription;
+ forcePushAttributes() {
+ const { allowForcePush } = this.branchProtection || {};
+ const icon = allowForcePush ? REQUIRED_ICON : NOT_REQUIRED_ICON;
+ const iconClass = allowForcePush ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
+ const title = allowForcePush
+ ? this.$options.i18n.allowForcePushTitle
+ : this.$options.i18n.doesNotAllowForcePushTitle;
+
+ return { icon, iconClass, title };
+ },
+ codeOwnersApprovalAttributes() {
+ const { codeOwnerApprovalRequired } = this.branchProtection || {};
+ const icon = codeOwnerApprovalRequired ? REQUIRED_ICON : NOT_REQUIRED_ICON;
+ const iconClass = codeOwnerApprovalRequired ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
+ const title = codeOwnerApprovalRequired
+ ? this.$options.i18n.requiresCodeOwnerApprovalTitle
+ : this.$options.i18n.doesNotRequireCodeOwnerApprovalTitle;
+ const description = codeOwnerApprovalRequired
+ ? this.$options.i18n.requiresCodeOwnerApprovalDescription
+ : this.$options.i18n.doesNotRequireCodeOwnerApprovalDescription;
+
+ return { icon, iconClass, title, description };
},
mergeAccessLevels() {
const { mergeAccessLevels } = this.branchProtection || {};
@@ -98,7 +123,7 @@ export default {
: this.$options.i18n.branchNameOrPattern;
},
matchingBranchesLinkHref() {
- return mergeUrlParams({ state: 'all', search: this.branch }, this.branchesPath);
+ return mergeUrlParams({ state: 'all', search: `^${this.branch}$` }, this.branchesPath);
},
matchingBranchesLinkTitle() {
const total = this.matchingBranchesCount;
@@ -162,12 +187,9 @@ export default {
:roles="pushAccessLevels.roles"
:users="pushAccessLevels.users"
:groups="pushAccessLevels.groups"
+ data-qa-selector="allowed_to_push_content"
/>
- <!-- Force push -->
- <strong>{{ $options.i18n.forcePushTitle }}</strong>
- <p>{{ forcePushDescription }}</p>
-
<!-- Allowed to merge -->
<protection
:header="allowedToMergeHeader"
@@ -176,11 +198,40 @@ export default {
:roles="mergeAccessLevels.roles"
:users="mergeAccessLevels.users"
:groups="mergeAccessLevels.groups"
+ data-qa-selector="allowed_to_merge_content"
/>
+ <!-- Force push -->
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon
+ :size="14"
+ data-testid="force-push-icon"
+ :name="forcePushAttributes.icon"
+ :class="forcePushAttributes.iconClass"
+ />
+ <strong class="gl-ml-2">{{ forcePushAttributes.title }}</strong>
+ </div>
+
+ <div class="gl-text-gray-400 gl-mb-2">{{ $options.i18n.forcePushDescription }}</div>
+
<!-- EE start -->
+ <!-- Code Owners -->
+ <div v-if="showCodeOwners">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon
+ data-testid="code-owners-icon"
+ :size="14"
+ :name="codeOwnersApprovalAttributes.icon"
+ :class="codeOwnersApprovalAttributes.iconClass"
+ />
+ <strong class="gl-ml-2">{{ codeOwnersApprovalAttributes.title }}</strong>
+ </div>
+
+ <div class="gl-text-gray-400">{{ codeOwnersApprovalAttributes.description }}</div>
+ </div>
+
<!-- Approvals -->
- <template v-if="approvalsHeader">
+ <template v-if="showApprovers">
<h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4>
<gl-sprintf :message="$options.i18n.approvalsDescription">
<template #link="{ content }">
@@ -200,7 +251,7 @@ export default {
</template>
<!-- Status checks -->
- <template v-if="statusChecksHeader">
+ <template v-if="showStatusChecks">
<h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4>
<gl-sprintf :message="$options.i18n.statusChecksDescription">
<template #link="{ content }">
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 9bff2f5506c..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
@@ -105,7 +105,8 @@ export default {
v-for="(item, index) in accessLevels"
:key="index"
data-testid="access-level"
- class="gl-w-quarter"
+ data-qa-selector="access_level_content"
+ :data-qa-role="item.accessLevelDescription"
>
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
{{ item.accessLevelDescription }}
diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
index 081d6cec958..c429c352bfa 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import View from 'ee_else_ce/projects/settings/branch_rules/components/view/index.vue';
export default function mountBranchRules(el) {
@@ -20,6 +21,9 @@ export default function mountBranchRules(el) {
approvalRulesPath,
statusChecksPath,
branchesPath,
+ showStatusChecks,
+ showApprovers,
+ showCodeOwners,
} = el.dataset;
return new Vue({
@@ -31,6 +35,9 @@ export default function mountBranchRules(el) {
approvalRulesPath,
statusChecksPath,
branchesPath,
+ showStatusChecks: parseBoolean(showStatusChecks),
+ showApprovers: parseBoolean(showApprovers),
+ showCodeOwners: parseBoolean(showCodeOwners),
},
render(h) {
return h(View);
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index cc47496971d..08a1c586f69 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__ } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants';
@@ -86,7 +86,10 @@ export default {
return groupBy(this.preselectedItems, 'type');
},
showDeployKeys() {
- return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
+ return (
+ (this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE) &&
+ this.deployKeys.length
+ );
},
toggleLabel() {
const counts = Object.entries(this.selected).reduce((acc, [key, value]) => {
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index aa3235b1515..b8e7e9e15db 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
+import { GlAlert, GlToggle } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import { CC_VALIDATION_REQUIRED_ERROR } from '../constants';
@@ -16,7 +16,6 @@ export default {
components: {
GlAlert,
GlToggle,
- GlTooltip,
CcValidationRequiredAlert: () =>
import('ee_component/billings/components/cc_validation_required_alert.vue'),
},
@@ -94,10 +93,26 @@ export default {
@dismiss="ccAlertDismissed = true"
/>
- <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false">
+ <gl-alert
+ v-if="genericError"
+ data-testid="error-alert"
+ variant="danger"
+ :dismissible="false"
+ class="gl-mb-5"
+ >
{{ errorMessage }}
</gl-alert>
+ <gl-alert
+ v-if="isDisabledAndUnoverridable"
+ data-testid="unoverridable-alert"
+ variant="warning"
+ :dismissible="false"
+ class="gl-mb-5"
+ >
+ {{ s__('Runners|Shared runners are disabled in the group settings') }}
+ </gl-alert>
+
<gl-toggle
ref="sharedRunnersToggle"
:disabled="isDisabledAndUnoverridable"
@@ -107,10 +122,6 @@ export default {
data-testid="toggle-shared-runners"
@change="toggleSharedRunners"
/>
-
- <gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle">
- {{ __('Shared runners are disabled on group level') }}
- </gl-tooltip>
</section>
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index 9cf1afd334f..595cbc9c991 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -17,6 +17,7 @@ export const LEVEL_ID_PROP = {
export const ACCESS_LEVELS = {
MERGE: 'merge_access_levels',
PUSH: 'push_access_levels',
+ CREATE: 'create_access_levels',
};
export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js b/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js
new file mode 100644
index 00000000000..527678250fb
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { generateRefDestinationPath } from './utils';
+
+export default function initRefSwitcherBadges() {
+ const refSwitcherElements = document.getElementsByClassName('js-ref-switcher-badge');
+
+ if (refSwitcherElements.length === 0) return false;
+
+ return Array.from(refSwitcherElements).forEach((element) => {
+ const { projectId, ref } = element.dataset;
+
+ return new Vue({
+ el: element,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(generateRefDestinationPath(selectedRef));
+ },
+ },
+ });
+ },
+ });
+ });
+}
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 f3d392a0ec4..dcf5155644d 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
import { expandSection } from '~/settings_panels';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -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 fa96eee5f92..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
@@ -27,6 +27,9 @@ export default {
branchRulesPath: {
default: '',
},
+ showCodeOwners: { default: false },
+ showStatusChecks: { default: false },
+ showApprovers: { default: false },
},
props: {
name: {
@@ -70,7 +73,7 @@ export default {
return this.approvalDetails.length;
},
detailsPath() {
- return `${this.branchRulesPath}?branch=${this.name}`;
+ return `${this.branchRulesPath}?branch=${encodeURIComponent(this.name)}`;
},
statusChecksText() {
return sprintf(this.$options.i18n.statusChecks, {
@@ -112,13 +115,13 @@ export default {
if (this.branchProtection?.allowForcePush) {
approvalDetails.push(this.$options.i18n.allowForcePush);
}
- if (this.branchProtection?.codeOwnerApprovalRequired) {
+ if (this.showCodeOwners && this.branchProtection?.codeOwnerApprovalRequired) {
approvalDetails.push(this.$options.i18n.codeOwnerApprovalRequired);
}
- if (this.statusChecksTotal) {
+ if (this.showStatusChecks && this.statusChecksTotal) {
approvalDetails.push(this.statusChecksText);
}
- if (this.approvalRulesTotal) {
+ if (this.showApprovers && this.approvalRulesTotal) {
approvalDetails.push(this.approvalRulesText);
}
if (this.mergeAccessLevels.total > 0) {
@@ -150,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>
@@ -166,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/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
index 042be089e09..a8736c87e22 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(VueApollo);
@@ -12,7 +13,13 @@ const apolloProvider = new VueApollo({
export default function mountBranchRules(el) {
if (!el) return null;
- const { projectPath, branchRulesPath } = el.dataset;
+ const {
+ projectPath,
+ branchRulesPath,
+ showCodeOwners,
+ showStatusChecks,
+ showApprovers,
+ } = el.dataset;
return new Vue({
el,
@@ -20,6 +27,9 @@ export default function mountBranchRules(el) {
provide: {
projectPath,
branchRulesPath,
+ showCodeOwners: parseBoolean(showCodeOwners),
+ showStatusChecks: parseBoolean(showStatusChecks),
+ showApprovers: parseBoolean(showApprovers),
},
render(createElement) {
return createElement(BranchRulesApp);
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index 3d553e71f71..47477d39b8a 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
+import { GlTokenSelector, GlAvatarLabeled, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
@@ -8,8 +9,15 @@ export default {
components: {
GlTokenSelector,
GlAvatarLabeled,
+ GlFormGroup,
+ GlLink,
+ GlSprintf,
},
i18n: {
+ topicsTitle: s__('ProjectSettings|Topics'),
+ topicsHelpText: s__(
+ 'ProjectSettings|Topics are publicly visible even on private projects. Do not include sensitive information in topic names. %{linkStart}Learn more%{linkEnd}.',
+ ),
placeholder: s__('ProjectSettings|Search for topic'),
},
props: {
@@ -51,6 +59,11 @@ export default {
placeholderText() {
return this.selectedTokens.length ? '' : this.$options.i18n.placeholder;
},
+ topicsHelpUrl() {
+ return helpPagePath('user/admin_area/index.html', {
+ anchor: 'administering-topics',
+ });
+ },
},
methods: {
handleEnter(event) {
@@ -70,25 +83,34 @@ export default {
};
</script>
<template>
- <gl-token-selector
- ref="tokenSelector"
- v-model="selectedTokens"
- :dropdown-items="topics"
- :loading="loading"
- allow-user-defined-tokens
- :placeholder="placeholderText"
- @keydown.enter="handleEnter"
- @text-input="filterTopics"
- @input="onTokensUpdate"
- >
- <template #dropdown-item-content="{ dropdownItem }">
- <gl-avatar-labeled
- :src="dropdownItem.avatarUrl"
- :entity-name="dropdownItem.name"
- :label="dropdownItem.title"
- :size="32"
- :shape="$options.AVATAR_SHAPE_OPTION_RECT"
- />
+ <gl-form-group id="project_topics" :label="$options.i18n.topicsTitle">
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="selectedTokens"
+ :dropdown-items="topics"
+ :loading="loading"
+ allow-user-defined-tokens
+ :placeholder="placeholderText"
+ @keydown.enter="handleEnter"
+ @text-input="filterTopics"
+ @input="onTokensUpdate"
+ >
+ <template #dropdown-item-content="{ dropdownItem }">
+ <gl-avatar-labeled
+ :src="dropdownItem.avatarUrl"
+ :entity-name="dropdownItem.name"
+ :label="dropdownItem.title"
+ :size="32"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ />
+ </template>
+ </gl-token-selector>
+ <template #description>
+ <gl-sprintf :message="$options.i18n.topicsHelpText">
+ <template #link="{ content }">
+ <gl-link :href="topicsHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
- </gl-token-selector>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js
index ea4574119c0..9c19657bb39 100644
--- a/app/assets/javascripts/projects/settings/utils.js
+++ b/app/assets/javascripts/projects/settings/utils.js
@@ -1,3 +1,24 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
+export const generateRefDestinationPath = (selectedRef) => {
+ const namespace = '-/settings/ci_cd';
+ const { pathname } = window.location;
+
+ if (!selectedRef || !pathname.includes(namespace)) {
+ return window.location.href;
+ }
+
+ const [projectRootPath] = pathname.split(namespace);
+
+ const destinationPath = joinPaths(projectRootPath, namespace);
+
+ const newURL = new URL(window.location);
+ newURL.pathname = destinationPath;
+ newURL.searchParams.set('ref', selectedRef);
+
+ return newURL.href;
+};
+
export const getAccessLevels = (accessLevels = {}) => {
const total = accessLevels.edges?.length;
const accessLevelTypes = { total, users: [], groups: [], roles: [] };
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..5a3930b5df4 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',
});
},
},
@@ -155,7 +155,7 @@ export default {
<div v-if="isEnabled" class="row mt-3">
<div class="col-md-9 mb-0">
<gl-form-group
- :label="__('Email address to use for Support Desk')"
+ :label="__('Email address to use for Service Desk')"
label-for="incoming-email"
data-testid="incoming-email-label"
>
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 55c3d68cd11..f294811dfff 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index 9f9b6424125..5b620aa2300 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index ae5eaa8e622..5342874250c 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -10,11 +10,8 @@ export const LEVEL_TYPES = {
DEPLOY_KEY: 'deploy_key',
};
-export const LEVEL_ID_PROP = {
- ROLE: 'access_level',
- USER: 'user_id',
- GROUP: 'group_id',
- DEPLOY_KEY: 'deploy_key_id',
-};
+export const BRANCH_RULES_ANCHOR = '#branch-rules';
+
+export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created';
-export const ACCESS_LEVEL_NONE = 0;
+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 120f75d4f0c..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 '~/flash';
+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/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 1693d869b54..b6c86750723 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,5 +1,5 @@
import { find } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
diff --git a/app/assets/javascripts/protected_tags/constants.js b/app/assets/javascripts/protected_tags/constants.js
index 3e71ba62877..758b820c4c4 100644
--- a/app/assets/javascripts/protected_tags/constants.js
+++ b/app/assets/javascripts/protected_tags/constants.js
@@ -1,3 +1,14 @@
import { s__ } from '~/locale';
export const FAILED_TO_UPDATE_TAG_MESSAGE = s__('ProjectSettings|Failed to update tag!');
+
+export const ACCESS_LEVELS = {
+ CREATE: 'create_access_levels',
+};
+
+export const LEVEL_TYPES = {
+ ROLE: 'role',
+ USER: 'user',
+ GROUP: 'group',
+ DEPLOY_KEY: 'deploy_key',
+};
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index 75fd11cd074..365b9a3b142 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,12 +1,21 @@
import $ from 'jquery';
-import { __ } from '~/locale';
-import CreateItemDropdown from '../create_item_dropdown';
-import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import CreateItemDropdown from '~/create_item_dropdown';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { s__, __ } from '~/locale';
+import AccessDropdown from '~/projects/settings/access_dropdown';
+import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedTagCreate {
- constructor() {
+ constructor({ hasLicense }) {
+ this.hasLicense = hasLicense;
this.$form = $('.js-new-protected-tag');
this.buildDropdowns();
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.$form.on('submit', this.onFormSubmit.bind(this));
}
buildDropdowns() {
@@ -16,15 +25,14 @@ export default class ProtectedTagCreate {
this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Create dropdown
- this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ this.protectedTagAccessDropdown = new AccessDropdown({
$dropdown: $allowedToCreateDropdown,
- data: gon.create_access_levels,
+ accessLevelsData: gon.create_access_levels,
onSelect: this.onSelectCallback,
+ accessLevel: ACCESS_LEVELS.CREATE,
+ hasLicense: this.hasLicense,
});
- // Select default
- $allowedToCreateDropdown.data('deprecatedJQueryDropdown').selectRowAtIndex(0);
-
// Protected tag dropdown
this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
@@ -39,7 +47,7 @@ export default class ProtectedTagCreate {
onSelect() {
// Enable submit button
const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
- const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
+ const $allowedToCreateInput = this.protectedTagAccessDropdown.getSelectedItems();
this.$form
.find('button[type="submit"]')
@@ -49,4 +57,57 @@ export default class ProtectedTagCreate {
static getProtectedTags(term, callback) {
callback(gon.open_tags);
}
+
+ getFormData() {
+ const formData = {
+ authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
+ protected_tag: {
+ name: this.$form.find('input[name="protected_tag[name]"]').val(),
+ },
+ };
+
+ Object.keys(ACCESS_LEVELS).forEach((level) => {
+ const accessLevel = ACCESS_LEVELS[level];
+ const selectedItems = this.protectedTagAccessDropdown.getSelectedItems();
+ const levelAttributes = [];
+
+ selectedItems.forEach((item) => {
+ if (item.type === LEVEL_TYPES.USER) {
+ levelAttributes.push({
+ user_id: item.user_id,
+ });
+ } else if (item.type === LEVEL_TYPES.ROLE) {
+ levelAttributes.push({
+ access_level: item.access_level,
+ });
+ } else if (item.type === LEVEL_TYPES.GROUP) {
+ levelAttributes.push({
+ group_id: item.group_id,
+ });
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ levelAttributes.push({
+ deploy_key_id: item.deploy_key_id,
+ });
+ }
+ });
+
+ formData.protected_tag[`${accessLevel}_attributes`] = levelAttributes;
+ });
+
+ return formData;
+ }
+
+ onFormSubmit(e) {
+ e.preventDefault();
+
+ axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
+ .then(() => {
+ window.location.reload();
+ })
+ .catch(() =>
+ createAlert({
+ message: s__('ProjectSettings|Failed to protect the tag'),
+ }),
+ );
+ }
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index 40c52eba99e..4fa3ac3be4b 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,57 +1,115 @@
-import { createAlert } from '~/flash';
-import axios from '../lib/utils/axios_utils';
-import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants';
-import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import { find } from 'lodash';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import AccessDropdown from '~/projects/settings/access_dropdown';
+import { ACCESS_LEVELS, LEVEL_TYPES, FAILED_TO_UPDATE_TAG_MESSAGE } from './constants';
export default class ProtectedTagEdit {
constructor(options) {
+ this.hasLicense = options.hasLicense;
+ this.hasChanges = false;
this.$wrap = options.$wrap;
this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create');
- this.onSelectCallback = this.onSelect.bind(this);
+
+ this.$allowedToCreateDropdownContainer = this.$allowedToCreateDropdownButton.closest(
+ '.create_access_levels-container',
+ );
this.buildDropdowns();
}
buildDropdowns() {
// Allowed to create dropdown
- this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ this.protectedTagAccessDropdown = new AccessDropdown({
+ accessLevel: ACCESS_LEVELS.CREATE,
+ accessLevelsData: gon.create_access_levels,
$dropdown: this.$allowedToCreateDropdownButton,
- data: gon.create_access_levels,
- onSelect: this.onSelectCallback,
+ onSelect: this.onSelectOption.bind(this),
+ onHide: this.onDropdownHide.bind(this),
+ hasLicense: this.hasLicense,
});
}
- onSelect() {
- const $allowedToCreateInput = this.$wrap.find(
- `input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`,
- );
+ onSelectOption() {
+ this.hasChanges = true;
+ }
- // Do not update if one dropdown has not selected any option
- if (!$allowedToCreateInput.length) return;
+ onDropdownHide() {
+ if (!this.hasChanges) {
+ return;
+ }
- this.$allowedToCreateDropdownButton.disable();
+ this.hasChanges = true;
+ this.updatePermissions();
+ }
+
+ updatePermissions() {
+ const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => {
+ const accessLevelName = ACCESS_LEVELS[level];
+ const inputData = this.protectedTagAccessDropdown.getInputData(accessLevelName);
+ acc[`${accessLevelName}_attributes`] = inputData;
+
+ return acc;
+ }, {});
axios
.patch(this.$wrap.data('url'), {
- protected_tag: {
- create_access_levels_attributes: [
- {
- id: this.$allowedToCreateDropdownButton.data('accessLevelId'),
- access_level: $allowedToCreateInput.val(),
- },
- ],
- },
+ protected_tag: formData,
})
- .then(() => {
- this.$allowedToCreateDropdownButton.enable();
+ .then(({ data }) => {
+ this.hasChanges = false;
+
+ Object.keys(ACCESS_LEVELS).forEach((level) => {
+ const accessLevelName = ACCESS_LEVELS[level];
+
+ // The data coming from server will be the new persisted *state* for each dropdown
+ this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`);
+ });
})
.catch(() => {
- this.$allowedToCreateDropdownButton.enable();
-
window.scrollTo({ top: 0, behavior: 'smooth' });
createAlert({
message: FAILED_TO_UPDATE_TAG_MESSAGE,
});
});
}
+
+ setSelectedItemsToDropdown(items = []) {
+ const itemsToAdd = items.map((currentItem) => {
+ if (currentItem.user_id) {
+ // Do this only for users for now
+ // get the current data for selected items
+ const selectedItems = this.protectedTagAccessDropdown.getSelectedItems();
+ const currentSelectedItem = find(selectedItems, {
+ user_id: currentItem.user_id,
+ });
+
+ return {
+ id: currentItem.id,
+ user_id: currentItem.user_id,
+ type: LEVEL_TYPES.USER,
+ persisted: true,
+ name: currentSelectedItem.name,
+ username: currentSelectedItem.username,
+ avatar_url: currentSelectedItem.avatar_url,
+ };
+ } else if (currentItem.group_id) {
+ return {
+ id: currentItem.id,
+ group_id: currentItem.group_id,
+ type: LEVEL_TYPES.GROUP,
+ persisted: true,
+ };
+ }
+
+ return {
+ id: currentItem.id,
+ access_level: currentItem.access_level,
+ type: LEVEL_TYPES.ROLE,
+ persisted: true,
+ };
+ });
+
+ this.protectedTagAccessDropdown.setSelectedItems(itemsToAdd);
+ }
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
index b35bf4d4606..8ceb970bf03 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -4,7 +4,8 @@ import $ from 'jquery';
import ProtectedTagEdit from './protected_tag_edit';
export default class ProtectedTagEditList {
- constructor() {
+ constructor(options) {
+ this.hasLicense = options.hasLicense;
this.$wrap = $('.protected-tags-list');
this.initEditForm();
}
@@ -13,6 +14,7 @@ export default class ProtectedTagEditList {
this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => {
new ProtectedTagEdit({
$wrap: $(el),
+ hasLicense: this.hasLicense,
});
});
}
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 9826124912b..7f58b394547 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -42,10 +42,10 @@ export default {
required: false,
default: '',
},
- refType: {
- type: String,
+ queryParams: {
+ type: Object,
required: false,
- default: null,
+ default: () => {},
},
projectId: {
type: String,
@@ -93,6 +93,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 +187,7 @@ export default {
this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
+ this.setParams(this.queryParams);
this.$watch(
'enabledRefTypes',
@@ -206,6 +208,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/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index adae92a92e9..7ecc39a56e7 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -182,7 +182,7 @@ export default {
:checked="linkedIssueType"
/>
</gl-form-group>
- <p class="bold">
+ <p class="bold gl-mb-2">
{{ issuableInputText }}
</p>
</template>
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 8d6a3110f35..1846b9cf8f4 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -182,7 +182,7 @@ export default {
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
- class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-p-3 gl-pb-2"
+ class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-px-3 gl-pt-2 gl-pb-0"
role="button"
@click="onIssuableFormWrapperClick"
>
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 4a130ade631..24b350c7f18 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
+import { GlLink, GlIcon, GlLoadingIcon, GlButton, GlCard } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import {
issuableIconMap,
@@ -16,8 +16,10 @@ export default {
name: 'RelatedIssuesBlock',
components: {
GlLink,
- GlButton,
GlIcon,
+ GlLoadingIcon,
+ GlButton,
+ GlCard,
AddIssuableForm,
RelatedIssuesList,
},
@@ -181,64 +183,71 @@ export default {
<template>
<div id="related-issues" class="related-issues-block">
- <div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0">
- <div
- :class="{
- 'gl-border-b-1': isOpen,
- 'gl-border-b-0': !isOpen,
- }"
- class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 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">
- <gl-link
- id="user-content-related-issues"
- class="anchor position-absolute gl-text-decoration-none"
- href="#related-issues"
- aria-hidden="true"
- />
- <slot name="header-text">{{ headerText }}</slot>
-
- <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3">
- <span class="gl-display-inline-flex gl-align-items-center">
- <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
- {{ badgeLabel }}
- </span>
- </div>
- </h3>
- <slot name="header-actions"></slot>
- <gl-button
- v-if="canAdmin"
- size="small"
- data-qa-selector="related_issues_plus_button"
- data-testid="related-issues-plus-button"
- :aria-label="addIssuableButtonText"
- class="gl-ml-3"
- @click="addButtonClick"
+ <gl-card
+ class="gl-overflow-hidden gl-mt-5 gl-mb-0"
+ header-class="gl-p-0 gl-border-0"
+ body-class="gl-p-0 gl-bg-gray-10"
+ >
+ <template #header>
+ <div
+ :class="{
+ 'gl-border-b-1': isOpen,
+ 'gl-border-b-0': !isOpen,
+ }"
+ 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"
>
- <slot name="add-button-text">{{ __('Add') }}</slot>
- </gl-button>
- <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <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"
+ href="#related-issues"
+ aria-hidden="true"
+ />
+ <slot name="header-text">{{ headerText }}</slot>
+
+ <div
+ class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3 gl-text-gray-500"
+ >
+ <span class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
+ {{ badgeLabel }}
+ </span>
+ </div>
+ </h3>
+ <slot name="header-actions"></slot>
<gl-button
- category="tertiary"
+ v-if="canAdmin"
size="small"
- :icon="toggleIcon"
- :aria-label="toggleLabel"
- data-testid="toggle-links"
- @click="handleToggle"
- />
+ data-qa-selector="related_issues_plus_button"
+ data-testid="related-issues-plus-button"
+ :aria-label="addIssuableButtonText"
+ class="gl-ml-3"
+ @click="addButtonClick"
+ >
+ <slot name="add-button-text">{{ __('Add') }}</slot>
+ </gl-button>
+ <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <gl-button
+ category="tertiary"
+ size="small"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ data-testid="toggle-links"
+ @click="handleToggle"
+ />
+ </div>
</div>
- </div>
+ </template>
<div
v-if="isOpen"
- class="linked-issues-card-body gl-bg-gray-10"
- :class="{
- 'gl-p-5': isFormVisible || shouldShowTokenBody,
- }"
+ class="linked-issues-card-body gl-py-3 gl-px-4 gl-bg-gray-10"
data-testid="related-issues-body"
>
<div
v-if="isFormVisible"
- class="js-add-related-issues-form-area card-body bordered-box bg-white"
+ class="js-add-related-issues-form-area card-body bg-white gl-mt-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base"
:class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }"
>
<add-issuable-form
@@ -261,6 +270,7 @@ export default {
/>
</div>
<template v-if="shouldShowTokenBody">
+ <gl-loading-icon v-if="isFetching" size="sm" class="gl-py-2" />
<related-issues-list
v-for="(category, index) in categorisedIssues"
:key="category.linkType"
@@ -272,13 +282,16 @@ export default {
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:related-issues="category.issues"
- :class="{ 'gl-mt-5': index > 0 }"
+ :class="{
+ 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100':
+ index !== categorisedIssues.length - 1,
+ }"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
@saveReorder="$emit('saveReorder', $event)"
/>
</template>
<div v-if="!shouldShowTokenBody && !isFormVisible" data-testid="related-items-empty">
- <p class="gl-my-5 gl-px-5">
+ <p class="gl-p-2 gl-mb-0 gl-text-gray-500">
{{ emptyStateMessage }}
<gl-link
v-if="hasHelpPath"
@@ -292,6 +305,6 @@ export default {
</p>
</div>
</div>
- </div>
+ </gl-card>
</div>
</template>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 7387b9ab87c..63452f3eace 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -97,11 +97,13 @@ export default {
<template>
<div :data-link-type="listLinkType">
- <h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4>
- <div
- class="related-issues-token-body bordered-box bg-white"
- :class="{ 'sortable-container': canReorder }"
+ <h4
+ v-if="heading"
+ class="gl-font-sm gl-font-weight-semibold gl-text-gray-700 gl-mx-2 gl-mt-3 gl-mb-2"
>
+ {{ heading }}
+ </h4>
+ <div class="related-issues-token-body" :class="{ 'sortable-container': canReorder }">
<div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
@@ -121,10 +123,11 @@ export default {
}"
:data-key="issue.id"
:data-ordering-id="issuableOrderingId(issue)"
- class="js-related-issues-token-list-item list-item pt-0 pb-0"
+ class="js-related-issues-token-list-item list-item pt-0 pb-0 gl-border-b-0!"
>
<related-issuable-item
:id-key="issue.id"
+ :iid="issue.iid"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
@@ -144,6 +147,7 @@ export default {
:work-item-type="issue.type"
event-namespace="relatedIssue"
data-qa-selector="related_issuable_content"
+ class="gl-mx-n2"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
/>
</li>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index ed70e1ce8a8..51f4e4f7d7b 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -23,7 +23,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
and hide the `AddIssuableForm` area.
*/
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/issues/constants';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 2a4ce70511b..25fc875db65 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -1,12 +1,5 @@
import { __, sprintf } from '~/locale';
-import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
-
-export const issuableTypesMap = {
- ISSUE: 'issue',
- INCIDENT: 'incident',
- EPIC: 'epic',
- MERGE_REQUEST: 'merge_request',
-};
+import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
export const linkedIssueTypesMap = {
BLOCKS: 'blocks',
@@ -27,7 +20,7 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
- [issuableTypesMap.INCIDENT]: sprintf(
+ [TYPE_INCIDENT]: sprintf(
__(' or %{emphasisStart}#id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -37,7 +30,7 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
- [issuableTypesMap.MERGE_REQUEST]: sprintf(
+ [TYPE_MERGE_REQUEST]: sprintf(
__(' or %{emphasisStart}!merge request id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -46,21 +39,21 @@ export const autoCompleteTextMap = {
false: {
[TYPE_ISSUE]: '',
[TYPE_EPIC]: '',
- [issuableTypesMap.MERGE_REQUEST]: __(' or references'),
+ [TYPE_MERGE_REQUEST]: __(' or references'),
},
};
export const inputPlaceholderTextMap = {
[TYPE_ISSUE]: __('Paste issue link'),
- [issuableTypesMap.INCIDENT]: __('Paste link'),
+ [TYPE_INCIDENT]: __('Paste link'),
[TYPE_EPIC]: __('Paste epic link'),
- [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
+ [TYPE_MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const inputPlaceholderConfidentialTextMap = {
[TYPE_ISSUE]: __('Paste confidential issue link'),
[TYPE_EPIC]: __('Paste confidential epic link'),
- [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
+ [TYPE_MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const relatedIssuesRemoveErrorMap = {
@@ -96,7 +89,7 @@ export const addRelatedItemErrorMap = {
*/
export const issuableIconMap = {
[TYPE_ISSUE]: 'issues',
- [issuableTypesMap.INCIDENT]: 'issues',
+ [TYPE_INCIDENT]: 'issues',
[TYPE_EPIC]: 'epic',
};
@@ -107,13 +100,13 @@ export const PathIdSeparator = {
export const issuablesBlockHeaderTextMap = {
[TYPE_ISSUE]: __('Linked items'),
- [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'),
+ [TYPE_INCIDENT]: __('Linked incidents or issues'),
[TYPE_EPIC]: __('Linked epics'),
};
export const issuablesBlockHelpTextMap = {
[TYPE_ISSUE]: __('Learn more about linking issues'),
- [issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'),
+ [TYPE_INCIDENT]: __('Learn more about linking issues and incidents'),
[TYPE_EPIC]: __('Learn more about linking epics'),
};
@@ -124,12 +117,12 @@ export const issuablesBlockAddButtonTextMap = {
export const issuablesFormCategoryHeaderTextMap = {
[TYPE_ISSUE]: __('The current issue'),
- [issuableTypesMap.INCIDENT]: __('The current incident'),
+ [TYPE_INCIDENT]: __('The current incident'),
[TYPE_EPIC]: __('The current epic'),
};
export const issuablesFormInputTextMap = {
[TYPE_ISSUE]: __('the following issues'),
- [issuableTypesMap.INCIDENT]: __('the following incidents or issues'),
+ [TYPE_INCIDENT]: __('the following incidents or issues'),
[TYPE_EPIC]: __('the following epics'),
};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index cc00ef10dda..23620432feb 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -19,6 +19,7 @@ export function initRelatedIssues(issueType = TYPE_ISSUE) {
fullPath: el.dataset.fullPath,
hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(el.dataset.hasIterationsFeature),
+ reportAbusePath: el.dataset.reportAbusePath,
},
render: (createElement) =>
createElement(RelatedIssuesRoot, {
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 9f200856db3..eebaeeea286 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,12 +1,13 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse, deleteReleaseSessionKey } from '~/releases/util';
+import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import { popDeleteReleaseNotification } from '~/releases/release_notification_service';
import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -173,18 +174,7 @@ export default {
},
},
mounted() {
- const key = deleteReleaseSessionKey(this.projectPath);
- const deletedRelease = window.sessionStorage.getItem(key);
-
- if (deletedRelease) {
- this.$toast.show(
- sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
- deletedRelease,
- }),
- );
- }
-
- window.sessionStorage.removeItem(key);
+ popDeleteReleaseNotification(this.projectPath);
},
created() {
this.updateQueryParamsFromUrl();
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 544f2de5132..111d9e232c5 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { popCreateReleaseNotification } from '~/releases/release_notification_service';
import oneReleaseQuery from '../graphql/queries/one_release.query.graphql';
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index cc28980a6bf..dd45a2b1762 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -83,8 +83,17 @@ export default {
linksForType(type) {
return this.assets.links.filter((l) => l.linkType === type);
},
+ getTooltipTitle(section) {
+ return section.title
+ ? this.$options.externalLinkTooltipText
+ : this.$options.downloadTooltipText;
+ },
+ getIconName(section) {
+ return section.title ? 'external-link' : 'download';
+ },
},
externalLinkTooltipText: __('This link points to external content'),
+ downloadTooltipText: __('Download'),
};
</script>
@@ -121,13 +130,12 @@ export default {
<gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" />
{{ link.name }}
<gl-icon
- v-if="section.title"
v-gl-tooltip
- name="external-link"
- :aria-label="$options.externalLinkTooltipText"
- :title="$options.externalLinkTooltipText"
+ :name="getIconName(section)"
+ :aria-label="getTooltipTitle(section)"
+ :title="getTooltipTitle(section)"
data-testid="external-link-indicator"
- class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400"
+ class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0"
/>
</gl-link>
</li>
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 2ddab5dddea..ec058cc3603 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,230 +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.isTagNameEmpty || this.validationErrors.existingRelease)
- );
+ return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid;
},
- tagNameInputId() {
- return uniqueId('tag-name-input-');
+ tagFeedback() {
+ return this.validationErrors.tagNameValidation.validationErrors[0];
},
- createFromSelectorId() {
- return uniqueId('create-from-selector-');
+ buttonText() {
+ return this.release?.tagName || s__('Release|Search or create tag name');
},
- tagFeedback() {
- return this.validationErrors.existingRelease
- ? __('Selected tag is already in use. Choose another option.')
- : __('Tag name is required.');
+ 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/constants.js b/app/assets/javascripts/releases/constants.js
index 4f862741e11..5e9e65a01b3 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -49,3 +49,8 @@ export const SORT_MAP = {
};
export const DEFAULT_SORT = RELEASED_AT_DESC;
+
+export const i18n = {
+ tagNameIsRequiredMessage: __('Tag name is required.'),
+ tagIsAlredyInUseMessage: __('Selected tag is already in use. Choose another option.'),
+};
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index e22726f27a7..e6d981600b9 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -23,7 +23,6 @@ fragment Release on Release {
url
directAssetUrl
linkType
- external
}
}
}
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/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js
index a4f926d7561..20fbab3241e 100644
--- a/app/assets/javascripts/releases/release_notification_service.js
+++ b/app/assets/javascripts/releases/release_notification_service.js
@@ -1,5 +1,5 @@
-import { s__, sprintf } from '~/locale';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { s__, __, sprintf } from '~/locale';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`;
@@ -21,3 +21,24 @@ export const popCreateReleaseNotification = (projectPath) => {
window.sessionStorage.removeItem(key);
}
};
+
+export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
+
+export const putDeleteReleaseNotification = (projectPath, releaseName) => {
+ window.sessionStorage.setItem(deleteReleaseSessionKey(projectPath), releaseName);
+};
+
+export const popDeleteReleaseNotification = (projectPath) => {
+ const key = deleteReleaseSessionKey(projectPath);
+ const deletedRelease = window.sessionStorage.getItem(key);
+
+ if (deletedRelease) {
+ createAlert({
+ message: sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
+ deletedRelease,
+ }),
+ variant: VARIANT_SUCCESS,
+ });
+ window.sessionStorage.removeItem(key);
+ }
+};
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 42ceed81c00..2e3cf3bf9b8 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -1,6 +1,6 @@
import { getTag } from '~/rest_api';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
@@ -8,11 +8,8 @@ import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql';
-import {
- gqClient,
- convertOneReleaseGraphQLResponse,
- deleteReleaseSessionKey,
-} from '~/releases/util';
+import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import { putDeleteReleaseNotification } from '~/releases/release_notification_service';
import * as types from './mutation_types';
@@ -98,7 +95,7 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
- redirectTo(urlToRedirectTo);
+ redirectTo(urlToRedirectTo); // eslint-disable-line import/no-deprecated
};
export const saveRelease = ({ commit, dispatch, state }) => {
@@ -261,10 +258,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
})
.then((response) => checkForErrorsAsData(response, 'releaseDelete'))
.then(() => {
- window.sessionStorage.setItem(
- deleteReleaseSessionKey(state.projectPath),
- state.originalRelease.name,
- );
+ putDeleteReleaseNotification(state.projectPath, state.originalRelease.name);
return dispatch('receiveSaveReleaseSuccess', state.releasesPagePath);
})
.catch((error) => {
@@ -274,3 +268,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 0d77095d099..edf6c81c9e9 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -2,6 +2,9 @@ import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
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
@@ -35,18 +38,21 @@ export const validationErrors = (state) => {
assets: {
links: {},
},
+ tagNameValidation: new ValidationResult(),
};
if (!state.release) {
return errors;
}
- if (!state.release.tagName?.trim?.().length) {
- errors.isTagNameEmpty = true;
+ if (!state.release.tagName || typeof state.release.tagName !== 'string') {
+ errors.tagNameValidation.addValidationError(i18n.tagNameIsRequiredMessage);
+ } else {
+ errors.tagNameValidation = validateTag(state.release.tagName);
}
if (state.existingRelease) {
- errors.existingRelease = true;
+ errors.tagNameValidation.addValidationError(i18n.tagIsAlredyInUseMessage);
}
// Each key of this object is a URL, and the value is an
@@ -164,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/releases/util.js b/app/assets/javascripts/releases/util.js
index 10d7887c0b1..018a9352beb 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -135,5 +135,3 @@ export const convertOneReleaseGraphQLResponse = (response) => {
return { data: release };
};
-
-export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
index d029f8cf89f..e26036b5620 100644
--- a/app/assets/javascripts/repository/commits_service.js
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { normalizeData } from 'ee_else_ce/repository/utils/commit';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants';
let requestedOffsets = [];
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 101625a4b72..e056a822c8b 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -4,11 +4,11 @@ import { uniqueId } from 'lodash';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { redirectTo, getLocationHash } from '~/lib/utils/url_utility';
+import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.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: {
@@ -303,7 +305,7 @@ export default {
}
const { ideEditPath, editBlobPath } = this.blobInfo;
- redirectTo(target === 'ide' ? ideEditPath : editBlobPath);
+ redirectTo(target === 'ide' ? ideEditPath : editBlobPath); // eslint-disable-line import/no-deprecated
},
setForkTarget(target) {
this.forkTarget = target;
@@ -316,9 +318,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 +395,11 @@ export default {
:wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
+ <ai-genie
+ v-if="explainCodeAvailable"
+ container-id="fileHolder"
+ :file-path="path"
+ class="gl-ml-7"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue
index 29c2c3762fc..460db0fe2ae 100644
--- a/app/assets/javascripts/repository/components/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/blob_controls.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import getRefMixin from '~/repository/mixins/get_ref';
import initSourcegraph from '~/sourcegraph';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
@@ -101,7 +101,6 @@ export default {
fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
// eslint-disable-next-line no-new
new ShortcutsBlob({
- skipResetBindings: true,
fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
});
diff --git a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
index 1114a0942ec..53dad19028d 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
@@ -1,11 +1,15 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import notebookLoader from '~/blob/notebook';
import { stripPathTail } from '~/lib/utils/url_utility';
+import NotebookViewer from '~/blob/notebook/notebook_viewer.vue';
export default {
- components: {
- GlLoadingIcon,
+ components: { NotebookViewer },
+ provide() {
+ // `relativeRawPath` is injected in app/assets/javascripts/notebook/cells/markdown.vue
+ // It is needed for images in Markdown cells that reference local files to work.
+ // See the following MR for more context:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69075
+ return { relativeRawPath: stripPathTail(this.url) };
},
props: {
blob: {
@@ -18,14 +22,9 @@ export default {
url: this.blob.rawPath,
};
},
- mounted() {
- notebookLoader({ el: this.$refs.viewer, relativeRawPath: stripPathTail(this.url) });
- },
};
</script>
<template>
- <div ref="viewer" :data-endpoint="url" data-testid="notebook">
- <gl-loading-icon class="gl-my-4" size="lg" />
- </div>
+ <notebook-viewer :endpoint="url" />
</template>
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index baf8449b188..cbdf6ef9ccd 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -101,23 +101,19 @@ export default {
primaryOptions() {
return {
text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
- attributes: [
- {
- variant: 'danger',
- loading: this.loading,
- disabled: this.loading || !this.form.state,
- },
- ],
+ attributes: {
+ variant: 'danger',
+ loading: this.loading,
+ disabled: this.loading || !this.form.state,
+ },
};
},
cancelOptions() {
return {
text: this.$options.i18n.SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
showCreateNewMrToggle() {
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 9804837b200..1da445a7906 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,8 +1,18 @@
<script>
-import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import { createAlert } from '~/flash';
+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';
export const i18n = {
forkedFrom: s__('ForkedFromProjectPath|Forked from'),
@@ -12,7 +22,14 @@ export const i18n = {
behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'),
ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'),
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.'),
+ updateFork: s__('ForksDivergence|Update fork'),
+ createMergeRequest: s__('ForksDivergence|Create merge request'),
+ viewMergeRequest: s__('ForksDivergence|View merge request'),
+ successMessage: s__(
+ 'ForksDivergence|Successfully fetched and merged from the upstream repository.',
+ ),
};
export default {
@@ -20,17 +37,19 @@ export default {
components: {
GlIcon,
GlLink,
+ GlButton,
GlSprintf,
GlSkeletonLoader,
+ ConflictsModal,
+ GlLoadingIcon,
},
+ mixins: [glFeatureFlagMixin()],
apollo: {
project: {
query: forkDetailsQuery,
+ notifyOnNetworkStatusChange: true,
variables() {
- return {
- projectPath: this.projectPath,
- ref: this.selectedBranch,
- };
+ return this.forkDetailsQueryVariables;
},
skip() {
return !this.sourceName;
@@ -42,6 +61,21 @@ export default {
error,
});
},
+ result({ 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;
+ },
},
},
props: {
@@ -53,6 +87,11 @@ export default {
type: String,
required: true,
},
+ sourceDefaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
sourceName: {
type: String,
required: false,
@@ -63,6 +102,11 @@ export default {
required: false,
default: '',
},
+ canSyncBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
aheadComparePath: {
type: String,
required: false,
@@ -73,21 +117,48 @@ export default {
required: false,
default: '',
},
+ createMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ viewMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- project: {
- forkDetails: {
- ahead: null,
- behind: null,
- },
- },
+ project: {},
+ currentPollInterval: null,
};
},
computed: {
+ forkDetailsQueryVariables() {
+ return {
+ projectPath: this.projectPath,
+ ref: this.selectedBranch,
+ };
+ },
+ pollInterval() {
+ return this.isSyncing ? this.currentPollInterval : 0;
+ },
isLoading() {
return this.$apollo.queries.project.loading;
},
+ forkDetails() {
+ return this.project?.forkDetails;
+ },
+ hasConflicts() {
+ return this.forkDetails?.hasConflicts;
+ },
+ isSyncing() {
+ return this.forkDetails?.isSyncing;
+ },
+ isForkUpdated() {
+ return this.isUpToDate && this.currentPollInterval;
+ },
ahead() {
return this.project?.forkDetails?.ahead;
},
@@ -107,7 +178,10 @@ export default {
});
},
isUnknownDivergence() {
- return (!this.ahead && this.ahead !== 0) || (!this.behind && this.behind !== 0);
+ return this.sourceName && this.ahead === null && this.behind === null;
+ },
+ isUpToDate() {
+ return this.ahead === 0 && this.behind === 0;
},
behindAheadMessage() {
const messages = [];
@@ -122,7 +196,23 @@ export default {
hasBehindAheadMessage() {
return this.behindAheadMessage.length > 0;
},
+ hasUpdateButton() {
+ return (
+ this.glFeatures.synchronizeFork &&
+ this.canSyncBranch &&
+ ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
+ );
+ },
+ hasCreateMrButton() {
+ return this.ahead && this.createMrPath;
+ },
+ hasViewMrButton() {
+ return this.viewMrPath;
+ },
forkDivergenceMessage() {
+ if (!this.forkDetails) {
+ return this.$options.i18n.limitedVisibility;
+ }
if (this.isUnknownDivergence) {
return this.$options.i18n.unknown;
}
@@ -134,6 +224,64 @@ export default {
return this.$options.i18n.upToDate;
},
},
+ watch: {
+ hasConflicts(newVal) {
+ if (newVal && this.currentPollInterval) {
+ this.showConflictsModal();
+ }
+ },
+ },
+ methods: {
+ async syncForkWithPolling() {
+ await this.$apollo.mutate({
+ mutation: syncForkMutation,
+ variables: {
+ projectPath: this.projectPath,
+ targetBranch: this.selectedBranch,
+ },
+ error(error) {
+ createAlert({
+ message: error.message,
+ captureError: true,
+ error,
+ });
+ },
+ update: (store, { data: { projectSyncFork } }) => {
+ const { details } = projectSyncFork;
+
+ store.writeQuery({
+ query: forkDetailsQuery,
+ variables: this.forkDetailsQueryVariables,
+ data: {
+ project: {
+ id: this.project.id,
+ forkDetails: details,
+ },
+ },
+ });
+ },
+ });
+ },
+ showConflictsModal() {
+ this.$refs.modal.show();
+ },
+ startSyncing() {
+ this.syncForkWithPolling();
+ },
+ checkIfSyncIsPossible() {
+ if (this.hasConflicts) {
+ this.showConflictsModal();
+ } else {
+ this.startSyncing();
+ }
+ },
+ 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;
+ },
+ },
};
</script>
@@ -141,23 +289,66 @@ export default {
<div class="info-well gl-sm-display-flex gl-flex-direction-column">
<div class="well-segment gl-p-5 gl-w-full gl-display-flex">
<gl-icon name="fork" :size="16" class="gl-display-block gl-m-4 gl-text-center" />
- <div v-if="sourceName">
- {{ $options.i18n.forkedFrom }}
- <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
- <gl-skeleton-loader v-if="isLoading" :lines="1" />
- <div v-else class="gl-text-secondary" data-testid="divergence-message">
- <gl-sprintf :message="forkDivergenceMessage">
- <template #aheadLink="{ content }">
- <gl-link :href="aheadComparePath">{{ content }}</gl-link>
- </template>
- <template #behindLink="{ content }">
- <gl-link :href="behindComparePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1"
+ >
+ <div v-if="sourceName">
+ {{ $options.i18n.forkedFrom }}
+ <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
+ <gl-skeleton-loader v-if="isLoading" :lines="1" />
+ <div v-else class="gl-text-secondary" data-testid="divergence-message">
+ <gl-sprintf :message="forkDivergenceMessage">
+ <template #aheadLink="{ content }">
+ <gl-link :href="aheadComparePath">{{ content }}</gl-link>
+ </template>
+ <template #behindLink="{ content }">
+ <gl-link :href="behindComparePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</div>
- </div>
- <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex">
- {{ $options.i18n.inaccessibleProject }}
+ <div
+ v-else
+ data-testid="inaccessible-project"
+ class="gl-align-items-center gl-display-flex"
+ >
+ {{ $options.i18n.inaccessibleProject }}
+ </div>
+ <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="hasViewMrButton"
+ class="gl-ml-4"
+ :href="viewMrPath"
+ data-testid="view-mr-button"
+ >
+ <span>{{ $options.i18n.viewMergeRequest }}</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"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
new file mode 100644
index 00000000000..ffe4fd4cd38
--- /dev/null
+++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
@@ -0,0 +1,137 @@
+<script>
+/* eslint-disable @gitlab/require-i18n-strings */
+import { GlModal, GlButton } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { getBaseURL } from '~/lib/utils/url_utility';
+
+export const i18n = {
+ modalTitle: s__('ForksDivergence|Resolve merge conflicts manually'),
+ modalMessage: s__(
+ 'ForksDivergence|The upstream changes could not be synchronized to this project due to file conflicts in the default branch. You must resolve the conflicts manually:',
+ ),
+ step1: __('Step 1.'),
+ step2: __('Step 2.'),
+ step3: __('Step 3.'),
+ step4: __('Step 4.'),
+ step1Text: s__(
+ "ForksDivergence|Fetch the latest changes from the upstream repository's default branch:",
+ ),
+ step2Text: s__(
+ "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:'),
+ copyToClipboard: __('Copy to clipboard'),
+ close: __('Close'),
+};
+
+export default {
+ name: 'ForkSyncConflictsModal',
+ components: {
+ GlModal,
+ GlButton,
+ ModalCopyButton,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ sourceDefaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourceName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourcePath: {
+ type: String,
+ 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() {
+ this.$refs.modal.show();
+ },
+ hide() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n,
+ instructionsStep3: 'git push',
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="fork-sync-conflicts-modal"
+ :title="$options.i18n.modalTitle"
+ size="md"
+ >
+ <p>{{ $options.i18n.modalMessage }}</p>
+ <p>
+ <b> {{ $options.i18n.step1 }}</b> {{ $options.i18n.modalMessage }}
+ </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">{{
+ instructionsStep1
+ }}</pre>
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="instructionsStep1"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p>
+ <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">{{
+ instructionsStep2
+ }}</pre>
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="instructionsStep2"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p>
+ <b> {{ $options.i18n.step3 }}</b> {{ $options.i18n.step3Text }}
+ </p>
+ <div class="gl-display-flex gl-mb-4">
+ <pre class="gl-w-full gl-mb-0" data-testid="resolve-conflict-instructions"
+ >{{ $options.instructionsStep3 }}
+</pre
+ >
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="$options.instructionsStep3"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3"
+ />
+ </div>
+ <template #modal-footer>
+ <gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 4d3c1521559..82dd1fda2a0 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -9,8 +9,11 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+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: {
@@ -23,6 +26,7 @@ export default {
GlLink,
GlLoadingIcon,
UserAvatarImage,
+ SignatureBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -95,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: {
@@ -170,10 +183,7 @@ export default {
<div
class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row"
>
- <div
- v-if="commit.signatureHtml"
- v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <signature-badge v-if="commit.signature" :signature="commit.signature" />
<div v-if="commit.pipeline" class="ci-status-link">
<gl-link
v-gl-tooltip.left
diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue
index b28ebe7bb1e..f36a700c902 100644
--- a/app/assets/javascripts/repository/components/new_directory_modal.vue
+++ b/app/assets/javascripts/repository/components/new_directory_modal.vue
@@ -8,7 +8,7 @@ import {
GlFormTextarea,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -93,23 +93,19 @@ export default {
primaryOptions() {
return {
text: this.primaryBtnText,
- attributes: [
- {
- variant: 'confirm',
- loading: this.loading,
- disabled: !this.formCompleted || this.loading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
showCreateNewMrToggle() {
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index f6d6004ba96..0c9b46344c5 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,10 +1,8 @@
<script>
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
-import { createAlert } from '~/flash';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { createAlert } from '~/alert';
import {
TREE_PAGE_SIZE,
- TREE_INITIAL_FETCH_COUNT,
TREE_PAGE_LIMIT,
COMMIT_BATCH_SIZE,
GITALY_UNAVAILABLE_CODE,
@@ -23,7 +21,7 @@ export default {
FileTable,
FilePreview,
},
- mixins: [getRefMixin, glFeatureFlagMixin()],
+ mixins: [getRefMixin],
apollo: {
projectPath: {
query: projectPathQuery,
@@ -59,13 +57,6 @@ export default {
};
},
computed: {
- pageSize() {
- // we want to exponentially increase the page size to reduce the load on the frontend
- const exponentialSize = (TREE_PAGE_SIZE / TREE_INITIAL_FETCH_COUNT) * (this.fetchCounter + 1);
- return exponentialSize < TREE_PAGE_SIZE && this.glFeatures.increasePageSizeExponentially
- ? exponentialSize
- : TREE_PAGE_SIZE;
- },
totalEntries() {
return Object.values(this.entries).flat().length;
},
@@ -110,7 +101,7 @@ export default {
ref: this.ref,
path: originalPath,
nextPageCursor: this.nextPageCursor,
- pageSize: this.pageSize,
+ pageSize: TREE_PAGE_SIZE,
},
})
.then(({ data }) => {
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 4603ea2710d..4ca625bc0de 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -9,7 +9,7 @@ import {
GlButton,
GlAlert,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -106,23 +106,19 @@ export default {
primaryOptions() {
return {
text: this.primaryBtnText,
- attributes: [
- {
- variant: 'confirm',
- loading: this.loading,
- disabled: !this.formCompleted || this.loading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
formattedFileSize() {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 5098053c4f7..b711f671850 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -3,7 +3,6 @@ import { __ } from '~/locale';
export const GITALY_UNAVAILABLE_CODE = 'unavailable';
export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
-export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25
@@ -106,3 +105,12 @@ export const i18n = {
generalError: __('An error occurred while fetching folder content.'),
gitalyError: __('Error: Gitaly is unavailable. Contact your administrator.'),
};
+
+export const FIVE_MINUTES_IN_MS = 1000 * 60 * 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 95e0c94527b..befd731a61b 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({
@@ -69,19 +78,33 @@ export default function setupVueRepositoryList() {
if (!forkEl) {
return null;
}
- const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset;
+ const {
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ createMrPath,
+ viewMrPath,
+ canSyncBranch,
+ aheadComparePath,
+ behindComparePath,
+ } = forkEl.dataset;
return new Vue({
el: forkEl,
apolloProvider,
render(h) {
return h(ForkInfo, {
props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
projectPath,
- selectedBranch: ref,
+ selectedBranch,
sourceName,
sourcePath,
+ sourceDefaultBranch,
aheadComparePath,
behindComparePath,
+ createMrPath,
+ viewMrPath,
},
});
},
@@ -131,6 +154,7 @@ export default function setupVueRepositoryList() {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -144,8 +168,8 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
initBlobControlsApp();
- initForkInfo();
initRefSwitcher();
+ initForkInfo();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
@@ -266,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/repository/mutations/sync_fork.mutation.graphql b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql
new file mode 100644
index 00000000000..b3426038694
--- /dev/null
+++ b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql
@@ -0,0 +1,11 @@
+mutation syncFork($projectPath: ID!, $targetBranch: String!) {
+ projectSyncFork(input: { projectPath: $projectPath, targetBranch: $targetBranch }) {
+ details {
+ ahead
+ behind
+ isSyncing
+ hasConflicts
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/fork_details.query.graphql b/app/assets/javascripts/repository/queries/fork_details.query.graphql
index d1a37d00d55..3d37f69b48d 100644
--- a/app/assets/javascripts/repository/queries/fork_details.query.graphql
+++ b/app/assets/javascripts/repository/queries/fork_details.query.graphql
@@ -4,6 +4,8 @@ query getForkDetails($projectPath: ID!, $ref: String) {
forkDetails(ref: $ref) {
ahead
behind
+ isSyncing
+ hasConflicts
}
}
}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 3256e13f4da..58e4553d00d 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -56,8 +56,10 @@ Sidebar.prototype.addEventListeners = function () {
const layoutPage = document.querySelector('.layout-page');
const rightSidebar = document.querySelector('.js-right-sidebar');
- updateSidebarClasses(layoutPage, rightSidebar);
- window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ if (rightSidebar.classList.contains('right-sidebar-merge-requests')) {
+ updateSidebarClasses(layoutPage, rightSidebar);
+ window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ }
}
};
@@ -70,7 +72,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
const $expandIcon = $('.js-sidebar-expand');
const $toggleContainer = $('.js-sidebar-toggle-container');
const isExpanded = $toggleContainer.data('is-expanded');
- const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
+ const tooltipLabel = isExpanded ? __('Collapse sidebar') : __('Expand sidebar');
e.preventDefault();
if (isExpanded) {
diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue
deleted file mode 100644
index 30089cfa53f..00000000000
--- a/app/assets/javascripts/saved_replies/components/list.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
-import savedRepliesQuery from '../queries/saved_replies.query.graphql';
-import ListItem from './list_item.vue';
-
-export default {
- apollo: {
- savedReplies: {
- query: savedRepliesQuery,
- update: (r) => r.currentUser?.savedReplies?.nodes,
- result({ data }) {
- const pageInfo = data.currentUser?.savedReplies?.pageInfo;
-
- this.count = data.currentUser?.savedReplies?.count;
-
- if (pageInfo) {
- this.pageInfo = pageInfo;
- }
- },
- },
- },
- components: {
- GlLoadingIcon,
- GlKeysetPagination,
- GlSprintf,
- ListItem,
- },
- data() {
- return {
- savedReplies: [],
- count: 0,
- pageInfo: {},
- };
- },
-};
-</script>
-
-<template>
- <div>
- <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" />
- <template v-else>
- <h5 class="gl-font-lg" data-testid="title">
- <gl-sprintf :message="__('My saved replies (%{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" />
- </ul>
- <gl-keyset-pagination
- v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
- v-bind="pageInfo"
- class="gl-mt-4"
- />
- </template>
- </div>
-</template>
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 dfa9a405dee..00000000000
--- a/app/assets/javascripts/saved_replies/components/list_item.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-export default {
- props: {
- reply: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <li class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center">
- <strong>{{ reply.name }}</strong>
- </div>
- <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
- </li>
-</template>
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue
deleted file mode 100644
index 38f51dbc365..00000000000
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
-import List from '../components/list.vue';
-
-export default {
- components: {
- List,
- },
-};
-</script>
-
-<template>
- <div>
- <list />
- </div>
-</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 2efc80fef75..317145d4cd1 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,33 +1,49 @@
<script>
-import { mapState } from 'vuex';
+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.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS;
+ return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
},
showBlobFilter() {
- return this.urlQuery.scope === SCOPE_BLOB && this.glFeatures.searchBlobsLanguageAggregation;
+ return this.currentScope === SCOPE_BLOB;
+ },
+ showOldNavigation() {
+ return Boolean(this.currentScope);
},
},
};
</script>
<template>
- <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5">
+ <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 />
<results-filters v-if="showIssueAndMergeFilters" />
<language-filter v-if="showBlobFilter" />
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
index b580d58b21b..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 } from 'vuex';
+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: {
@@ -12,31 +17,33 @@ export default {
GlFormCheckbox,
},
props: {
- filterData: {
+ filtersData: {
type: Object,
required: true,
},
+ trackingNamespace: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['query']),
- scope() {
- return this.query.scope;
- },
- queryFilters() {
- return this.query[this.filterData?.filterParam] || [];
- },
+ ...mapState(['query', 'useNewNavigation']),
+ ...mapGetters(['queryLanguageFilters']),
dataFilters() {
- return Object.values(this.filterData?.filters || []);
+ return Object.values(this.filtersData?.filters || []);
},
flatDataFilterValues() {
return this.dataFilters.map(({ value }) => value);
},
selectedFilter: {
get() {
- return intersection(this.flatDataFilterValues, this.queryFilters);
+ return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
},
- set(value) {
- this.setQuery({ key: this.filterData?.filterParam, value });
+ async set(value) {
+ this.setQuery({ key: this.filtersData?.filterParam, value });
+
+ await Vue.nextTick();
+ this.trackSelectCheckbox();
},
},
labelCountClasses() {
@@ -45,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,
@@ -56,7 +69,7 @@ export default {
<template>
<div class="gl-mx-5">
- <h5 class="gl-mt-0">{{ filterData.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"
@@ -72,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 26ce204cb5c..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',
@@ -27,19 +35,24 @@ export default {
apply: __('Apply'),
showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }),
loadError: s__('GlobalSearch|Aggregations load error.'),
+ reset: s__('GlobalSearch|Reset filters'),
},
computed: {
- ...mapState(['aggregations', 'sidebarDirty']),
- ...mapGetters(['langugageAggregationBuckets']),
+ ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']),
+ ...mapGetters([
+ 'languageAggregationBuckets',
+ 'currentUrlQueryHasLanguageFilters',
+ 'queryLanguageFilters',
+ ]),
hasBuckets() {
- return this.langugageAggregationBuckets.length > 0;
+ return this.languageAggregationBuckets.length > 0;
},
filtersData() {
return convertFiltersData(this.shortenedLanguageFilters);
},
shortenedLanguageFilters() {
if (!this.hasShowMore) {
- return this.langugageAggregationBuckets;
+ return this.languageAggregationBuckets;
}
if (this.showAll) {
return this.trimBuckets(MAX_ITEM_LENGTH);
@@ -47,28 +60,54 @@ export default {
return this.trimBuckets(DEFAULT_ITEM_LENGTH);
},
hasShowMore() {
- return this.langugageAggregationBuckets.length > DEFAULT_ITEM_LENGTH;
+ return this.languageAggregationBuckets.length > DEFAULT_ITEM_LENGTH;
},
hasOverMax() {
- return this.langugageAggregationBuckets.length > MAX_ITEM_LENGTH;
+ return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH;
},
dividerClasses() {
return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
},
+ hasQueryFilters() {
+ return this.queryLanguageFilters.length > 0;
+ },
},
async created() {
await this.fetchLanguageAggregation();
},
methods: {
- ...mapActions(['applyQuery', 'fetchLanguageAggregation']),
+ ...mapActions([
+ 'applyQuery',
+ 'resetLanguageQuery',
+ 'resetLanguageQueryWithRedirect',
+ 'fetchLanguageAggregation',
+ ]),
onShowMore() {
this.showAll = true;
+ trackShowMore();
+
+ if (this.hasOverMax) {
+ trackShowHasOverMax();
+ }
+ },
+ submitQuery() {
+ trackSubmitQuery();
+ this.applyQuery();
},
trimBuckets(length) {
- return this.langugageAggregationBuckets.slice(0, length);
+ return this.languageAggregationBuckets.slice(0, length);
+ },
+ cleanResetFilters() {
+ trackResetQuery();
+ if (this.currentUrlQueryHasLanguageFilters) {
+ return this.resetLanguageQueryWithRedirect();
+ }
+ this.showAll = false;
+ return this.resetLanguageQuery();
},
},
HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_SELECT,
};
</script>
@@ -76,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 class="gl-px-5" :filter-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>
@@ -105,8 +147,10 @@ export default {
</gl-button>
</div>
<div v-if="!aggregations.error">
- <hr :class="$options.HR_DEFAULT_CLASSES" />
- <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-mx-5 gl-px-5">
+ <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"
+ >
<gl-button
category="primary"
variant="confirm"
@@ -116,6 +160,16 @@ export default {
>
{{ $options.i18n.apply }}
</gl-button>
+ <gl-button
+ category="tertiary"
+ variant="link"
+ size="small"
+ :disabled="!hasQueryFilters && !sidebarDirty"
+ data-testid="reset-button"
+ @click="cleanResetFilters"
+ >
+ {{ $options.i18n.reset }}
+ </gl-button>
</div>
</div>
</gl-form>
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 aa7c26b8044..477ba37dab7 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
export default {
@@ -16,13 +16,11 @@ export default {
},
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
+ ...mapGetters(['currentScope']),
ANY() {
return this.filterData.filters.ANY;
},
- scope() {
- return this.query.scope;
- },
initialFilter() {
return this.query[this.filterData.filterParam];
},
@@ -30,7 +28,7 @@ export default {
return this.initialFilter || this.ANY.value;
},
filtersArray() {
- return this.filterData.filterByScope[this.scope];
+ return this.filterData.filterByScope[this.currentScope];
},
selectedFilter: {
get() {
@@ -58,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 4d9cc9d6450..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 } from 'vuex';
+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,15 +16,19 @@ export default {
ConfidentialityFilter,
},
computed: {
- ...mapState(['urlQuery', 'sidebarDirty']),
+ ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
+ ...mapGetters(['currentScope']),
showReset() {
return this.urlQuery.state || this.urlQuery.confidential;
},
showConfidentialityFilter() {
- return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope);
+ return Object.values(confidentialFilterData.scopes).includes(this.currentScope);
},
showStatusFilter() {
- return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope);
+ return Object.values(stateFilterData.scopes).includes(this.currentScope);
+ },
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
},
},
methods: {
@@ -34,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 5863381e2ef..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}` });
@@ -44,9 +46,6 @@ export default {
isHighlighted ? 'gl-text-gray-900' : 'gl-text-gray-500',
];
},
- isActive(scope, index) {
- return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0;
- },
qaSelectorValue(item) {
return `${slugifyWithUnderscore(item.label)}_tab`;
},
@@ -60,16 +59,17 @@ export default {
<nav data-testid="search-filter">
<gl-nav vertical pills>
<gl-nav-item
- v-for="(item, scope, index) in navigation"
+ v-for="(item, scope) in navigation"
:key="scope"
- :link-classes="linkClasses(isActive(scope, index))"
+ :link-classes="linkClasses(item.active)"
class="gl-mb-1"
:href="item.link"
- :active="isActive(scope, index)"
+ :active="item.active"
:data-qa-selector="qaSelectorValue(item)"
+ :data-testid="qaSelectorValue(item)"
@click="handleClick(scope)"
- ><span>{{ item.label }}</span
- ><span v-if="item.count" :class="countClasses(isActive(scope, index))">
+ ><span data-testid="label">{{ item.label }}</span
+ ><span v-if="item.count" data-testid="count" :class="countClasses(item.active)">
{{ showFormatedCount(item.count)
}}<gl-icon
v-if="isCountOverLimit(item.count)"
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 5c08ad2f959..78e03fcdeee 100644
--- a/app/assets/javascripts/search/sidebar/utils.js
+++ b/app/assets/javascripts/search/sidebar/utils.js
@@ -1,20 +1,17 @@
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
-export const convertFiltersData = (rawBuckets) => {
- return rawBuckets.reduce(
- (acc, bucket) => {
- return {
- ...acc,
- filters: {
- ...acc.filters,
- [bucket.key.toUpperCase()]: {
- label: bucket.key,
- value: bucket.key,
- count: bucket.count,
- },
+export const convertFiltersData = (rawBuckets) =>
+ rawBuckets.reduce(
+ (acc, bucket) => ({
+ ...acc,
+ filters: {
+ ...acc.filters,
+ [bucket.key.toUpperCase()]: {
+ label: bucket.key,
+ value: bucket.key,
+ count: bucket.count,
},
- };
- },
+ },
+ }),
{ ...languageFilterData, filters: {} },
);
-};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index fc0817be882..3d6ca2a6eee 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,9 +1,10 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
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/components/language_filter/data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
import {
@@ -12,6 +13,7 @@ import {
mergeById,
isSidebarDirty,
getAggregationsUrl,
+ prepareSearchAggregations,
} from './utils';
export const fetchGroups = ({ commit }, search) => {
@@ -105,17 +107,27 @@ export const applyQuery = ({ state }) => {
};
export const resetQuery = ({ state }) => {
- visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
+ visitUrl(
+ setUrlParams({ ...state.query, page: null, state: null, confidential: null }, undefined, true),
+ );
+};
+
+export const resetLanguageQueryWithRedirect = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true));
+};
+
+export const resetLanguageQuery = ({ commit }) => {
+ commit(types.SET_QUERY, { key: languageFilterData?.filterParam, value: [] });
};
export const fetchSidebarCount = ({ commit, state }) => {
- const promises = Object.keys(state.navigation).map((scope) => {
+ const promises = Object.values(state.navigation).map((navItem) => {
// active nav item has count already so we skip it
- if (scope !== state.urlQuery.scope) {
+ if (!navItem.active) {
return axios
- .get(state.navigation[scope].count_link)
+ .get(navItem.count_link)
.then(({ data: { count } }) => {
- commit(types.RECEIVE_NAVIGATION_COUNT, { key: scope, count });
+ commit(types.RECEIVE_NAVIGATION_COUNT, { key: navItem.scope, count });
})
.catch((e) => logError(e));
}
@@ -124,12 +136,12 @@ export const fetchSidebarCount = ({ commit, state }) => {
return Promise.all(promises);
};
-export const fetchLanguageAggregation = ({ commit }) => {
+export const fetchLanguageAggregation = ({ commit, state }) => {
commit(types.REQUEST_AGGREGATIONS);
return axios
.get(getAggregationsUrl())
.then(({ data }) => {
- commit(types.RECEIVE_AGGREGATIONS_SUCCESS, data);
+ commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data));
})
.catch((e) => {
logError(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 0278239c144..135c9a3d67c 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,5 +1,8 @@
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
-import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+import { findKey, has } from 'lodash';
+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, ICON_MAP } from './constants';
export const frequentGroups = (state) => {
return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
@@ -9,10 +12,28 @@ export const frequentProjects = (state) => {
return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY];
};
-export const langugageAggregationBuckets = (state) => {
+export const languageAggregationBuckets = (state) => {
return (
state.aggregations.data.find(
(aggregation) => aggregation.name === languageFilterData.filterParam,
)?.buckets || []
);
};
+
+export const currentScope = (state) => findKey(state.navigation, { active: true });
+
+export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || [];
+
+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/mutations.js b/app/assets/javascripts/search/store/mutations.js
index f9fd69d2211..b2f9f5ab225 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -24,7 +24,7 @@ export default {
state.projects = [];
},
[types.SET_QUERY](state, { key, value }) {
- state.query[key] = value;
+ state.query = { ...state.query, [key]: value };
},
[types.SET_SIDEBAR_DIRTY](state, value) {
state.sidebarDirty = value;
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 acb99c60426..2f02ef3475c 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -1,6 +1,8 @@
+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/components/language_filter/data';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
@@ -8,6 +10,8 @@ import {
NUMBER_FORMATING_OPTIONS,
} from './constants';
+const LANGUAGE_AGGREGATION_NAME = languageFilterData.filterParam;
+
function extractKeys(object, keyList) {
return Object.fromEntries(keyList.map((key) => [key, object[key]]));
}
@@ -94,6 +98,10 @@ export const isSidebarDirty = (currentQuery, urlQuery) => {
const userAddedParam = !urlQuery[param] && currentQuery[param];
const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param];
+ if (Array.isArray(currentQuery[param]) || Array.isArray(urlQuery[param])) {
+ return !isEqual(currentQuery[param], urlQuery[param]);
+ }
+
return userAddedParam || userChangedExistingParam;
});
};
@@ -112,3 +120,31 @@ export const getAggregationsUrl = () => {
currentUrl.pathname = joinPaths('/search', 'aggregations');
return currentUrl.toString();
};
+
+const sortLanguages = (state, entries) => {
+ const queriedLanguages = state.query?.[LANGUAGE_AGGREGATION_NAME] || [];
+
+ if (!Array.isArray(queriedLanguages) || !queriedLanguages.length) {
+ return entries;
+ }
+
+ const queriedLanguagesSet = new Set(queriedLanguages);
+
+ return orderBy(entries, [({ key }) => queriedLanguagesSet.has(key), 'count'], ['desc', 'desc']);
+};
+
+export const prepareSearchAggregations = (state, aggregationData) =>
+ aggregationData.map((item) => {
+ if (item?.name === LANGUAGE_AGGREGATION_NAME) {
+ return {
+ ...item,
+ buckets: sortLanguages(state, item.buckets),
+ };
+ }
+
+ return item;
+ });
+
+export const addCountOverLimit = (count = '') => {
+ return count.includes('+') ? '+' : '';
+};
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index da6039f4758..16ff8c94885 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -86,45 +86,50 @@ export default {
</script>
<template>
- <section class="search-page-form gl-lg-display-flex gl-flex-direction-column">
- <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end">
- <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
- <div
- class="gl-sm-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-4 gl-md-mb-0"
- >
- <label>{{ $options.i18n.searchLabel }}</label>
- <template v-if="showSyntaxOptions">
- <gl-button
- category="tertiary"
- variant="link"
- size="small"
- button-text-classes="gl-font-sm!"
- @click="onToggleDrawer"
- >{{ $options.i18n.syntaxOptionsLabel }}
- </gl-button>
- <markdown-drawer
- ref="markdownDrawer"
- :document-path="$options.SYNTAX_OPTIONS_DOCUMENT"
- />
- </template>
+ <section class="gl-p-5 gl-bg-gray-10 gl-border-b gl-border-t">
+ <div class="search-page-form gl-lg-display-flex gl-flex-direction-column">
+ <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end">
+ <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
+ <div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-0 gl-md-mb-4"
+ >
+ <label class="gl-mb-1 gl-md-pb-2">{{ $options.i18n.searchLabel }}</label>
+ <template v-if="showSyntaxOptions">
+ <gl-button
+ category="tertiary"
+ variant="link"
+ size="small"
+ button-text-classes="gl-font-sm!"
+ @click="onToggleDrawer"
+ >{{ $options.i18n.syntaxOptionsLabel }}
+ </gl-button>
+ <markdown-drawer
+ ref="markdownDrawer"
+ :document-path="$options.SYNTAX_OPTIONS_DOCUMENT"
+ />
+ </template>
+ </div>
+ <gl-search-box-by-click
+ id="dashboard_search"
+ v-model="search"
+ name="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @submit="applyQuery"
+ />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3">
+ <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{
+ $options.i18n.groupFieldLabel
+ }}</label>
+ <group-filter :initial-data="groupInitialJson" />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3">
+ <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{
+ $options.i18n.projectFieldLabel
+ }}</label>
+ <project-filter :initial-data="projectInitialJson" />
</div>
- <gl-search-box-by-click
- id="dashboard_search"
- v-model="search"
- name="search"
- :placeholder="$options.i18n.searchPlaceholder"
- @submit="applyQuery"
- />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3">
- <label class="gl-display-block">{{ $options.i18n.groupFieldLabel }}</label>
- <group-filter :initial-data="groupInitialJson" />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3">
- <label class="gl-display-block">{{ $options.i18n.projectFieldLabel }}</label>
- <project-filter :initial-data="projectInitialJson" />
</div>
</div>
- <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100" />
</section>
</template>
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 3ebd21609a6..d57b3fda342 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -10,10 +10,8 @@ import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue';
import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants';
import FeatureCard from './feature_card.vue';
import TrainingProviderList from './training_provider_list.vue';
-import UpgradeBanner from './upgrade_banner.vue';
export const i18n = {
- compliance: s__('SecurityConfiguration|Compliance'),
configurationHistory: s__('SecurityConfiguration|Configuration history'),
securityTesting: s__('SecurityConfiguration|Security testing'),
latestPipelineDescription: s__(
@@ -26,7 +24,7 @@ export const i18n = {
scanner will not be reflected as such until the pipeline has been
successfully executed and it has generated valid artifacts.`,
),
- securityConfiguration: __('Security Configuration'),
+ securityConfiguration: __('Security configuration'),
vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'),
securityTraining: s__('SecurityConfiguration|Security training'),
securityTrainingDescription: s__(
@@ -48,7 +46,8 @@ export default {
GlTabs,
LocalStorageSync,
SectionLayout,
- UpgradeBanner,
+ UpgradeBanner: () =>
+ import('ee_component/security_configuration/components/upgrade_banner.vue'),
UserCalloutDismisser,
TrainingProviderList,
},
@@ -59,10 +58,6 @@ export default {
type: Array,
required: true,
},
- augmentedComplianceFeatures: {
- type: Array,
- required: true,
- },
gitlabCiPresent: {
type: Boolean,
required: false,
@@ -101,9 +96,7 @@ export default {
},
computed: {
canUpgrade() {
- return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some(
- ({ available }) => !available,
- );
+ return [...this.augmentedSecurityFeatures].some(({ available }) => !available);
},
canViewCiHistory() {
return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath);
@@ -226,44 +219,6 @@ export default {
</section-layout>
</gl-tab>
<gl-tab
- data-testid="compliance-testing-tab"
- :title="$options.i18n.compliance"
- query-param-value="compliance-testing"
- >
- <section-layout :heading="$options.i18n.compliance">
- <template #description>
- <p>
- <span data-testid="latest-pipeline-info-compliance">
- <gl-sprintf
- v-if="latestPipelinePath"
- :message="$options.i18n.latestPipelineDescription"
- >
- <template #link="{ content }">
- <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
-
- {{ $options.i18n.description }}
- </p>
- <p v-if="canViewCiHistory">
- <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{
- $options.i18n.configurationHistory
- }}</gl-link>
- </p>
- </template>
- <template #features>
- <feature-card
- v-for="feature in augmentedComplianceFeatures"
- :key="feature.type"
- :feature="feature"
- class="gl-mb-6"
- @error="onError"
- />
- </template>
- </section-layout>
- </gl-tab>
- <gl-tab
data-testid="vulnerability-management-tab"
:title="$options.i18n.vulnerabilityManagement"
query-param-value="vulnerability-management"
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index c87dcef6a93..1b86d7d0a2b 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -6,17 +6,18 @@ 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,
REPORT_TYPE_COVERAGE_FUZZING,
REPORT_TYPE_CORPUS_MANAGEMENT,
REPORT_TYPE_API_FUZZING,
- REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
import kontraLogo from 'images/vulnerability/kontra-logo.svg';
import scwLogo from 'images/vulnerability/scw-logo.svg';
+import secureflagLogo from 'images/vulnerability/secureflag-logo.svg';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
@@ -35,7 +36,7 @@ export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sas
});
export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning');
-export const SAST_IAC_SHORT_NAME = s__('ciReport|IaC Scanning');
+export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC');
export const SAST_IAC_DESCRIPTION = __(
'Analyze your infrastructure as code configuration files for known vulnerabilities.',
);
@@ -65,9 +66,32 @@ export const DAST_PROFILES_NAME = __('DAST profiles');
export const DAST_PROFILES_DESCRIPTION = s__(
'SecurityConfiguration|Manage profiles for use by DAST scans.',
);
-export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
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.',
@@ -126,13 +150,7 @@ export const API_FUZZING_NAME = __('API Fuzzing');
export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
-export const LICENSE_COMPLIANCE_NAME = __('License Compliance');
-export const LICENSE_COMPLIANCE_DESCRIPTION = __(
- 'Search your project dependencies for their licenses and apply policies.',
-);
-export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath(
- 'user/compliance/license_compliance/index',
-);
+export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning');
export const SCANNER_NAMES_MAP = {
SAST: SAST_SHORT_NAME,
@@ -143,6 +161,8 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ BREACH_AND_ATTACK_SIMULATION: BAS_NAME,
+ CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME,
GENERIC: s__('ciReport|Manually added'),
};
@@ -224,14 +244,24 @@ export const securityFeatures = [
configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
},
},
-];
-
-export const complianceFeatures = [
{
- name: LICENSE_COMPLIANCE_NAME,
- description: LICENSE_COMPLIANCE_DESCRIPTION,
- helpPath: LICENSE_COMPLIANCE_HELP_PATH,
- type: REPORT_TYPE_LICENSE_COMPLIANCE,
+ 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,
},
];
@@ -284,6 +314,9 @@ export const TEMP_PROVIDER_LOGOS = {
[__('Secure Code Warrior')]: {
svg: scwLogo,
},
+ SecureFlag: {
+ svg: secureflagLogo,
+ },
};
// Use the `url` field from the GraphQL query once this issue is resolved
@@ -291,4 +324,5 @@ export const TEMP_PROVIDER_LOGOS = {
export const TEMP_PROVIDER_URLS = {
Kontra: 'https://application.security/',
[__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/',
+ SecureFlag: 'https://www.secureflag.com/',
};
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/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
deleted file mode 100644
index eaff1ce6055..00000000000
--- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-import { GlBanner } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import Tracking from '~/tracking';
-
-export const SECURITY_UPGRADE_BANNER = 'security_upgrade_banner';
-export const UPGRADE_OR_FREE_TRIAL = 'upgrade_or_free_trial';
-
-export default {
- components: {
- GlBanner,
- },
- mixins: [Tracking.mixin({ property: SECURITY_UPGRADE_BANNER })],
- inject: ['upgradePath'],
- i18n: {
- title: s__('SecurityConfiguration|Secure your project'),
- bodyStart: s__(
- `SecurityConfiguration|Immediately begin risk analysis and remediation with application security features. Start with SAST and Secret Detection, available to all plans. Upgrade to Ultimate to get all features, including:`,
- ),
- bodyListItems: [
- s__('SecurityConfiguration|Vulnerability details and statistics in the merge request'),
- s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups'),
- s__('SecurityConfiguration|Runtime security metrics for application environments'),
- s__(
- 'SecurityConfiguration|More scan types, including DAST, Dependency Scanning, Fuzzing, and Licence Compliance',
- ),
- ],
- buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'),
- },
- mounted() {
- this.track('render', { label: SECURITY_UPGRADE_BANNER });
- },
- methods: {
- bannerClosed() {
- this.track('dismiss_banner', { label: SECURITY_UPGRADE_BANNER });
- },
- bannerButtonClicked() {
- this.track('click_button', { label: UPGRADE_OR_FREE_TRIAL });
- },
- },
-};
-</script>
-
-<template>
- <gl-banner
- :title="$options.i18n.title"
- :button-text="$options.i18n.buttonText"
- :button-link="upgradePath"
- variant="introduction"
- @close="bannerClosed"
- @primary="bannerButtonClicked"
- v-on="$listeners"
- >
- <p>{{ $options.i18n.bodyStart }}</p>
- <ul class="gl-pl-6">
- <li v-for="bodyListItem in $options.i18n.bodyListItems" :key="bodyListItem">
- {{ bodyListItem }}
- </li>
- </ul>
- </gl-banner>
-</template>
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 637d510e684..aa3c9c87622 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
-import { securityFeatures, complianceFeatures } from './components/constants';
+import { securityFeatures } from './components/constants';
import { augmentFeatures } from './utils';
export const initSecurityConfiguration = (el) => {
@@ -28,9 +28,8 @@ export const initSecurityConfiguration = (el) => {
vulnerabilityTrainingDocsPath,
} = el.dataset;
- const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures(
+ const { augmentedSecurityFeatures } = augmentFeatures(
securityFeatures,
- complianceFeatures,
features ? JSON.parse(features) : [],
);
@@ -48,7 +47,6 @@ export const initSecurityConfiguration = (el) => {
render(createElement) {
return createElement(SecurityConfigurationApp, {
props: {
- augmentedComplianceFeatures,
augmentedSecurityFeatures,
latestPipelinePath,
gitlabCiHistoryPath,
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index df23698ba7e..72e6d870e13 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -2,19 +2,18 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
/**
- * This function takes in 3 arrays of objects, securityFeatures, complianceFeatures and features.
- * securityFeatures and complianceFeatures are static arrays living in the constants.
+ * This function takes in 3 arrays of objects, securityFeatures and features.
+ * securityFeatures are static arrays living in the constants.
* features is dynamic and coming from the backend.
* This function builds a superset of those arrays.
* It looks for matching keys within the dynamic and the static arrays
* and will enrich the objects with the available static data.
* @param [{}] securityFeatures
- * @param [{}] complianceFeatures
* @param [{}] features
* @returns {Object} Object with enriched features from constants divided into Security and Compliance Features
*/
-export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
+export const augmentFeatures = (securityFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
return acc;
@@ -39,7 +38,6 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features =
return {
augmentedSecurityFeatures: securityFeatures.map((feature) => augmentFeature(feature)),
- augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)),
};
};
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
deleted file mode 100644
index d9e969e2278..00000000000
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<script>
-import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink } from '@gitlab/ui';
-import Vue from 'vue';
-import { mapState, mapActions } from 'vuex';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
-import { __, s__, sprintf } from '~/locale';
-
-Vue.use(GlToast);
-
-export default {
- components: {
- GlFormGroup,
- GlButton,
- GlModal,
- GlToggle,
- GlLink,
- },
- directives: {
- SafeHtml,
- },
- formLabels: {
- createProject: __('Self-monitoring'),
- },
- data() {
- return {
- modalId: 'delete-self-monitor-modal',
- };
- },
- computed: {
- ...mapState('selfMonitoring', [
- 'projectEnabled',
- 'projectCreated',
- 'showAlert',
- 'projectPath',
- 'loading',
- 'alertContent',
- ]),
- selfMonitorEnabled: {
- get() {
- return this.projectEnabled;
- },
- set(projectEnabled) {
- this.setSelfMonitor(projectEnabled);
- },
- },
- selfMonitorProjectFullUrl() {
- return `${getBaseURL()}/${this.projectPath}`;
- },
- selfMonitoringFormText() {
- if (this.projectCreated) {
- return sprintf(
- s__(
- 'SelfMonitoring|Self-monitoring is active. Use the %{projectLinkStart}self-monitoring project%{projectLinkEnd} to monitor the health of your instance.',
- ),
- {
- projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`,
- projectLinkEnd: '</a>',
- },
- false,
- );
- }
-
- return s__(
- 'SelfMonitoring|Activate self-monitoring to create a project to use to monitor the health of your instance.',
- );
- },
- helpDocsPath() {
- return helpPagePath('administration/monitoring/gitlab_self_monitoring_project/index');
- },
- },
- watch: {
- selfMonitorEnabled() {
- this.saveChangesSelfMonitorProject();
- },
- showAlert() {
- let toastOptions = {
- onComplete: () => {
- this.resetAlert();
- },
- };
-
- if (this.showAlert) {
- if (this.alertContent.actionName && this.alertContent.actionName.length > 0) {
- toastOptions = {
- ...toastOptions,
- action: {
- text: this.alertContent.actionText,
- onClick: (_, toastObject) => {
- this[this.alertContent.actionName]();
- toastObject.hide();
- },
- },
- };
- }
- this.$toast.show(this.alertContent.message, toastOptions);
- }
- },
- },
- methods: {
- ...mapActions('selfMonitoring', [
- 'setSelfMonitor',
- 'createProject',
- 'deleteProject',
- 'resetAlert',
- ]),
- hideSelfMonitorModal() {
- this.$root.$emit(BV_HIDE_MODAL, this.modalId);
- this.setSelfMonitor(true);
- },
- showSelfMonitorModal() {
- this.$root.$emit(BV_SHOW_MODAL, this.modalId);
- },
- saveChangesSelfMonitorProject() {
- if (this.projectCreated && !this.projectEnabled) {
- this.showSelfMonitorModal();
- } else if (!this.projectCreated && !this.loading) {
- this.createProject();
- }
- },
- viewSelfMonitorProject() {
- visitUrl(this.selfMonitorProjectFullUrl);
- },
- },
-};
-</script>
-<template>
- <section class="settings no-animate js-self-monitoring-settings">
- <div class="settings-header">
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
- {{ s__('SelfMonitoring|Self-monitoring') }}
- </h4>
- <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
- <p class="js-section-sub-header">
- {{ s__('SelfMonitoring|Activate or deactivate instance self-monitoring.') }}
- <gl-link :href="helpDocsPath">{{ __('Learn more.') }}</gl-link>
- </p>
- </div>
- <div class="settings-content">
- <form name="self-monitoring-form">
- <p ref="selfMonitoringFormText" v-safe-html="selfMonitoringFormText"></p>
- <gl-form-group>
- <gl-toggle
- v-model="selfMonitorEnabled"
- :is-loading="loading"
- :label="$options.formLabels.createProject"
- />
- </gl-form-group>
- </form>
- </div>
- <gl-modal
- :title="s__('SelfMonitoring|Deactivate self-monitoring?')"
- :modal-id="modalId"
- :ok-title="__('Delete self-monitoring project')"
- :cancel-title="__('Cancel')"
- ok-variant="danger"
- category="primary"
- @ok="deleteProject"
- @cancel="hideSelfMonitorModal"
- >
- <div>
- {{
- s__(
- 'SelfMonitoring|Deactivating self-monitoring deletes the self-monitoring project. Are you sure you want to deactivate self-monitoring and delete the project?',
- )
- }}
- </div>
- </gl-modal>
- </section>
-</template>
diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js
deleted file mode 100644
index 384b73e528e..00000000000
--- a/app/assets/javascripts/self_monitor/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import SelfMonitorForm from './components/self_monitor_form.vue';
-import store from './store';
-
-export default () => {
- const el = document.querySelector('.js-self-monitoring-settings');
-
- if (el) {
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store: store({
- ...el.dataset,
- }),
- render(createElement) {
- return createElement(SelfMonitorForm);
- },
- });
- }
-};
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
deleted file mode 100644
index d94fd77dd42..00000000000
--- a/app/assets/javascripts/self_monitor/store/actions.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import { backOff } from '~/lib/utils/common_utils';
-import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { __, s__ } from '~/locale';
-import * as types from './mutation_types';
-
-const TWO_MINUTES = 120000;
-
-function backOffRequest(makeRequestCallback) {
- return backOff((next, stop) => {
- makeRequestCallback()
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- next();
- } else {
- stop(resp);
- }
- })
- .catch(stop);
- }, TWO_MINUTES);
-}
-
-export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled);
-
-export const createProject = ({ dispatch }) => dispatch('requestCreateProject');
-
-export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false);
-
-export const requestCreateProject = ({ dispatch, state, commit }) => {
- commit(types.SET_LOADING, true);
- axios
- .post(state.createProjectEndpoint)
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- dispatch('requestCreateProjectStatus', resp.data.job_id);
- }
- })
- .catch((error) => {
- dispatch('requestCreateProjectError', error);
- });
-};
-
-export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
- backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } }))
- .then((resp) => {
- if (resp.status === HTTP_STATUS_OK) {
- dispatch('requestCreateProjectSuccess', resp.data);
- }
- })
- .catch((error) => {
- dispatch('requestCreateProjectError', error);
- });
-};
-
-export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorData) => {
- commit(types.SET_LOADING, false);
- commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path);
- commit(types.SET_ALERT_CONTENT, {
- message: s__('SelfMonitoring|Self-monitoring project successfully created.'),
- actionText: __('View project'),
- actionName: 'viewSelfMonitorProject',
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_PROJECT_CREATED, true);
- dispatch('setSelfMonitor', true);
-};
-
-export const requestCreateProjectError = ({ commit }, error) => {
- const { response } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- commit(types.SET_ALERT_CONTENT, {
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_LOADING, false);
-};
-
-export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject');
-
-export const requestDeleteProject = ({ dispatch, state, commit }) => {
- commit(types.SET_LOADING, true);
- axios
- .delete(state.deleteProjectEndpoint)
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- dispatch('requestDeleteProjectStatus', resp.data.job_id);
- }
- })
- .catch((error) => {
- dispatch('requestDeleteProjectError', error);
- });
-};
-
-export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => {
- backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } }))
- .then((resp) => {
- if (resp.status === HTTP_STATUS_OK) {
- dispatch('requestDeleteProjectSuccess', resp.data);
- }
- })
- .catch((error) => {
- dispatch('requestDeleteProjectError', error);
- });
-};
-
-export const requestDeleteProjectSuccess = ({ commit }) => {
- commit(types.SET_PROJECT_URL, '');
- commit(types.SET_PROJECT_CREATED, false);
- commit(types.SET_ALERT_CONTENT, {
- message: s__('SelfMonitoring|Self-monitoring project successfully deleted.'),
- actionText: __('Undo'),
- actionName: 'createProject',
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_LOADING, false);
-};
-
-export const requestDeleteProjectError = ({ commit }, error) => {
- const { response } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- commit(types.SET_ALERT_CONTENT, {
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
- commit(types.SET_LOADING, false);
-};
diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js
deleted file mode 100644
index 187c3bdd803..00000000000
--- a/app/assets/javascripts/self_monitor/store/index.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-Vue.use(Vuex);
-
-export const createStore = (initialState) =>
- new Vuex.Store({
- modules: {
- selfMonitoring: {
- namespaced: true,
- state: createState(initialState),
- actions,
- mutations,
- },
- },
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js
deleted file mode 100644
index c5952b66144..00000000000
--- a/app/assets/javascripts/self_monitor/store/mutation_types.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export const SET_ENABLED = 'SET_ENABLED';
-export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED';
-export const SET_SHOW_ALERT = 'SET_SHOW_ALERT';
-export const SET_PROJECT_URL = 'SET_PROJECT_URL';
-export const SET_LOADING = 'SET_LOADING';
-export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT';
diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js
deleted file mode 100644
index 7dca8bcdc4d..00000000000
--- a/app/assets/javascripts/self_monitor/store/mutations.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.SET_ENABLED](state, enabled) {
- state.projectEnabled = enabled;
- },
- [types.SET_PROJECT_CREATED](state, created) {
- state.projectCreated = created;
- },
- [types.SET_SHOW_ALERT](state, show) {
- state.showAlert = show;
- },
- [types.SET_PROJECT_URL](state, url) {
- state.projectPath = url;
- },
- [types.SET_LOADING](state, loading) {
- state.loading = loading;
- },
- [types.SET_ALERT_CONTENT](state, content) {
- state.alertContent = content;
- },
-};
diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js
deleted file mode 100644
index 582ce5576f1..00000000000
--- a/app/assets/javascripts/self_monitor/store/state.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default (initialState = {}) => ({
- projectEnabled: parseBoolean(initialState.selfMonitoringProjectExists) || false,
- projectCreated: parseBoolean(initialState.selfMonitoringProjectExists) || false,
- createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '',
- deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '',
- createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '',
- deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '',
- selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '',
- showAlert: false,
- projectPath: initialState.selfMonitoringProjectFullPath || '',
- loading: false,
- alertContent: {},
-});
diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js
index 5539a061726..ea835945aa9 100644
--- a/app/assets/javascripts/sentry/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -15,8 +15,9 @@ const index = function index() {
: [gon.gitlab_url, 'webpack-internal://'],
release: gon.revision,
tags: {
- revision: gon.revision,
- feature_category: gon.feature_category,
+ revision: gon?.revision,
+ feature_category: gon?.feature_category,
+ page: document?.body?.dataset?.page,
},
});
};
diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/legacy_constants.js
index 5531c4f56db..d04011dab2f 100644
--- a/app/assets/javascripts/sentry/constants.js
+++ b/app/assets/javascripts/sentry/legacy_constants.js
@@ -1,6 +1,6 @@
import { __ } from '~/locale';
-// TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144
+// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
export const IGNORE_ERRORS = [
// Random plugins/extensions
'top.GLOBALS',
@@ -22,6 +22,8 @@ export const IGNORE_ERRORS = [
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
+ // Exclude errors from polling when navigating away from a page
+ 'TypeError: Failed to fetch',
];
export const DENY_URLS = [
diff --git a/app/assets/javascripts/sentry/legacy_sentry_config.js b/app/assets/javascripts/sentry/legacy_sentry_config.js
index 50a943886db..ae9ae327544 100644
--- a/app/assets/javascripts/sentry/legacy_sentry_config.js
+++ b/app/assets/javascripts/sentry/legacy_sentry_config.js
@@ -1,7 +1,7 @@
import * as Sentry5 from 'sentrybrowser5';
import $ from 'jquery';
import { __ } from '~/locale';
-import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants';
+import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './legacy_constants';
const SentryConfig = {
IGNORE_ERRORS,
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index ed8a55b7d44..80f087691f4 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,5 +1,4 @@
import * as Sentry from 'sentrybrowser7';
-import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants';
const SentryConfig = {
init(options = {}) {
@@ -17,9 +16,6 @@ const SentryConfig = {
release,
allowUrls,
environment,
- ignoreErrors: IGNORE_ERRORS,
- denyUrls: DENY_URLS,
- sampleRate: SAMPLE_RATE,
});
Sentry.setTags(tags);
diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js
index 654263ba27b..7d6e7e81f3b 100644
--- a/app/assets/javascripts/service_ping_consent.js
+++ b/app/assets/javascripts/service_ping_consent.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from './flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index e7d028e8d23..270d7f0d182 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,7 +1,7 @@
<script>
import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 323f6f23df6..d65c950b33a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
@@ -32,7 +32,7 @@ export default {
);
},
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
hasMergeIcon() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 73cd0044c16..2c6eb0e5001 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
@@ -73,7 +73,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
cannotMerge() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index 93fcf2cf1c9..319699b88f3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,6 +1,5 @@
<script>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '../../constants';
export default {
@@ -22,9 +21,6 @@ export default {
},
},
computed: {
- issuableClass() {
- return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
- },
issuableId() {
return this.issuable?.id;
},
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index d2f0ceb19c9..884edc97016 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import CollapsedAssignee from './collapsed_assignee.vue';
@@ -47,7 +47,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === 'merge_request';
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
hasNoUsers() {
return !this.users.length;
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index caf3bb2f798..062f63175a7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,6 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 8893e90b1e5..ae81dcb95de 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,8 +1,8 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import Vue from 'vue';
-import { createAlert } from '~/flash';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { TYPE_ALERT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, n__ } from '~/locale';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -60,7 +60,7 @@ export default {
required: false,
default: TYPE_ISSUE,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest, IssuableType.Alert].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_ALERT].includes(value);
},
},
issuableId: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
index 28bc5afc1a4..b41d126be68 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -4,8 +4,6 @@ import { __ } from '~/locale';
export default {
displayText: __('Invite members'),
- dataTrackLabel: 'edit_assignee',
- dataTrackEvent: 'click_invite_members',
components: {
InviteMembersTrigger,
},
@@ -27,8 +25,6 @@ export default {
<invite-members-trigger
trigger-element="anchor"
:display-text="$options.displayText"
- :event="$options.dataTrackEvent"
- :label="$options.dataTrackLabel"
:trigger-source="triggerSource"
classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index ddbd8866680..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 { IssuableType, TYPE_ISSUE } from '~/issues/constants';
-import { s__, sprintf } from '~/locale';
+import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
+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 === IssuableType.MergeRequest && !this.user.canMerge;
+ 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/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 71f349bb87e..b424d9074d0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -53,7 +53,7 @@ export default {
return `@${this.firstUser.username}`;
},
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
},
methods: {
@@ -61,7 +61,7 @@ export default {
this.showLess = !this.showLess;
},
userAvailability(u) {
- if (this.issuableType === IssuableType.MergeRequest) {
+ if (this.issuableType === TYPE_MERGE_REQUEST) {
return u?.availability || '';
}
return u?.status?.availability || '';
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/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
index 1eeb725d5c9..196a86a931a 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import { TYPE_EPIC, WorkspaceType } from '~/issues/constants';
+import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { confidentialityInfoText } from '~/vue_shared/constants';
export default {
@@ -25,7 +25,7 @@ export default {
computed: {
confidentialBodyText() {
return confidentialityInfoText(
- this.issuableType === TYPE_EPIC ? WorkspaceType.group : WorkspaceType.project,
+ this.issuableType === TYPE_EPIC ? WORKSPACE_GROUP : WORKSPACE_PROJECT,
this.issuableType,
);
},
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index f7526bcff3d..3038cec03eb 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { confidentialityQueries } from '../../constants';
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index c2f239b56c7..9177baec246 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -1,7 +1,7 @@
<script>
import produce from 'immer';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { confidentialityQueries, Tracking } from '../../constants';
import SidebarEditableItem from '../sidebar_editable_item.vue';
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
index c9ecaf4102f..916ff70a5ea 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql';
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 77be8022ec0..5a9545f3460 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
@@ -54,6 +54,16 @@ export default {
type: Boolean,
default: false,
},
+ minDate: {
+ required: false,
+ type: Date,
+ default: null,
+ },
+ maxDate: {
+ required: false,
+ type: Date,
+ default: null,
+ },
},
data() {
return {
@@ -96,6 +106,19 @@ export default {
),
});
},
+ subscribeToMore: {
+ document() {
+ return this.dateQueries[this.issuableType].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.skipIssueDueDateSubscription;
+ },
+ },
},
},
computed: {
@@ -153,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() {
@@ -292,6 +321,9 @@ export default {
v-if="!isLoading"
ref="datePicker"
class="gl-relative"
+ :value="parsedDate"
+ :min-date="minDate"
+ :max-date="maxDate"
:default-date="parsedDate"
:first-day="firstDay"
show-clear-button
diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
index f7daad63f45..6db332a82da 100644
--- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
import {
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
deleted file mode 100644
index 00c54313292..00000000000
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const DropdownVariant = {
- Sidebar: 'sidebar',
- Standalone: 'standalone',
- Embedded: 'embedded',
-};
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 b8afa67a947..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">
@@ -92,10 +93,14 @@ export default {
/>
</div>
<div class="color-input-container gl-display-flex">
- <span
- class="dropdown-label-color-preview position-relative position-relative d-inline-block"
- :style="{ backgroundColor: selectedColor }"
- ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
+ type="color"
+ :value="selectedColor"
+ :placeholder="__('Open color picker')"
+ data-testid="selected-color"
+ />
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
@@ -109,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_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
index ee6b531c1ca..3db962c7fe8 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
@@ -80,9 +80,6 @@ export default {
'updateSelectedLabels',
'toggleDropdownContents',
]),
- isLabelSelected(label) {
- return this.selectedLabelsList.includes(label.id);
- },
/**
* This method scrolls item from dropdown into
* the view if it is off the viewable area of the
@@ -160,7 +157,11 @@ export default {
<template>
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
- <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
+ <div
+ class="labels-select-contents-list js-labels-list"
+ data-testid="labels-list"
+ @keydown="handleKeyDown"
+ >
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
@@ -200,7 +201,11 @@ export default {
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
- <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
+ <li
+ v-show="showNoMatchingResultsMessage"
+ class="gl-p-3 gl-text-center"
+ data-testid="no-matching-results"
+ >
{{ __('No matching results') }}
</li>
</ul>
@@ -210,6 +215,7 @@ export default {
<li v-if="allowLabelCreate">
<gl-link
class="gl-display-flex w-100 flex-row text-break-word label-item"
+ data-testid="create-label-link"
@click="handleCreateLabelClick"
>
{{ footerCreateLabelTitle }}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
index 135fa9f6228..9df03a4d7f8 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
@@ -31,7 +31,7 @@ export default {
const { label, highlight, isLabelSet, isLabelIndeterminate } = props;
const labelColorBox = h('span', {
- class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
+ class: 'dropdown-label-box gl-mr-2',
style: {
backgroundColor: label.color,
},
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
index 04c62c99a11..74e47b333ef 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
@@ -3,8 +3,8 @@ import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import { VARIANT_SIDEBAR } from '~/sidebar/components/labels/labels_select_widget/constants';
-import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
@@ -60,7 +60,7 @@ export default {
variant: {
type: String,
required: false,
- default: DropdownVariant.Sidebar,
+ default: VARIANT_SIDEBAR,
},
selectedLabels: {
type: Array,
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
index 2dab97826b9..06030003f3c 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
index ef3eedd9bb2..03ace6286e0 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
@@ -1,5 +1,9 @@
import { __, s__, sprintf } from '~/locale';
-import { DropdownVariant } from '../constants';
+import {
+ VARIANT_EMBEDDED,
+ VARIANT_SIDEBAR,
+ VARIANT_STANDALONE,
+} from '~/sidebar/components/labels/labels_select_widget/constants';
/**
* Returns string representing current labels
@@ -36,18 +40,18 @@ export const selectedLabelsList = (state) => state.selectedLabels.map((label) =>
* is `sidebar`
* @param {object} state
*/
-export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
+export const isDropdownVariantSidebar = (state) => state.variant === VARIANT_SIDEBAR;
/**
* Returns boolean representing whether dropdown variant
* is `standalone`
* @param {object} state
*/
-export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
+export const isDropdownVariantStandalone = (state) => state.variant === VARIANT_STANDALONE;
/**
* Returns boolean representing whether dropdown variant
* is `embedded`
* @param {object} state
*/
-export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
+export const isDropdownVariantEmbedded = (state) => state.variant === VARIANT_EMBEDDED;
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
index c85d9befcbb..dba48a721ac 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
@@ -1,5 +1,5 @@
import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
-import { DropdownVariant } from '../constants';
+import { VARIANT_SIDEBAR } from '~/sidebar/components/labels/labels_select_widget/constants';
import * as types from './mutation_types';
const transformLabels = (labels, selectedLabels) =>
@@ -43,7 +43,7 @@ export default {
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
- if (state.variant === DropdownVariant.Sidebar) {
+ if (state.variant === VARIANT_SIDEBAR) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
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 cd671b4d8f5..57f08ab59e2 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,13 +1,7 @@
export const SCOPED_LABEL_DELIMITER = '::';
export const DEBOUNCE_DROPDOWN_DELAY = 200;
+export const DEFAULT_LABEL_COLOR = '#6699cc';
-export const DropdownVariant = {
- Sidebar: 'sidebar',
- Standalone: 'standalone',
- Embedded: 'embedded',
-};
-
-export const LabelType = {
- group: 'group',
- project: 'project',
-};
+export const VARIANT_EMBEDDED = 'embedded';
+export const VARIANT_SIDEBAR = 'sidebar';
+export const VARIANT_STANDALONE = 'standalone';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
index 83df9056af2..b44096c7743 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
@@ -188,6 +188,11 @@ export default {
selectFirstItem() {
this.$refs.dropdownContentsView.selectFirstItem();
},
+ handleNewLabel(label) {
+ this.localSelectedLabels = [...this.localSelectedLabels, label];
+ this.toggleDropdownContent();
+ this.clearSearch();
+ },
},
};
</script>
@@ -229,6 +234,7 @@ export default {
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
@hideCreateView="toggleDropdownContent"
+ @labelCreated="handleNewLabel"
@input="clearSearch"
/>
</template>
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 aa1184ed314..45778640957 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
@@ -8,11 +8,12 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP } from '~/issues/constants';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
-import { LabelType } from './constants';
+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,
};
@@ -62,7 +68,7 @@ export default {
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
mutationVariables() {
- const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath';
+ const attributePath = this.labelCreateType === WORKSPACE_GROUP ? 'groupPath' : 'projectPath';
return {
title: this.labelTitle,
@@ -126,7 +132,7 @@ export default {
if (labelCreate.errors.length) {
[this.error] = labelCreate.errors;
} else {
- this.$emit('hideCreateView');
+ this.$emit('labelCreated', labelCreate.label);
}
} catch {
createAlert({ message: errorMessage });
@@ -163,11 +169,14 @@ export default {
/>
</div>
<div class="color-input-container gl-display-flex">
- <span
- class="dropdown-label-color-preview gl-relative gl-display-inline-block"
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
+ type="color"
+ :value="selectedColor"
+ :placeholder="__('Select color')"
data-testid="selected-color"
- :style="{ backgroundColor: selectedColor }"
- ></span>
+ />
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
index c1939dc7785..1d8b21700c3 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
@@ -147,14 +147,13 @@ export default {
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
- size="lg"
+ size="sm"
/>
<template v-else>
<gl-dropdown-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:is-checked="isLabelSelected(label)"
- is-check-centered
is-check-item
:active="shouldHighlightFirstItem && index === 0"
active-class="is-focused"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
index 3a93fc7f3b2..a3bacc4a674 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
@@ -53,7 +53,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-mt-3" data-testid="embedded-labels-list">
<gl-label
v-for="label in sortedSelectedLabels"
:key="label.id"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
index 314ffbaf84c..4ce2644262a 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
@@ -10,7 +10,7 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center gl-word-break-word">
+ <div class="gl-display-flex gl-word-break-word">
<span
class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
:style="{ 'background-color': label.color }"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
index bf916e26a15..72567b7d4a4 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -2,14 +2,14 @@
import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants';
import { __ } from '~/locale';
import { issuableLabelsQueries } from '../../../constants';
import SidebarEditableItem from '../../sidebar_editable_item.vue';
-import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
+import { DEBOUNCE_DROPDOWN_DELAY, VARIANT_SIDEBAR } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
import EmbeddedLabelsList from './embedded_labels_list.vue';
@@ -60,7 +60,7 @@ export default {
variant: {
type: String,
required: false,
- default: DropdownVariant.Sidebar,
+ default: VARIANT_SIDEBAR,
},
labelsFilterBasePath: {
type: String,
@@ -166,7 +166,7 @@ export default {
fullPath: this.fullPath,
};
- if (this.issuableType === IssuableType.TestCase) {
+ if (this.issuableType === TYPE_TEST_CASE) {
queryVariables.types = ['TEST_CASE'];
}
@@ -262,9 +262,9 @@ export default {
switch (this.issuableType) {
case TYPE_ISSUE:
- case IssuableType.TestCase:
+ case TYPE_TEST_CASE:
return updateVariables;
- case IssuableType.MergeRequest:
+ case TYPE_MERGE_REQUEST:
return {
...updateVariables,
operationMode: MutationOperationMode.Replace,
@@ -319,12 +319,12 @@ export default {
switch (this.issuableType) {
case TYPE_ISSUE:
- case IssuableType.TestCase:
+ case TYPE_TEST_CASE:
return {
...removeVariables,
removeLabelIds: [labelId],
};
- case IssuableType.MergeRequest:
+ case TYPE_MERGE_REQUEST:
return {
...removeVariables,
labelIds: [labelId],
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
index b5cd946a189..d752accf14a 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
@@ -1,22 +1,22 @@
-import { DropdownVariant } from './constants';
+import { VARIANT_EMBEDDED, VARIANT_SIDEBAR, VARIANT_STANDALONE } from './constants';
/**
* Returns boolean representing whether dropdown variant
* is `sidebar`
* @param {string} variant
*/
-export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar;
+export const isDropdownVariantSidebar = (variant) => variant === VARIANT_SIDEBAR;
/**
* Returns boolean representing whether dropdown variant
* is `standalone`
* @param {string} variant
*/
-export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone;
+export const isDropdownVariantStandalone = (variant) => variant === VARIANT_STANDALONE;
/**
* Returns boolean representing whether dropdown variant
* is `embedded`
* @param {string} variant
*/
-export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded;
+export const isDropdownVariantEmbedded = (variant) => variant === VARIANT_EMBEDDED;
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index df03af346c0..606d374158b 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import $ from 'jquery';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
@@ -49,11 +49,11 @@ export default {
fullPath: this.fullPath,
})
.catch(() => {
- const flashMessage = __(
+ const alertMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
);
createAlert({
- message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }),
+ message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }),
});
})
.finally(() => {
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 9d8f1304911..06876546fa4 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -3,8 +3,9 @@ import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitl
import { mapGetters, mapActions } from 'vuex';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import toast from '~/vue_shared/plugins/global_toast';
import eventHub from '../../event_hub';
import EditForm from './edit_form.vue';
@@ -45,8 +46,8 @@ export default {
},
computed: {
...mapGetters(['getNoteableData']),
- isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar;
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
issuableDisplayName() {
const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
@@ -58,7 +59,6 @@ export default {
lockStatus() {
return this.isLocked ? this.$options.locked : this.$options.unlocked;
},
-
tooltipLabel() {
return this.isLocked ? __('Locked') : __('Unlocked');
},
@@ -87,16 +87,21 @@ export default {
fullPath: this.fullPath,
})
.then(() => {
- if (this.isMergeRequest) {
- toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.'));
+ if (this.isMovedMrSidebar) {
+ toast(
+ sprintf(__('%{issuableDisplayName} %{lockStatus}.'), {
+ issuableDisplayName: capitalizeFirstCharacter(this.issuableDisplayName),
+ lockStatus: this.isLocked ? __('locked') : __('unlocked'),
+ }),
+ );
}
})
.catch(() => {
- const flashMessage = __(
+ const alertMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
);
createAlert({
- message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }),
+ message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }),
});
})
.finally(() => {
@@ -111,14 +116,14 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-dropdown-item">
- <button type="button" class="dropdown-item" @click="toggleLocked">
+ <li v-if="isMovedMrSidebar" class="gl-dropdown-item">
+ <button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked">
<span class="gl-dropdown-item-text-wrapper">
<template v-if="isLocked">
- {{ __('Unlock merge request') }}
+ {{ sprintf(__('Unlock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
<template v-else>
- {{ __('Lock merge request') }}
+ {{ sprintf(__('Lock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
</span>
</button>
diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
index 8072154cd28..24afb25e403 100644
--- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
@@ -2,7 +2,12 @@
import { GlDropdownItem } from '@gitlab/ui';
import { TYPENAME_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import { __ } from '~/locale';
import { IssuableAttributeType } from '../../constants';
import SidebarDropdown from '../sidebar_dropdown.vue';
@@ -37,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
inputName: {
@@ -64,7 +69,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ return [WORKSPACE_GROUP, WORKSPACE_PROJECT].includes(value);
},
},
},
diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
index 9f64ddc8721..34a4da946d6 100644
--- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
@@ -194,7 +194,11 @@ export default {
<div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
{{ __('No matching results') }}
</div>
- <div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
+ <div
+ v-if="failedToLoadResults"
+ data-testid="failed-load-results"
+ class="gl-text-center gl-p-3"
+ >
{{ __('Failed to load projects') }}
</div>
</div>
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 e1259fad6a7..581537264db 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
@@ -1,7 +1,7 @@
<script>
import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import moveIssueMutation from '../../queries/move_issue.mutation.graphql';
@@ -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/move/move_issues_button.vue b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
index ab4ac9500ad..68c8b35c009 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import { s__ } from '~/locale';
import {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 2f25c2fd4b0..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 },
);
},
@@ -99,7 +99,7 @@ export default {
>
<gl-icon name="users" />
<gl-loading-icon v-if="loading" size="sm" />
- <span v-else data-testid="collapsed-count" class="gl-pt-2 gl-px-3 gl-font-sm">
+ <span v-else class="gl-pt-2 gl-px-3 gl-font-sm">
{{ participantCount }}
</span>
</div>
@@ -114,9 +114,12 @@ export default {
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author gl-display-inline-block gl-pr-3 gl-pb-3"
+ class="participants-author gl-display-inline-block gl-mr-3 gl-mb-3"
>
- <a :href="participant.web_url || participant.webUrl" class="author-link">
+ <a
+ :href="participant.web_url || participant.webUrl"
+ class="author-link gl-display-inline-block gl-rounded-full"
+ >
<user-avatar-image
:lazy="lazy"
:img-src="participant.avatar_url || participant.avatarUrl"
@@ -133,7 +136,6 @@ export default {
<gl-button
variant="link"
button-text-classes="gl-text-secondary"
- data-testid="more-participants"
@click="toggleMoreParticipants"
>{{ toggleLabel }}</gl-button
>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 56ac4c39e84..80c051f86b5 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -2,7 +2,7 @@
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import ReviewerAvatar from './reviewer_avatar.vue';
@@ -41,7 +41,9 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.mergeRequestInteraction?.canMerge;
+ return (
+ this.issuableType === TYPE_MERGE_REQUEST && !this.user.mergeRequestInteraction?.canMerge
+ );
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 8dd58d33ecf..9c23f239b4c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -3,7 +3,7 @@
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
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/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
index ecb9a2809a0..55de0ceb388 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
@@ -1,9 +1,10 @@
<script>
import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_INCIDENT } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql';
-import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants';
+import { INCIDENT_SEVERITY, SEVERITY_I18N as I18N } from '../../constants';
import SeverityToken from './severity.vue';
export default {
@@ -34,10 +35,10 @@ export default {
issuableType: {
type: String,
required: false,
- default: ISSUABLE_TYPES.INCIDENT,
+ default: TYPE_INCIDENT,
validator: (value) => {
// currently severity is supported only for incidents, but this list might be extended
- return [ISSUABLE_TYPES.INCIDENT].includes(value);
+ return [TYPE_INCIDENT].includes(value);
},
},
},
@@ -50,7 +51,7 @@ export default {
computed: {
severitiesList() {
switch (this.issuableType) {
- case ISSUABLE_TYPES.INCIDENT:
+ case TYPE_INCIDENT:
return Object.values(INCIDENT_SEVERITY);
default:
return [];
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
index d68e4974ea4..50b4284cde0 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
@@ -8,7 +8,13 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import { __ } from '~/locale';
import {
defaultEpicSort,
@@ -21,7 +27,7 @@ import {
LocalizedIssuableAttributeType,
noAttributeId,
} from 'ee_else_ce/sidebar/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { PathIdSeparator } from '~/related_issues/constants';
export default {
@@ -70,15 +76,15 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
workspaceType: {
type: String,
required: false,
- default: WorkspaceType.project,
+ default: WORKSPACE_PROJECT,
validator(value) {
- return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ return [WORKSPACE_GROUP, WORKSPACE_PROJECT].includes(value);
},
},
},
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 5df65c4aaaf..4721c6fee61 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -71,7 +71,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
icon: {
@@ -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/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index cbe839d1112..f2b960ed02c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { IssuableType, TYPE_EPIC } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -86,8 +86,8 @@ export default {
},
},
computed: {
- isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest && this.glFeatures.movedMrSidebar;
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
isLoading() {
return this.$apollo.queries?.subscribed?.loading || this.loading;
@@ -109,7 +109,7 @@ export default {
},
subscribeDisabledDescription() {
return sprintf(__('Disabled by %{parent} owner'), {
- parent: this.parentIsGroup ? 'group' : 'project',
+ parent: this.parentIsGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT,
});
},
isLoggedIn() {
@@ -143,7 +143,7 @@ export default {
});
}
- if (this.isMergeRequest) {
+ if (this.isMovedMrSidebar) {
toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.'));
}
},
@@ -182,7 +182,7 @@ export default {
</script>
<template>
- <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item">
+ <gl-dropdown-form v-if="isMovedMrSidebar" class="gl-dropdown-item">
<div class="gl-px-5 gl-pb-2 gl-pt-1">
<gl-toggle
:value="subscribed"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
index 964da3b6138..9b582ba41ed 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
@@ -29,7 +29,11 @@ export default {
GlLink,
GlSprintf,
},
- inject: ['issuableType'],
+ inject: {
+ issuableType: {
+ default: null,
+ },
+ },
props: {
issuableId: {
type: String,
@@ -52,13 +56,11 @@ export default {
primaryProps() {
return {
text: s__('CreateTimelogForm|Save'),
- attributes: [
- {
- variant: 'confirm',
- disabled: this.submitDisabled,
- loading: this.isLoading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index cffbb6466f2..109e1af85ec 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/issues/constants';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index c645b1649d2..f6968558122 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -8,7 +8,7 @@ import {
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __ } from '~/locale';
@@ -173,7 +173,7 @@ export default {
return Boolean(this.showHelp);
},
isTimeReportSupported() {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(this.issuableType) && this.issuableId;
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(this.issuableType) && this.issuableId;
},
timeTrackingIconTitle() {
return this.showHelpState ? '' : HOW_TO_TRACK_TIME;
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index b86ff279fd8..551d306a9c4 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -1,7 +1,8 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
@@ -83,7 +84,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.glFeatures.movedMrSidebar && this.issuableType === 'merge_request';
+ return this.glFeatures.movedMrSidebar && this.issuableType === TYPE_MERGE_REQUEST;
},
todoIdQuery() {
return todoQueries[this.issuableType].query;
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 14491226b15..0f82182c6e2 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -3,8 +3,17 @@ import { s__, __, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_ALERT,
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ TYPE_TEST_CASE,
+ WORKSPACE_GROUP,
+ 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';
@@ -69,11 +78,11 @@ export const assigneesQueries = {
subscription: issuableAssigneesSubscription,
mutation: updateIssueAssigneesMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMergeRequestAssignees,
mutation: updateMergeRequestAssigneesMutation,
},
- [IssuableType.Alert]: {
+ [TYPE_ALERT]: {
query: getAlertAssignees,
mutation: updateAlertAssigneesMutation,
},
@@ -83,13 +92,13 @@ export const participantsQueries = {
[TYPE_ISSUE]: {
query: issueParticipantsQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMergeRequestParticipants,
},
[TYPE_EPIC]: {
query: epicParticipantsQuery,
},
- [IssuableType.Alert]: {
+ [TYPE_ALERT]: {
query: '',
skipQuery: true,
},
@@ -99,7 +108,7 @@ export const userSearchQueries = {
[TYPE_ISSUE]: {
query: userSearchQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: userSearchWithMRPermissionsQuery,
},
};
@@ -119,7 +128,7 @@ export const referenceQueries = {
[TYPE_ISSUE]: {
query: issueReferenceQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestReferenceQuery,
},
[TYPE_EPIC]: {
@@ -128,10 +137,10 @@ export const referenceQueries = {
};
export const workspaceLabelsQueries = {
- [WorkspaceType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectLabelsQuery,
},
- [WorkspaceType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupLabelsQuery,
},
};
@@ -142,7 +151,7 @@ export const issuableLabelsQueries = {
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
issuableQuery: mergeRequestLabelsQuery,
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
@@ -152,7 +161,7 @@ export const issuableLabelsQueries = {
mutation: updateEpicLabelsMutation,
mutationName: 'updateEpic',
},
- [IssuableType.TestCase]: {
+ [TYPE_TEST_CASE]: {
issuableQuery: issueLabelsQuery,
mutation: updateTestCaseLabelsMutation,
mutationName: 'updateTestCaseLabels',
@@ -186,7 +195,7 @@ export const subscribedQueries = {
query: epicSubscribedQuery,
mutation: updateEpicSubscriptionMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestSubscribed,
mutation: updateMergeRequestSubscriptionMutation,
},
@@ -201,7 +210,7 @@ export const timeTrackingQueries = {
[TYPE_ISSUE]: {
query: issueTimeTrackingQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTimeTrackingQuery,
},
};
@@ -210,6 +219,7 @@ export const dueDateQueries = {
[TYPE_ISSUE]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
+ subscription: issuableDatesUpdatedSubscription,
},
[TYPE_EPIC]: {
query: epicDueDateQuery,
@@ -228,7 +238,7 @@ export const timelogQueries = {
[TYPE_ISSUE]: {
query: getIssueTimelogsQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMrTimelogsQuery,
},
};
@@ -240,7 +250,7 @@ export const issuableMilestoneQueries = {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestMilestone,
mutation: mergeRequestMilestoneMutation,
},
@@ -249,14 +259,14 @@ export const issuableMilestoneQueries = {
export const milestonesQueries = {
[TYPE_ISSUE]: {
query: {
- [WorkspaceType.group]: groupMilestonesQuery,
- [WorkspaceType.project]: projectMilestonesQuery,
+ [WORKSPACE_GROUP]: groupMilestonesQuery,
+ [WORKSPACE_PROJECT]: projectMilestonesQuery,
},
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: {
- [WorkspaceType.group]: groupMilestonesQuery,
- [WorkspaceType.project]: projectMilestonesQuery,
+ [WORKSPACE_GROUP]: groupMilestonesQuery,
+ [WORKSPACE_PROJECT]: projectMilestonesQuery,
},
},
};
@@ -289,7 +299,7 @@ export const todoQueries = {
[TYPE_ISSUE]: {
query: issueTodoQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTodoQuery,
},
};
@@ -407,10 +417,6 @@ export const INCIDENT_SEVERITY = {
},
};
-export const ISSUABLE_TYPES = {
- INCIDENT: 'incident',
-};
-
export const MILESTONE_STATE = {
ACTIVE: 'active',
CLOSED: 'closed',
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index b908cf0cd9e..b0060e4c28d 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import { IssuableType } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import TimeTracker from './components/time_tracking/time_tracker.vue';
@@ -25,9 +24,6 @@ export default class SidebarMilestone {
components: {
TimeTracker,
},
- provide: {
- issuableType: IssuableType.Milestone,
- },
render: (createElement) =>
createElement('time-tracker', {
props: {
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index fb024d818da..74843bcc006 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -4,7 +4,7 @@ import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constan
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
import { gqlClient } from '~/issues/list/graphql';
import {
isInDesignPage,
@@ -17,6 +17,7 @@ import { __ } from '~/locale';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import Translate from '~/vue_shared/translate';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
@@ -24,8 +25,6 @@ import SidebarConfidentialityWidget from './components/confidential/sidebar_conf
import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue';
import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue';
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
-import { DropdownVariant } from './components/labels/labels_select_vue/constants';
-import { LabelType } from './components/labels/labels_select_widget/constants';
import LabelsSelectWidget from './components/labels/labels_select_widget/labels_select_root.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import MilestoneDropdown from './components/milestone/milestone_dropdown.vue';
@@ -81,7 +80,7 @@ function mountSidebarTodoWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -125,7 +124,7 @@ function mountSidebarAssigneesDeprecated(mediator) {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
issuableId: id,
assigneeAvailabilityStatus,
},
@@ -142,14 +141,14 @@ function mountSidebarAssigneesWidget() {
const { id, iid, fullPath, editable } = getSidebarOptions();
const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
- const issuableType = isIssuablePage ? TYPE_ISSUE : IssuableType.MergeRequest;
+ const issuableType = isIssuablePage ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
// eslint-disable-next-line no-new
new Vue({
el,
name: 'SidebarAssigneesRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
directlyInviteMembers: Object.prototype.hasOwnProperty.call(
el.dataset,
'directlyInviteMembers',
@@ -163,7 +162,7 @@ function mountSidebarAssigneesWidget() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1,
- editable,
+ editable: parseBoolean(editable),
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -204,8 +203,7 @@ function mountSidebarReviewers(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- issuableType:
- isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
},
}),
});
@@ -275,8 +273,7 @@ function mountSidebarMilestoneWidget() {
attrWorkspacePath: projectPath,
workspacePath: projectPath,
iid: issueIid,
- issuableType:
- isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
issuableAttribute: IssuableAttributeType.Milestone,
icon: 'clock',
},
@@ -313,7 +310,7 @@ export function mountMilestoneDropdown() {
attrWorkspacePath: fullPath,
canAdminMilestone,
inputName,
- issuableType: isInIssuePage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
milestoneId,
milestoneTitle,
projectMilestonesPath,
@@ -354,14 +351,13 @@ export function mountSidebarLabelsWidget() {
footerManageLabelTitle: __('Manage project labels'),
labelsCreateTitle: __('Create project label'),
labelsFilterBasePath: el.dataset.projectIssuesPath,
- variant: DropdownVariant.Sidebar,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
- workspaceType: 'project',
+ : TYPE_MERGE_REQUEST,
+ workspaceType: WORKSPACE_PROJECT,
attrWorkspacePath: el.dataset.projectPath,
- labelCreateType: LabelType.project,
+ labelCreateType: WORKSPACE_PROJECT,
},
class: ['block labels js-labels-block'],
scopedSlots: {
@@ -398,7 +394,7 @@ function mountSidebarConfidentialityWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -418,7 +414,7 @@ function mountSidebarDueDateWidget() {
name: 'SidebarDueDateWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarDueDateWidget, {
@@ -454,7 +450,7 @@ function mountSidebarReferenceWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -479,7 +475,7 @@ function mountIssuableLockForm(store) {
render: (createElement) =>
createElement(IssuableLockForm, {
props: {
- isEditable: editable,
+ isEditable: parseBoolean(editable),
},
}),
});
@@ -506,7 +502,7 @@ function mountSidebarParticipantsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -526,7 +522,7 @@ function mountSidebarSubscriptionsWidget() {
name: 'SidebarSubscriptionsWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSubscriptionsWidget, {
@@ -536,7 +532,7 @@ function mountSidebarSubscriptionsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -590,7 +586,7 @@ function mountSidebarSeverityWidget() {
name: 'SidebarSeverityWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSeverityWidget, {
@@ -648,7 +644,7 @@ function mountCopyEmailToClipboard() {
});
}
-export function mountMoveIssuesButton() {
+export async function mountMoveIssuesButton() {
const el = document.querySelector('.js-move-issues');
if (!el) {
@@ -661,7 +657,7 @@ export function mountMoveIssuesButton() {
el,
name: 'MoveIssuesRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render: (createElement) =>
createElement(MoveIssuesButton, {
@@ -790,6 +786,21 @@ export function mountAssigneesDropdown() {
});
}
+function mountNewIssuePopover() {
+ const el = document.querySelector('.js-sidebar-header-popover');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'NewHeaderActionsPopover',
+ render: (createElement) =>
+ createElement(NewHeaderActionsPopover, { props: { issueType: TYPE_MERGE_REQUEST } }),
+ });
+}
+
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
@@ -817,6 +828,7 @@ export function mountSidebar(mediator, store) {
mountSidebarSeverityWidget();
mountSidebarEscalationStatus();
mountMoveIssueButton();
+ mountNewIssuePopover();
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
index 9b0a8b4a8f7..690eb75f8f4 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
@@ -4,6 +4,7 @@ mutation updateEpicDueDate($input: UpdateEpicInput!) {
id
dueDateIsFixed
dueDateFixed
+ dueDate
dueDateFromMilestones
}
errors
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
index 9b4bb9159c3..d2a598a00fa 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
@@ -4,6 +4,7 @@ mutation updateEpicStartDate($input: UpdateEpicInput!) {
id
startDateIsFixed
startDateFixed
+ startDate
startDateFromMilestones
}
errors
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index c6a66ab2275..7353694a324 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import { visitUrl } from '~/lib/utils/url_utility';
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 6e5b2ce4dbe..bab167bb7e4 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,7 +1,5 @@
-/* eslint-disable consistent-return */
-
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { spriteIcon } from '~/lib/utils/common_utils';
import FilesCommentButton from './files_comment_button';
@@ -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/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 4a7528d9c8e..5e2f194e133 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -2,8 +2,8 @@
import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
-import { createAlert } from '~/flash';
-import { redirectTo, joinPaths } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, sprintf } from '~/locale';
import {
SNIPPET_MARK_EDIT_APP_START,
@@ -141,7 +141,7 @@ export default {
Object.assign(e, { returnValue });
return returnValue;
},
- flashAPIFailure(err) {
+ alertAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
: SNIPPET_UPDATE_MUTATION_ERROR;
@@ -190,16 +190,16 @@ export default {
const errors = baseObj?.errors;
if (errors?.length) {
- this.flashAPIFailure(errors[0]);
+ this.alertAPIFailure(errors[0]);
} else {
- redirectTo(baseObj.snippet.webUrl);
+ redirectTo(baseObj.snippet.webUrl); // eslint-disable-line import/no-deprecated
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('[gitlab] unexpected error while updating snippet', e);
- this.flashAPIFailure(getErrorMessage(e));
+ this.alertAPIFailure(getErrorMessage(e));
});
},
updateActions(actions) {
@@ -256,7 +256,7 @@ export default {
<form-footer-actions>
<template #prepend>
<gl-button
- class="js-no-auto-disable"
+ class="js-no-auto-disable gl-mr-2"
category="primary"
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 7e80928cbea..021bd23781e 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
@@ -60,9 +60,9 @@ export default {
.then((res) => {
this.notifyAboutUpdates({ content: res.data });
})
- .catch((e) => this.flashAPIFailure(e));
+ .catch((e) => this.alertAPIFailure(e));
},
- flashAPIFailure(err) {
+ alertAPIFailure(err) {
createAlert({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) });
},
},
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index bac423f6838..3ce7ea231ff 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -35,11 +35,7 @@ export default {
<div class="js-collapsed" :class="{ 'd-none': value }">
<gl-form-input
class="form-control"
- :placeholder="
- s__(
- 'Snippets|Optionally add a description about what your snippet does or how to use it…',
- )
- "
+ :placeholder="s__('Snippets|Describe what your snippet does or how to use it…')"
data-qa-selector="description_placeholder"
/>
</div>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 759a3f31a05..881e06113d9 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -19,7 +19,7 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/alert';
import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql';
@@ -78,6 +78,7 @@ export default {
isSubmittingSpam: false,
errorMessage: '',
canCreateSnippet: false,
+ isDeleteModalVisible: false,
};
},
computed: {
@@ -164,10 +165,10 @@ export default {
: `${gon.relative_url_root}dashboard/snippets`;
},
closeDeleteModal() {
- this.$refs.deleteModal.hide();
+ this.isDeleteModalVisible = false;
},
showDeleteModal() {
- this.$refs.deleteModal.show();
+ this.isDeleteModalVisible = true;
},
deleteSnippet() {
this.isLoading = true;
@@ -291,12 +292,22 @@ export default {
</div>
</div>
- <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
+ <gl-modal
+ ref="deleteModal"
+ v-model="isDeleteModalVisible"
+ modal-id="delete-modal"
+ title="Example title"
+ >
<template #modal-title>{{ __('Delete snippet?') }}</template>
- <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
- errorMessage
- }}</gl-alert>
+ <gl-alert
+ v-if="errorMessage"
+ variant="danger"
+ class="mb-2"
+ data-testid="delete-alert"
+ @dismiss="errorMessage = ''"
+ >{{ errorMessage }}</gl-alert
+ >
<gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
<template #name>
@@ -311,6 +322,7 @@ export default {
category="primary"
:disabled="isLoading"
data-qa-selector="delete_snippet_button"
+ data-testid="delete-snippet"
@click="deleteSnippet"
>
<gl-loading-icon v-if="isLoading" size="sm" inline />
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/streaming/chunk_writer.js b/app/assets/javascripts/streaming/chunk_writer.js
new file mode 100644
index 00000000000..4bbd0a5f843
--- /dev/null
+++ b/app/assets/javascripts/streaming/chunk_writer.js
@@ -0,0 +1,144 @@
+import { throttle } from 'lodash';
+import { RenderBalancer } from '~/streaming/render_balancer';
+import {
+ BALANCE_RATE,
+ HIGH_FRAME_TIME,
+ LOW_FRAME_TIME,
+ MAX_CHUNK_SIZE,
+ MIN_CHUNK_SIZE,
+ TIMEOUT,
+} from '~/streaming/constants';
+
+const defaultConfig = {
+ balanceRate: BALANCE_RATE,
+ minChunkSize: MIN_CHUNK_SIZE,
+ maxChunkSize: MAX_CHUNK_SIZE,
+ lowFrameTime: LOW_FRAME_TIME,
+ highFrameTime: HIGH_FRAME_TIME,
+ timeout: TIMEOUT,
+};
+
+function concatUint8Arrays(a, b) {
+ const array = new Uint8Array(a.length + b.length);
+ array.set(a, 0);
+ array.set(b, a.length);
+ return array;
+}
+
+// This class is used to write chunks with a balanced size
+// to avoid blocking main thread for too long.
+//
+// A chunk can be:
+// 1. Too small
+// 2. Too large
+// 3. Delayed in time
+//
+// This class resolves all these problems by
+// 1. Splitting or concatenating chunks to met the size criteria
+// 2. Rendering current chunk buffer immediately if enough time has passed
+//
+// The size of the chunk is determined by RenderBalancer,
+// It measures execution time for each chunk write and adjusts next chunk size.
+export class ChunkWriter {
+ buffer = null;
+ decoder = new TextDecoder('utf-8');
+ timeout = null;
+
+ constructor(htmlStream, config) {
+ this.htmlStream = htmlStream;
+
+ const { balanceRate, minChunkSize, maxChunkSize, lowFrameTime, highFrameTime, timeout } = {
+ ...defaultConfig,
+ ...config,
+ };
+
+ // ensure we still render chunks over time if the size criteria is not met
+ this.scheduleAccumulatorFlush = throttle(this.flushAccumulator.bind(this), timeout);
+
+ const averageSize = Math.round((maxChunkSize + minChunkSize) / 2);
+ this.size = Math.max(averageSize, minChunkSize);
+
+ this.balancer = new RenderBalancer({
+ lowFrameTime,
+ highFrameTime,
+ decrease: () => {
+ this.size = Math.round(Math.max(this.size / balanceRate, minChunkSize));
+ },
+ increase: () => {
+ this.size = Math.round(Math.min(this.size * balanceRate, maxChunkSize));
+ },
+ });
+ }
+
+ write(chunk) {
+ this.scheduleAccumulatorFlush.cancel();
+
+ if (this.buffer) {
+ this.buffer = concatUint8Arrays(this.buffer, chunk);
+ } else {
+ this.buffer = chunk;
+ }
+
+ // accumulate chunks until the size is fulfilled
+ if (this.size > this.buffer.length) {
+ this.scheduleAccumulatorFlush();
+ return Promise.resolve();
+ }
+
+ return this.balancedWrite();
+ }
+
+ balancedWrite() {
+ let cursor = 0;
+
+ return this.balancer.render(() => {
+ const chunkPart = this.buffer.subarray(cursor, cursor + this.size);
+ // accumulate chunks until the size is fulfilled
+ // this is a hot path for the last chunkPart of the chunk
+ if (chunkPart.length < this.size) {
+ this.buffer = chunkPart;
+ this.scheduleAccumulatorFlush();
+ return false;
+ }
+
+ this.writeToDom(chunkPart);
+
+ cursor += this.size;
+ if (cursor >= this.buffer.length) {
+ this.buffer = null;
+ return false;
+ }
+ // continue render
+ return true;
+ });
+ }
+
+ writeToDom(chunk, stream = true) {
+ // stream: true allows us to split chunks with multi-part words
+ const decoded = this.decoder.decode(chunk, { stream });
+ this.htmlStream.write(decoded);
+ }
+
+ flushAccumulator() {
+ if (this.buffer) {
+ this.writeToDom(this.buffer);
+ this.buffer = null;
+ }
+ }
+
+ close() {
+ this.scheduleAccumulatorFlush.cancel();
+ if (this.buffer) {
+ // last chunk should have stream: false to indicate the end of the stream
+ this.writeToDom(this.buffer, false);
+ this.buffer = null;
+ }
+ this.htmlStream.close();
+ }
+
+ abort() {
+ this.scheduleAccumulatorFlush.cancel();
+ this.buffer = null;
+ this.htmlStream.abort();
+ }
+}
diff --git a/app/assets/javascripts/streaming/constants.js b/app/assets/javascripts/streaming/constants.js
new file mode 100644
index 00000000000..224d93a7ac1
--- /dev/null
+++ b/app/assets/javascripts/streaming/constants.js
@@ -0,0 +1,9 @@
+// Lower min chunk numbers can make the page loading take incredibly long
+export const MIN_CHUNK_SIZE = 128 * 1024;
+export const MAX_CHUNK_SIZE = 2048 * 1024;
+export const LOW_FRAME_TIME = 32;
+// Tasks that take more than 50ms are considered Long
+// https://web.dev/optimize-long-tasks/
+export const HIGH_FRAME_TIME = 64;
+export const BALANCE_RATE = 1.2;
+export const TIMEOUT = 500;
diff --git a/app/assets/javascripts/streaming/handle_streamed_anchor_link.js b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
new file mode 100644
index 00000000000..315dc9bb0a0
--- /dev/null
+++ b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
@@ -0,0 +1,26 @@
+import { throttle } from 'lodash';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+
+const noop = () => {};
+
+export function handleStreamedAnchorLink(rootElement) {
+ // "#L100-200" → ['L100', 'L200']
+ const [anchorStart, end] = window.location.hash.substring(1).split('-');
+ const anchorEnd = end ? `L${end}` : anchorStart;
+ if (!anchorStart || document.getElementById(anchorEnd)) return noop;
+
+ const handler = throttle((mutationList, instance) => {
+ if (!document.getElementById(anchorEnd)) return;
+ scrollToElement(document.getElementById(anchorStart));
+ // eslint-disable-next-line no-new
+ new LineHighlighter();
+ instance.disconnect();
+ }, 300);
+
+ const observer = new MutationObserver(handler);
+
+ observer.observe(rootElement, { childList: true, subtree: true });
+
+ return () => observer.disconnect();
+}
diff --git a/app/assets/javascripts/streaming/html_stream.js b/app/assets/javascripts/streaming/html_stream.js
new file mode 100644
index 00000000000..8182f69a607
--- /dev/null
+++ b/app/assets/javascripts/streaming/html_stream.js
@@ -0,0 +1,33 @@
+import { ChunkWriter } from '~/streaming/chunk_writer';
+
+export class HtmlStream {
+ constructor(element) {
+ const streamDocument = document.implementation.createHTMLDocument('stream');
+
+ streamDocument.open();
+ streamDocument.write('<streaming-element>');
+
+ const virtualStreamingElement = streamDocument.querySelector('streaming-element');
+ element.appendChild(document.adoptNode(virtualStreamingElement));
+
+ this.streamDocument = streamDocument;
+ }
+
+ withChunkWriter(config) {
+ return new ChunkWriter(this, config);
+ }
+
+ write(chunk) {
+ // eslint-disable-next-line no-unsanitized/method
+ this.streamDocument.write(chunk);
+ }
+
+ close() {
+ this.streamDocument.write('</streaming-element>');
+ this.streamDocument.close();
+ }
+
+ abort() {
+ this.streamDocument.close();
+ }
+}
diff --git a/app/assets/javascripts/streaming/polyfills.js b/app/assets/javascripts/streaming/polyfills.js
new file mode 100644
index 00000000000..a9a044a3e99
--- /dev/null
+++ b/app/assets/javascripts/streaming/polyfills.js
@@ -0,0 +1,5 @@
+import { createReadableStreamWrapper } from '@mattiasbuelens/web-streams-adapter';
+import { ReadableStream as PolyfillReadableStream } from 'web-streams-polyfill';
+
+// TODO: remove this when our WebStreams API reaches 100% support
+export const toPolyfillReadable = createReadableStreamWrapper(PolyfillReadableStream);
diff --git a/app/assets/javascripts/streaming/rate_limit_stream_requests.js b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
new file mode 100644
index 00000000000..04a592baa16
--- /dev/null
+++ b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
@@ -0,0 +1,87 @@
+const consumeReadableStream = (stream) => {
+ return new Promise((resolve, reject) => {
+ stream.pipeTo(
+ new WritableStream({
+ close: resolve,
+ abort: reject,
+ }),
+ );
+ });
+};
+
+const wait = (timeout) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, timeout);
+ });
+
+// this rate-limiting approach is specific to Web Streams
+// because streams only resolve when they're fully consumed
+// so we need to split each stream into two pieces:
+// one for the rate-limiter (wait for all the bytes to be sent)
+// another for the original consumer
+export const rateLimitStreamRequests = ({
+ factory,
+ total,
+ maxConcurrentRequests,
+ immediateCount = maxConcurrentRequests,
+ timeout = 0,
+}) => {
+ if (total === 0) return [];
+
+ const unsettled = [];
+
+ const pushUnsettled = (promise) => {
+ let res;
+ let rej;
+ const consume = new Promise((resolve, reject) => {
+ res = resolve;
+ rej = reject;
+ });
+ unsettled.push(consume);
+ return promise.then((stream) => {
+ const [first, second] = stream.tee();
+ // eslint-disable-next-line promise/no-nesting
+ consumeReadableStream(first)
+ .then(() => {
+ unsettled.splice(unsettled.indexOf(consume), 1);
+ res();
+ })
+ .catch(rej);
+ return second;
+ }, rej);
+ };
+
+ const immediate = Array.from({ length: Math.min(immediateCount, total) }, (_, i) =>
+ pushUnsettled(factory(i)),
+ );
+
+ const queue = [];
+ const flushQueue = () => {
+ const promises =
+ unsettled.length > maxConcurrentRequests ? unsettled : [...unsettled, wait(timeout)];
+ // errors are handled by the caller
+ // eslint-disable-next-line promise/catch-or-return
+ Promise.race(promises).then(() => {
+ const cb = queue.shift();
+ cb?.();
+ if (queue.length !== 0) {
+ // wait for stream consumer promise to be removed from unsettled
+ queueMicrotask(flushQueue);
+ }
+ });
+ };
+
+ const throttled = Array.from({ length: total - immediateCount }, (_, i) => {
+ return new Promise((resolve, reject) => {
+ queue.push(() => {
+ pushUnsettled(factory(i + immediateCount))
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+ });
+
+ flushQueue();
+
+ return [...immediate, ...throttled];
+};
diff --git a/app/assets/javascripts/streaming/render_balancer.js b/app/assets/javascripts/streaming/render_balancer.js
new file mode 100644
index 00000000000..66929ff3a54
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_balancer.js
@@ -0,0 +1,36 @@
+export class RenderBalancer {
+ previousTimestamp = undefined;
+
+ constructor({ increase, decrease, highFrameTime, lowFrameTime }) {
+ this.increase = increase;
+ this.decrease = decrease;
+ this.highFrameTime = highFrameTime;
+ this.lowFrameTime = lowFrameTime;
+ }
+
+ render(fn) {
+ return new Promise((resolve) => {
+ const callback = (timestamp) => {
+ this.throttle(timestamp);
+ if (fn()) requestAnimationFrame(callback);
+ else resolve();
+ };
+ requestAnimationFrame(callback);
+ });
+ }
+
+ throttle(timestamp) {
+ const { previousTimestamp } = this;
+ this.previousTimestamp = timestamp;
+ if (previousTimestamp === undefined) return;
+
+ const duration = Math.round(timestamp - previousTimestamp);
+ if (!duration) return;
+
+ if (duration >= this.highFrameTime) {
+ this.decrease();
+ } else if (duration < this.lowFrameTime) {
+ this.increase();
+ }
+ }
+}
diff --git a/app/assets/javascripts/streaming/render_html_streams.js b/app/assets/javascripts/streaming/render_html_streams.js
new file mode 100644
index 00000000000..7201e541777
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_html_streams.js
@@ -0,0 +1,40 @@
+import { HtmlStream } from '~/streaming/html_stream';
+
+async function pipeStreams(domWriter, streamPromises) {
+ try {
+ for await (const stream of streamPromises.slice(0, -1)) {
+ await stream.pipeTo(domWriter, { preventClose: true });
+ }
+ const stream = await streamPromises[streamPromises.length - 1];
+ await stream.pipeTo(domWriter);
+ } catch (error) {
+ domWriter.abort(error);
+ }
+}
+
+// this function (and the rest of the pipeline) expects polyfilled streams
+// do not pass native streams here unless our browser support allows for it
+// TODO: remove this notice when our WebStreams API support reaches 100%
+export function renderHtmlStreams(streamPromises, element, config) {
+ if (streamPromises.length === 0) return Promise.resolve();
+
+ const chunkedHtmlStream = new HtmlStream(element).withChunkWriter(config);
+
+ return new Promise((resolve, reject) => {
+ const domWriter = new WritableStream({
+ write(chunk) {
+ return chunkedHtmlStream.write(chunk);
+ },
+ close() {
+ chunkedHtmlStream.close();
+ resolve();
+ },
+ abort(error) {
+ chunkedHtmlStream.abort();
+ reject(error);
+ },
+ });
+
+ pipeStreams(domWriter, streamPromises);
+ });
+}
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index f1ddb8290a0..ad2111140a1 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -1,83 +1,216 @@
<script>
-import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { contextSwitcherItems } from '../mock_data';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
+import { trackContextAccess, formatContextSwitcherItems } from '../utils';
+import { maxSize, applyMaxSize } from '../popper_max_size_modifier';
import NavItem from './nav_item.vue';
+import ProjectsList from './projects_list.vue';
+import GroupsList from './groups_list.vue';
+import ContextSwitcherToggle from './context_switcher_toggle.vue';
export default {
+ i18n: {
+ contextNavigation: s__('Navigation|Context navigation'),
+ switchTo: s__('Navigation|Switch context'),
+ 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,
+ manual: true,
+ variables() {
+ return {
+ username: this.username,
+ search: this.searchString,
+ };
+ },
+ result(response) {
+ this.hasError = false;
+ try {
+ const {
+ data: {
+ projects: { nodes: projects },
+ user: {
+ groups: { nodes: groups },
+ },
+ },
+ } = response;
+
+ this.projects = formatContextSwitcherItems(projects);
+ this.groups = formatContextSwitcherItems(groups);
+ } catch (e) {
+ this.handleError(e);
+ }
+ },
+ error(e) {
+ this.handleError(e);
+ },
+ skip() {
+ return !this.searchString;
+ },
+ },
+ },
components: {
- GlAvatar,
+ GlDisclosureDropdown,
+ ContextSwitcherToggle,
GlSearchBoxByType,
+ GlLoadingIcon,
+ GlAlert,
NavItem,
+ ProjectsList,
+ GroupsList,
},
- i18n: {
- contextNavigation: s__('Navigation|Context navigation'),
- switchTo: s__('Navigation|Switch to...'),
- recentProjects: s__('Navigation|Recent projects'),
- recentGroups: s__('Navigation|Recent groups'),
+ props: {
+ persistentLinks: {
+ type: Array,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ projectsPath: {
+ type: String,
+ required: true,
+ },
+ groupsPath: {
+ type: String,
+ required: true,
+ },
+ currentContext: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ contextHeader: {
+ type: Object,
+ required: true,
+ },
},
- contextSwitcherItems,
- viewAllProjectsItem: {
- title: s__('Navigation|View all projects'),
- link: '/projects',
- icon: 'project',
+ data() {
+ return {
+ searchString: '',
+ projects: [],
+ groups: [],
+ hasError: false,
+ isOpen: false,
+ };
},
- viewAllGroupsItem: {
- title: s__('Navigation|View all groups'),
- link: '/groups',
- icon: 'group',
+ computed: {
+ isSearch() {
+ return Boolean(this.searchString);
+ },
+ isSearching() {
+ return this.$apollo.queries.groupsAndProjects.loading;
+ },
+ },
+ watch: {
+ isOpen(isOpen) {
+ this.$emit('toggle', isOpen);
+
+ if (isOpen) {
+ this.focusInput();
+ }
+ },
+ },
+ created() {
+ if (this.currentContext.namespace) {
+ trackContextAccess(this.username, this.currentContext);
+ }
+ },
+ methods: {
+ close() {
+ this.$refs['disclosure-dropdown'].close();
+ },
+ focusInput() {
+ this.$refs['search-box'].focusInput();
+ },
+ handleError(e) {
+ Sentry.captureException(e);
+ this.hasError = true;
+ },
+ onDisclosureDropdownShown() {
+ this.isOpen = true;
+ },
+ onDisclosureDropdownHidden() {
+ this.isOpen = false;
+ },
+ },
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ popperOptions: {
+ modifiers: [maxSize, applyMaxSize],
},
};
</script>
<template>
- <div>
- <gl-search-box-by-type />
- <nav :aria-label="$options.i18n.contextNavigation">
- <ul class="gl-p-0 gl-list-style-none">
- <li>
- <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" />
- </ul>
- </li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentProjects }}
- </div>
- <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0">
- <nav-item
- v-for="project in $options.contextSwitcherItems.recentProjects"
- :key="project.title"
- :item="project"
- >
- <template #icon>
- <gl-avatar shape="rect" :size="32" :src="project.avatar" />
- </template>
- </nav-item>
- <nav-item :item="$options.viewAllProjectsItem" />
- </ul>
- </li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentGroups }}
- </div>
- <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0">
+ <gl-disclosure-dropdown
+ ref="disclosure-dropdown"
+ class="context-switcher gl-w-full"
+ placement="center"
+ :popper-options="$options.popperOptions"
+ @shown="onDisclosureDropdownShown"
+ @hidden="onDisclosureDropdownHidden"
+ >
+ <template #toggle>
+ <context-switcher-toggle :context="contextHeader" :expanded="isOpen" />
+ </template>
+ <div aria-hidden="true" class="gl-font-sm gl-font-weight-bold gl-px-4 gl-pt-3 gl-pb-4">
+ {{ $options.i18n.switchTo }}
+ </div>
+ <div class="gl-p-1 gl-border-t 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>
+ <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" data-qa-selector="context_navigation">
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <li v-if="!isSearch">
+ <ul
+ :aria-label="$options.i18n.switchTo"
+ class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2"
+ >
<nav-item
- v-for="project in $options.contextSwitcherItems.recentGroups"
- :key="project.title"
- :item="project"
- >
- <template #icon>
- <gl-avatar shape="rect" :size="32" :src="project.avatar" />
- </template>
- </nav-item>
- <nav-item :item="$options.viewAllGroupsItem" />
+ v-for="item in persistentLinks"
+ :key="item.link"
+ :item="item"
+ :link-classes="{ [item.link_classes]: item.link_classes }"
+ />
</ul>
</li>
+ <projects-list
+ :username="username"
+ :view-all-link="projectsPath"
+ :is-search="isSearch"
+ :search-results="projects"
+ />
+ <groups-list
+ class="gl-border-t gl-border-gray-50"
+ :username="username"
+ :view-all-link="groupsPath"
+ :is-search="isSearch"
+ :search-results="groups"
+ />
</ul>
</nav>
- </div>
+ </gl-disclosure-dropdown>
</template>
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 b6f058f7aee..cfb7e7732e9 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
export default {
components: {
@@ -7,10 +7,10 @@ export default {
GlAvatar,
GlIcon,
},
- directives: {
- CollapseToggle: GlCollapseToggleDirective,
- },
props: {
+ /*
+ * Contains metadata about the current view, e.g. `id`, `title` and `avatar`
+ */
context: {
type: Object,
required: true,
@@ -24,22 +24,39 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
+ avatarShape() {
+ return this.context.avatar_shape || 'rect';
+ },
},
};
</script>
<template>
<button
- v-collapse-toggle.context-switcher
type="button"
- class="context-switcher-toggle gl-bg-transparent 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-pl-3 gl-pr-5 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 gl-flex-shrink-0"
+ data-qa-selector="context_switcher"
>
- <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" />
- <div class="gl-overflow-auto">
+ <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 class="gl-text-gray-700" :name="context.icon" :size="16" />
+ </span>
+ <gl-avatar
+ v-else
+ :size="24"
+ :shape="avatarShape"
+ :entity-name="context.title"
+ :entity-id="context.id"
+ :src="context.avatar"
+ class="gl-mr-3 gl-ml-4"
+ />
+ <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-icon :name="collapseIcon" />
+ <span class="gl-flex-grow-1 gl-text-right gl-mr-4">
+ <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 62a1e5a6b20..a6f19ff95f3 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { highCountTrim } from '~/lib/utils/text_utility';
export default {
components: {
@@ -7,7 +8,7 @@ export default {
},
props: {
count: {
- type: Number,
+ type: [Number, String],
required: true,
},
href: {
@@ -31,6 +32,12 @@ export default {
component() {
return this.href ? 'a' : 'button';
},
+ formattedCount() {
+ if (Number.isFinite(this.count)) {
+ return highCountTrim(this.count);
+ }
+ return this.count;
+ },
},
};
</script>
@@ -40,9 +47,9 @@ export default {
:is="component"
:aria-label="ariaLabel"
:href="href"
- class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none"
+ class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none gl-focus--focus"
>
<gl-icon aria-hidden="true" :name="icon" />
- <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span>
+ <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ formattedCount }}</span>
</component>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index e92a6cbf5f5..fa6056aff5e 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -1,11 +1,28 @@
<script>
-import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { __ } from '~/locale';
+import {
+ TOP_NAV_INVITE_MEMBERS_COMPONENT,
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
+} from '~/invite_members/constants';
+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: {
GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
GlTooltip,
+ InviteMembersTrigger,
},
i18n: {
createNew: __('Create new...'),
@@ -16,22 +33,74 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ isInvitedMembers(groupItem) {
+ return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
+ },
+ closeAndFocus() {
+ this.$refs.dropdown.closeAndFocus();
+ },
+ },
toggleId: 'create-menu-toggle',
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
+ },
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
};
</script>
<template>
<div>
<gl-disclosure-dropdown
+ ref="dropdown"
category="tertiary"
icon="plus"
- :items="groups"
no-caret
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
- />
- <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
+ :popper-options="$options.popperOptions"
+ data-qa-selector="new_menu_toggle"
+ data-testid="new-menu-toggle"
+ @shown="dropdownOpen = true"
+ @hidden="dropdownOpen = false"
+ >
+ <gl-disclosure-dropdown-group
+ v-for="(group, index) in groups"
+ :key="group.name"
+ :bordered="index !== 0"
+ :group="group"
+ >
+ <template v-for="groupItem in group.items">
+ <invite-members-trigger
+ v-if="isInvitedMembers(groupItem)"
+ :key="`${groupItem.text}-trigger`"
+ trigger-source="top-nav"
+ :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN"
+ @modal-opened="closeAndFocus"
+ />
+ <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" />
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
+ <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
new file mode 100644
index 00000000000..11bf2ddbd30
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -0,0 +1,96 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
+import ItemsList from './items_list.vue';
+
+export default {
+ components: {
+ ItemsList,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ pristineText: {
+ type: String,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ maxItems: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ cachedFrequentItems: [],
+ };
+ },
+ computed: {
+ isEmpty() {
+ return !this.cachedFrequentItems.length;
+ },
+ },
+ created() {
+ this.getItemsFromLocalStorage();
+ },
+ methods: {
+ getItemsFromLocalStorage() {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return;
+ }
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
+ const topFrequentItems = getTopFrequentItems(parsedCachedFrequentItems, this.maxItems);
+ this.cachedFrequentItems = formatContextSwitcherItems(topFrequentItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ 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);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-3">
+ <div
+ data-testid="list-title"
+ aria-hidden="true"
+ class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-semibold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ >
+ <span class="gl-flex-grow-1 gl-px-3">{{ title }}</span>
+ </div>
+ <div
+ v-if="isEmpty"
+ data-testid="empty-text"
+ class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3"
+ >
+ {{ pristineText }}
+ </div>
+ <items-list :aria-label="title" :items="cachedFrequentItems" @remove-item="handleItemRemove">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </li>
+</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
new file mode 100644
index 00000000000..55c28661440
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -0,0 +1,316 @@
+<script>
+import {
+ GlSearchBoxByType,
+ GlOutsideDirective as Outside,
+ GlIcon,
+ GlToken,
+ GlTooltipDirective,
+ GlResizeObserverDirective,
+ GlModal,
+} from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+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 { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys';
+import {
+ MIN_SEARCH_TERM,
+ SEARCH_GITLAB,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+} from '~/vue_shared/global_search/constants';
+import {
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ SCOPE_TOKEN_MAX_LENGTH,
+ INPUT_FIELD_PADDING,
+ IS_SEARCHING,
+ SEARCH_MODAL_ID,
+ SEARCH_INPUT_SELECTOR,
+ SEARCH_RESULTS_ITEM_SELECTOR,
+} from '../constants';
+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: 'GlobalSearchModal',
+ SEARCH_MODAL_ID,
+ i18n: {
+ SEARCH_GITLAB,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ MIN_SEARCH_TERM,
+ },
+ directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ components: {
+ GlSearchBoxByType,
+ GlobalSearchDefaultItems,
+ GlobalSearchScopedItems,
+ GlobalSearchAutocompleteItems,
+ GlIcon,
+ GlToken,
+ GlModal,
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'searchContext']),
+ ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
+ searchText: {
+ get() {
+ return this.search;
+ },
+ set(value) {
+ this.setSearch(value);
+ },
+ },
+ showDefaultItems() {
+ return !this.searchText;
+ },
+ searchTermOverMin() {
+ return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ },
+ showScopedSearchItems() {
+ return this.searchTermOverMin && this.scopedSearchOptions.length > 1;
+ },
+ 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, {
+ count: this.searchOptions.length,
+ });
+ },
+ searchBarClasses() {
+ return {
+ [IS_SEARCHING]: this.searchTermOverMin,
+ };
+ },
+ showScopeHelp() {
+ return this.searchTermOverMin;
+ },
+ searchBarItem() {
+ return this.searchOptions?.[0];
+ },
+ infieldHelpContent() {
+ return this.searchBarItem?.scope || this.searchBarItem?.description;
+ },
+ infieldHelpIcon() {
+ return this.searchBarItem?.icon;
+ },
+ scopeTokenTitle() {
+ return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
+ scope: this.infieldHelpContent,
+ });
+ },
+ },
+ methods: {
+ ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
+ getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ if (!searchTerm) {
+ this.clearAutocomplete();
+ } else {
+ this.fetchAutocompleteOptions();
+ }
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ observeTokenWidth({ contentRect: { width } }) {
+ const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
+ if (!inputField) {
+ return;
+ }
+ inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
+ },
+ 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_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+};
+</script>
+
+<template>
+ <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"
+ @hidden="$emit('hidden')"
+ @shown="$emit('shown')"
+ >
+ <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"
+ >
+ <div class="gl-p-1">
+ <gl-search-box-by-type
+ id="search"
+ ref="searchInputBox"
+ v-model="searchText"
+ role="searchbox"
+ data-testid="global-search-input"
+ data-qa-selector="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"
+ />
+ <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"
+ />
+ {{
+ 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>
+
+ <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
new file mode 100644
index 00000000000..cd623200b03
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -0,0 +1,90 @@
+<script>
+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 { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'GlobalSearchAutocompleteItems',
+ i18n: {
+ AUTOCOMPLETE_ERROR_MESSAGE,
+ },
+ components: {
+ GlAvatar,
+ GlAlert,
+ GlLoadingIcon,
+ GlDisclosureDropdownGroup,
+ },
+ directives: {
+ SafeHtml,
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'autocompleteError']),
+ ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
+ isPrecededByScopedOptions() {
+ return this.scopedSearchOptions.length > 1;
+ },
+ },
+ methods: {
+ highlightedName(val) {
+ return highlight(val, this.search);
+ },
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div>
+ <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="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(item.text)"
+ class="gl-text-gray-900"
+ data-testid="autocomplete-item-name"
+ ></span>
+ <span
+ 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>
+ </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"
+ :dismissible="false"
+ variant="danger"
+ >
+ {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }}
+ </gl-alert>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..239c61fd750
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'GlobalSearchDefaultItems',
+ i18n: {
+ ALL_GITLAB,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ sectionHeader() {
+ return (
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
+ this.$options.i18n.ALL_GITLAB
+ );
+ },
+ defaultItemsGroup() {
+ return {
+ name: this.sectionHeader,
+ items: this.defaultSearchOptions,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <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
new file mode 100644
index 00000000000..76600f829f6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -0,0 +1,54 @@
+<script>
+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 { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
+
+export default {
+ name: 'GlobalSearchScopedItems',
+ components: {
+ GlIcon,
+ GlToken,
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['search']),
+ ...mapGetters(['scopedSearchGroup']),
+ },
+ methods: {
+ titleLabel(item) {
+ return sprintf(s__('GlobalSearch|in %{scope}'), {
+ search: this.search,
+ scope: item.scope || item.description,
+ });
+ },
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <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 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
new file mode 100644
index 00000000000..cb267df6122
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -0,0 +1,28 @@
+export const ICON_PROJECT = 'project';
+
+export const ICON_GROUP = 'group';
+
+export const ICON_SUBGROUP = 'subgroup';
+
+export const LARGE_AVATAR_PX = 32;
+
+export const SMALL_AVATAR_PX = 16;
+
+export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
+
+export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
+
+export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
+
+export const SCOPE_TOKEN_MAX_LENGTH = 36;
+
+export const INPUT_FIELD_PADDING = 84;
+
+export const IS_SEARCHING = 'is-searching';
+
+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_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js
new file mode 100644
index 00000000000..a0f9e594506
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js
@@ -0,0 +1,45 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import { FETCH_TYPES } from '../constants';
+import * as types from './mutation_types';
+
+export const autocompleteQuery = ({ state, fetchType }) => {
+ const query = omitBy(
+ {
+ term: state.search,
+ project_id: state.searchContext?.project?.id,
+ project_ref: state.searchContext?.ref,
+ filter: fetchType,
+ },
+ isNil,
+ );
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
+const doFetch = ({ commit, state, fetchType }) => {
+ return axios
+ .get(autocompleteQuery({ state, fetchType }))
+ .then(({ data }) => {
+ commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data);
+ })
+ .catch(() => {
+ commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
+ });
+};
+
+export const fetchAutocompleteOptions = ({ commit, state }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType }));
+
+ return Promise.all(promises);
+};
+
+export const clearAutocomplete = ({ commit }) => {
+ commit(types.CLEAR_AUTOCOMPLETE);
+};
+
+export const setSearch = ({ commit }, value) => {
+ commit(types.SET_SEARCH, value);
+};
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
new file mode 100644
index 00000000000..4a42f416206
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -0,0 +1,222 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+ MSG_IN_ALL_GITLAB,
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
+ SEARCH_RESULTS_ORDER,
+} from '~/vue_shared/global_search/constants';
+import { getFormattedItem } from '../utils';
+
+import {
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+} from '../constants';
+
+export const searchQuery = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedIssuesPath = (state) => {
+ if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) {
+ return false;
+ }
+
+ return (
+ state.searchContext?.project_metadata?.issues_path ||
+ state.searchContext?.group_metadata?.issues_path ||
+ state.issuesPath
+ );
+};
+
+export const scopedMRPath = (state) => {
+ return (
+ state.searchContext?.project_metadata?.mr_path ||
+ state.searchContext?.group_metadata?.mr_path ||
+ state.mrPath
+ );
+};
+
+export const defaultSearchOptions = (state, getters) => {
+ const userName = gon.current_username;
+
+ const issues = [
+ {
+ text: MSG_ISSUES_ASSIGNED_TO_ME,
+ href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: MSG_ISSUES_IVE_CREATED,
+ href: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ },
+ ];
+
+ const mergeRequests = [
+ {
+ text: MSG_MR_ASSIGNED_TO_ME,
+ href: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: MSG_MR_IM_REVIEWER,
+ href: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ },
+ {
+ text: MSG_MR_IVE_CREATED,
+ href: `${getters.scopedMRPath}/?author_username=${userName}`,
+ },
+ ];
+ return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
+};
+
+export const projectUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const groupUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const allUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedSearchOptions = (state, getters) => {
+ const items = [];
+
+ if (state.searchContext?.project) {
+ items.push({
+ text: 'scoped-in-project',
+ scope: state.searchContext.project?.name || '',
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ href: getters.projectUrl,
+ });
+ }
+
+ if (state.searchContext?.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,
+ href: getters.groupUrl,
+ });
+ }
+
+ items.push({
+ text: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ href: getters.allUrl,
+ });
+
+ 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((item) => {
+ const group = groupedOptions[item.category];
+ const formattedItem = getFormattedItem(item, state.searchContext);
+
+ if (group) {
+ group.items.push(formattedItem);
+ } else {
+ groupedOptions[item.category] = {
+ name: formattedItem.category,
+ items: [formattedItem],
+ };
+
+ results.push(groupedOptions[formattedItem.category]);
+ }
+ });
+
+ return results.sort(
+ (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name),
+ );
+};
+
+export const searchOptions = (state, getters) => {
+ if (!state.search) {
+ return getters.defaultSearchOptions;
+ }
+
+ const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
+ (items, group) => {
+ return [...items, ...group.items];
+ },
+ [],
+ );
+
+ if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return sortedAutocompleteOptions;
+ }
+
+ return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions);
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
new file mode 100644
index 00000000000..b83433c5b49
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+}) => ({
+ actions,
+ getters,
+ mutations,
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
+});
+
+const createStore = (config) => new Vuex.Store(getStoreConfig(config));
+export default createStore;
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
new file mode 100644
index 00000000000..d7d9ebecd16
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 00000000000..9936c3f59d8
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -0,0 +1,26 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_AUTOCOMPLETE](state) {
+ state.loading = true;
+ state.autocompleteOptions = [];
+ state.autocompleteError = false;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
+ state.loading = false;
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(data);
+ state.autocompleteError = false;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
+ state.loading = false;
+ state.autocompleteOptions = [];
+ state.autocompleteError = true;
+ },
+ [types.CLEAR_AUTOCOMPLETE](state) {
+ state.autocompleteOptions = [];
+ state.autocompleteError = false;
+ },
+ [types.SET_SEARCH](state, value) {
+ state.search = value;
+ },
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/state.js b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js
new file mode 100644
index 00000000000..bebdbc7b92e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js
@@ -0,0 +1,19 @@
+const createState = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+}) => ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+ autocompleteOptions: [],
+ autocompleteError: false,
+ loading: false,
+});
+export default createState;
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
new file mode 100644
index 00000000000..4fa15f1cd76
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -0,0 +1,81 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_GROUPS_COUNT } from '../constants';
+import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ MAX_FREQUENT_GROUPS_COUNT,
+ components: {
+ FrequentItemsList,
+ SearchResults,
+ NavItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ viewAllLink: {
+ type: String,
+ required: true,
+ },
+ isSearch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ storageKey() {
+ return `${this.username}/frequent-groups`;
+ },
+ viewAllProps() {
+ return {
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your groups'),
+ icon: 'group',
+ },
+ linkClasses: { 'dashboard-shortcuts-groups': true },
+ };
+ },
+ },
+ i18n: {
+ 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'),
+ },
+};
+</script>
+
+<template>
+ <search-results
+ v-if="isSearch"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item v-bind="viewAllProps" />
+ </template>
+ </search-results>
+ <frequent-items-list
+ v-else
+ :title="$options.i18n.title"
+ :storage-key="storageKey"
+ :max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
+ :pristine-text="$options.i18n.pristineText"
+ >
+ <template #view-all-items>
+ <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 8e7c7efa631..1fffbb05d03 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -1,19 +1,32 @@
<script>
-import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
+import {
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+} from '@gitlab/ui';
import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
+import Tracking from '~/tracking';
+import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS, helpCenterState } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -4;
export default {
components: {
GlBadge,
GlButton,
+ GlIcon,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
GitlabVersionCheckBadge,
},
+ mixins: [Tracking.mixin({ property: 'nav_help_menu' })],
i18n: {
help: __('Help'),
support: __('Support'),
@@ -25,6 +38,7 @@ export default {
shortcuts: __('Keyboard shortcuts'),
version: __('Your GitLab version'),
whatsnew: __("What's new"),
+ chat: s__('TanukiBot|Ask GitLab Chat'),
},
props: {
sidebarData: {
@@ -35,6 +49,7 @@ export default {
data() {
return {
showWhatsNewNotification: this.shouldShowWhatsNewNotification(),
+ helpCenterState,
};
},
computed: {
@@ -46,28 +61,84 @@ 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/' },
+ this.sidebarData.show_tanuki_bot && {
+ icon: 'tanuki',
+ text: this.$options.i18n.chat,
+ action: this.showTanukiBotChat,
+ extraAttrs: {
+ ...this.trackingAttrs('tanuki_bot_help_dropdown'),
+ },
+ },
+ {
+ 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.${DOMAIN}`,
+ 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.${DOMAIN}/`,
+ 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' },
- ],
+ {
+ text: this.$options.i18n.feedback,
+ href: `${PROMO_URL}/submit-feedback`,
+ extraAttrs: {
+ ...this.trackingAttrs('submit_feedback'),
+ },
+ },
+ ].filter(Boolean),
},
helpActions: {
items: [
{
text: this.$options.i18n.shortcuts,
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: '?',
},
this.sidebarData.display_whats_new && {
@@ -76,6 +147,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),
},
@@ -96,15 +172,14 @@ export default {
return true;
},
- handleAction({ action }) {
- if (action) {
- action();
- }
+ showKeyboardShortcuts() {
+ this.$refs.dropdown.close();
},
- showKeyboardShortcuts() {
+ showTanukiBotChat() {
this.$refs.dropdown.close();
- window?.toggleShortcutsHelp();
+
+ this.helpCenterState.showTanukiBotChatDrawer = true;
},
async showWhatsNew() {
@@ -122,15 +197,43 @@ 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"></span>
+ <span v-if="showWhatsNewNotification" class="notification-dot-info"></span>
{{ $options.i18n.help }}
</gl-button>
</template>
@@ -140,11 +243,7 @@ export default {
:group="itemGroups.versionCheck"
>
<template #list-item="{ item }">
- <a
- :href="item.href"
- tabindex="-1"
- class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none"
- >
+ <span class="gl-display-flex gl-flex-direction-column gl-line-height-24">
<span class="gl-font-sm gl-font-weight-bold">
{{ item.text }}
<gl-emoji data-name="rocket" />
@@ -153,25 +252,31 @@ export default {
<span class="gl-mr-2">{{ item.version }}</span>
<gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" />
</span>
- </a>
+ </span>
</template>
</gl-disclosure-dropdown-group>
<gl-disclosure-dropdown-group
:group="itemGroups.helpLinks"
:bordered="sidebarData.show_version_check"
- />
+ >
+ <template #list-item="{ item }">
+ <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ {{ item.text }}
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-orange-500" />
+ </span>
+ </template>
+ </gl-disclosure-dropdown-group>
- <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction">
+ <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered>
<template #list-item="{ item }">
- <button
- tabindex="-1"
- class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900"
+ <span
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-n1"
>
{{ item.text }}
<gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge>
<kbd v-else-if="item.shortcut" class="flat">?</kbd>
- </button>
+ </span>
</template>
</gl-disclosure-dropdown-group>
</gl-disclosure-dropdown>
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
new file mode 100644
index 00000000000..ef27251dc6c
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ components: {
+ GlButton,
+ ProjectAvatar,
+ NavItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <nav-item
+ v-for="item in items"
+ :key="item.id"
+ :item="item"
+ :link-classes="{ 'gl-py-2!': true }"
+ >
+ <template #icon>
+ <project-avatar
+ :project-id="item.id"
+ :project-name="item.title"
+ :project-avatar-url="item.avatar"
+ :size="24"
+ aria-hidden="true"
+ />
+ </template>
+ <template #actions>
+ <gl-button
+ v-gl-tooltip.right.viewport
+ size="small"
+ category="tertiary"
+ icon="dash"
+ :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)"
+ />
+ </template>
+ </nav-item>
+ <slot name="view-all-items"></slot>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
new file mode 100644
index 00000000000..93c249dffeb
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -0,0 +1,122 @@
+<script>
+import { kebabCase } from 'lodash';
+import { GlCollapse, GlIcon } from '@gitlab/ui';
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'MenuSection',
+ components: {
+ GlCollapse,
+ GlIcon,
+ NavItem,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ separated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ tag: {
+ type: String,
+ required: false,
+ default: 'div',
+ },
+ },
+ data() {
+ return {
+ isExpanded: Boolean(this.expanded || this.item.is_active),
+ };
+ },
+ computed: {
+ buttonProps() {
+ return {
+ 'aria-controls': this.itemId,
+ 'aria-expanded': String(this.isExpanded),
+ 'data-qa-menu-item': this.item.title,
+ };
+ },
+ collapseIcon() {
+ return this.isExpanded ? 'chevron-up' : 'chevron-down';
+ },
+ computedLinkClasses() {
+ return {
+ 'gl-bg-t-gray-a-08': this.isActive,
+ };
+ },
+ isActive() {
+ return !this.isExpanded && this.item.is_active;
+ },
+ itemId() {
+ return kebabCase(this.item.title);
+ },
+ },
+ watch: {
+ isExpanded(newIsExpanded) {
+ this.$emit('collapse-toggle', newIsExpanded);
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="tag">
+ <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
+ <button
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 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! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
+ :class="computedLinkClasses"
+ data-qa-selector="menu_section_button"
+ :data-qa-section-name="item.title"
+ v-bind="buttonProps"
+ @click="isExpanded = !isExpanded"
+ >
+ <span
+ :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
+ class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
+ aria-hidden="true"
+ style="width: 3px; border-radius: 3px; margin-right: 1px"
+ ></span>
+ <span 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 item-icon" />
+ </slot>
+ </span>
+
+ <span class="gl-pr-3 gl-text-gray-900 gl-truncate-end">
+ {{ item.title }}
+ </span>
+
+ <span class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-text-gray-400">
+ <gl-icon :name="collapseIcon" />
+ </span>
+ </button>
+
+ <gl-collapse
+ :id="itemId"
+ v-model="isExpanded"
+ :aria-label="item.title"
+ class="gl-list-style-none gl-p-0 gl-m-0 gl-transition-duration-medium gl-transition-timing-function-ease"
+ data-qa-selector="menu_section"
+ :data-qa-section-name="item.title"
+ tag="ul"
+ >
+ <slot>
+ <nav-item
+ 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)"
+ />
+ </slot>
+ </gl-collapse>
+ </component>
+</template>
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 edc13e305cf..260c3906b93 100644
--- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui';
+import { userCounts } from '~/super_sidebar/user_counts_manager';
export default {
components: {
@@ -13,28 +14,28 @@ export default {
},
},
methods: {
- navigate() {
- this.$refs.link.click();
+ getCount(item) {
+ return userCounts[item.userCount] ?? item.count ?? 0;
},
},
};
</script>
<template>
- <gl-disclosure-dropdown :items="items" placement="center" @action="navigate">
+ <gl-disclosure-dropdown
+ :items="items"
+ placement="center"
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
+ >
<template #toggle>
<slot></slot>
</template>
<template #list-item="{ item }">
- <a
- ref="link"
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900"
- :href="item.href"
- tabindex="-1"
- >
+ <span class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
{{ item.text }}
- <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge>
- </a>
+ <gl-badge pill size="sm" variant="neutral">{{ getCount(item) }}</gl-badge>
+ </span>
</template>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index 4fd6918fd6f..ec1c4069b1a 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -1,37 +1,178 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ CLICK_MENU_ITEM_ACTION,
+ CLICK_PINNED_MENU_ITEM_ACTION,
+ TRACKING_UNKNOWN_ID,
+ TRACKING_UNKNOWN_PANEL,
+} from '~/super_sidebar/constants';
+import NavItemLink from './nav_item_link.vue';
+import NavItemRouterLink from './nav_item_router_link.vue';
export default {
+ i18n: {
+ pinItem: s__('Navigation|Pin item'),
+ unpinItem: s__('Navigation|Unpin item'),
+ },
name: 'NavItem',
components: {
+ GlButton,
GlIcon,
+ GlBadge,
+ NavItemLink,
+ NavItemRouterLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ pinnedItemIds: { default: { ids: [] } },
+ panelSupportsPins: { default: false },
+ panelType: { default: '' },
},
props: {
+ isInPinnedSection: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isStatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
item: {
type: Object,
required: true,
},
+ linkClasses: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ pillData() {
+ return this.item.pill_count;
+ },
+ hasPill() {
+ return (
+ Number.isFinite(this.pillData) ||
+ (typeof this.pillData === 'string' && this.pillData !== '')
+ );
+ },
+ isPinnable() {
+ return this.panelSupportsPins && !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': this.isInPinnedSection
+ ? CLICK_PINNED_MENU_ITEM_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() {
+ return {
+ ...this.$attrs,
+ ...this.trackingProps,
+ item: this.item,
+ 'data-qa-submenu-item': this.item.title,
+ 'data-method': this.item.data_method ?? null,
+ };
+ },
+ computedLinkClasses() {
+ return {
+ 'gl-py-2': this.isPinnable,
+ 'gl-py-3': !this.isPinnable,
+ [this.item.link_classes]: this.item.link_classes,
+ ...this.linkClasses,
+ };
+ },
+ navItemLinkComponent() {
+ return this.item.to ? NavItemRouterLink : NavItemLink;
+ },
},
};
</script>
<template>
<li>
- <a
- :href="item.link"
- class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08"
+ <component
+ :is="navItemLinkComponent"
+ #default="{ isActive }"
+ v-bind="linkProps"
+ class="nav-item-link 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! gl-focus--focus"
+ :class="computedLinkClasses"
+ data-qa-selector="nav_item_link"
+ data-testid="nav-item-link"
>
- <div class="gl-mr-3">
+ <div
+ :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
+ class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
+ aria-hidden="true"
+ style="width: 3px; border-radius: 3px; margin-right: 1px"
+ data-testid="active-indicator"
+ ></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" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ <gl-icon
+ v-else-if="isInPinnedSection"
+ name="grip"
+ class="gl-text-gray-400 gl-ml-2 draggable-icon"
+ />
</slot>
</div>
- <div class="gl-pr-3">
+ <div class="gl-pr-8 gl-text-gray-900 gl-truncate-end">
{{ item.title }}
- <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1">
+ <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end">
{{ item.subtitle }}
</div>
</div>
- </a>
+ <slot name="actions"></slot>
+ <span v-if="hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-relative">
+ <gl-badge
+ v-if="hasPill"
+ size="sm"
+ variant="neutral"
+ :class="{ 'nav-item-badge gl-absolute gl-right-0 gl-top-2': isPinnable }"
+ >
+ {{ pillData }}
+ </gl-badge>
+ <gl-button
+ v-if="isPinnable && !isPinned"
+ v-gl-tooltip.right.viewport="$options.i18n.pinItem"
+ 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"
+ v-gl-tooltip.right.viewport="$options.i18n.unpinItem"
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.unpinItem"
+ icon="thumbtack-solid"
+ @click.prevent="$emit('pin-remove', item.id)"
+ />
+ </span>
+ </component>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue
new file mode 100644
index 00000000000..8358e96db94
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue
@@ -0,0 +1,35 @@
+<script>
+import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants';
+import { ariaCurrent } from '../utils';
+
+export default {
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isActive() {
+ return this.item.is_active;
+ },
+ linkProps() {
+ return {
+ href: this.item.link,
+ 'aria-current': ariaCurrent(this.isActive),
+ };
+ },
+ computedLinkClasses() {
+ return {
+ [NAV_ITEM_LINK_ACTIVE_CLASS]: this.isActive,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <a v-bind="linkProps" :class="computedLinkClasses">
+ <slot :is-active="isActive"></slot>
+ </a>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue
new file mode 100644
index 00000000000..78aca24d9a6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue
@@ -0,0 +1,37 @@
+<script>
+import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants';
+import { ariaCurrent } from '../utils';
+
+export default {
+ NAV_ITEM_LINK_ACTIVE_CLASS,
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ linkProps() {
+ return {
+ to: this.item.to,
+ };
+ },
+ },
+ methods: {
+ ariaCurrent,
+ },
+};
+</script>
+
+<template>
+ <router-link
+ #default="{ href, navigate, isActive }"
+ v-bind="linkProps"
+ :active-class="$options.NAV_ITEM_LINK_ACTIVE_CLASS"
+ custom
+ >
+ <a :href="href" :aria-current="ariaCurrent(isActive)" @click="navigate">
+ <slot :is-active="isActive"></slot>
+ </a>
+ </router-link>
+</template>
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..ccd739c8bb1
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -0,0 +1,101 @@
+<script>
+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 MenuSection from './menu_section.vue';
+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,
+ MenuSection,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ separated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false',
+ draggableItems: this.items,
+ };
+ },
+ computed: {
+ isActive() {
+ return this.items.some((item) => item.is_active);
+ },
+ sectionItem() {
+ return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive };
+ },
+ 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>
+ <menu-section
+ :item="sectionItem"
+ :expanded="expanded"
+ :separated="separated"
+ @collapse-toggle="expanded = !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"
+ :item="item"
+ is-in-pinned-section
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </draggable>
+ <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem">
+ {{ $options.i18n.emptyHint }}
+ </li>
+ </menu-section>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
new file mode 100644
index 00000000000..78860e35eb1
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -0,0 +1,82 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants';
+import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ MAX_FREQUENT_PROJECTS_COUNT,
+ components: {
+ FrequentItemsList,
+ SearchResults,
+ NavItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ viewAllLink: {
+ type: String,
+ required: true,
+ },
+ isSearch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ storageKey() {
+ return `${this.username}/frequent-projects`;
+ },
+ viewAllProps() {
+ return {
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your projects'),
+ icon: 'project',
+ },
+ linkClasses: { 'dashboard-shortcuts-projects': true },
+ };
+ },
+ },
+ i18n: {
+ 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'),
+ },
+};
+</script>
+
+<template>
+ <search-results
+ v-if="isSearch"
+ class="gl-border-t-0"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item v-bind="viewAllProps" />
+ </template>
+ </search-results>
+ <frequent-items-list
+ v-else
+ :title="$options.i18n.title"
+ :storage-key="storageKey"
+ :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
+ :pristine-text="$options.i18n.pristineText"
+ >
+ <template #view-all-items>
+ <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
new file mode 100644
index 00000000000..ff933f341af
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -0,0 +1,99 @@
+<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,
+ required: true,
+ },
+ noResultsText: {
+ type: String,
+ required: true,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ 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-semibold',
+ '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',
+ // Layout
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ],
+};
+</script>
+
+<template>
+ <li class="gl-border-t gl-border-gray-50">
+ <button
+ v-collapse-toggle="collapseId"
+ :class="$options.buttonClasses"
+ class="gl-mx-3"
+ data-testid="search-results-toggle"
+ >
+ {{ title }}
+ <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-mb-3 gl-mx-4"
+ >
+ {{ 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
new file mode 100644
index 00000000000..08af9232107
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -0,0 +1,178 @@
+<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';
+import MenuSection from './menu_section.vue';
+
+export default {
+ name: 'SidebarMenu',
+ components: {
+ MenuSection,
+ 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);
+ },
+ hasStaticItems() {
+ return this.staticItems.length > 0;
+ },
+ },
+ 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);
+ });
+ },
+ isSection(navItem) {
+ return navItem.items?.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav class="gl-p-2 gl-relative">
+ <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0">
+ <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static />
+ </ul>
+ <pinned-section
+ v-if="supportsPins"
+ separated
+ :items="pinnedItems"
+ @pin-remove="destroyPin"
+ @pin-reorder="movePin"
+ />
+ <hr
+ v-if="supportsPins"
+ aria-hidden="true"
+ class="gl-my-2 gl-mx-4"
+ data-testid="main-menu-separator"
+ />
+ <ul class="gl-p-0 gl-list-style-none">
+ <template v-for="item in nonStaticItems">
+ <menu-section
+ v-if="isSection(item)"
+ :key="item.id"
+ :item="item"
+ :separated="item.separated"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
+ <nav-item
+ v-else
+ :key="item.id"
+ :item="item"
+ tag="li"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
+ </template>
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
new file mode 100644
index 00000000000..9d2836e9dfa
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -0,0 +1,122 @@
+<script>
+import { getCssClassDimensions } from '~/lib/utils/css_utils';
+import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants';
+
+export const STATE_CLOSED = 'closed';
+export const STATE_WILL_OPEN = 'will-open';
+export const STATE_OPEN = 'open';
+export const STATE_WILL_CLOSE = 'will-close';
+
+export default {
+ name: 'SidebarPeek',
+ created() {
+ // Nothing needs to observe these properties, so they are not reactive.
+ this.state = null;
+ this.openTimer = null;
+ this.closeTimer = null;
+ this.xNearWindowEdge = null;
+ this.xSidebarEdge = null;
+ this.xAwayFromSidebar = null;
+ },
+ mounted() {
+ this.xNearWindowEdge = getCssClassDimensions('gl-w-3').width;
+ this.xSidebarEdge = getCssClassDimensions('super-sidebar').width;
+ this.xAwayFromSidebar = 2 * this.xSidebarEdge;
+ document.addEventListener('mousemove', this.onMouseMove);
+ document.documentElement.addEventListener('mouseleave', this.onDocumentLeave);
+ this.changeState(STATE_CLOSED);
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousemove', this.onMouseMove);
+ document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave);
+ this.clearTimers();
+ },
+ methods: {
+ /**
+ * Callback for document-wide mousemove events.
+ *
+ * Since mousemove events can fire frequently, it's important for this to
+ * do as little work as possible.
+ *
+ * When mousemove events fire within one of the defined regions, this ends
+ * up being a no-op. Only when the cursor moves from one region to another
+ * does this do any work: it sets a non-reactive instance property, maybe
+ * cancels/starts timers, and emits an event.
+ *
+ * @params {MouseEvent} event
+ */
+ onMouseMove({ clientX }) {
+ if (this.state === STATE_CLOSED) {
+ if (clientX < this.xNearWindowEdge) {
+ this.willOpen();
+ }
+ } else if (this.state === STATE_WILL_OPEN) {
+ if (clientX >= this.xNearWindowEdge) {
+ this.close();
+ }
+ } else if (this.state === STATE_OPEN) {
+ if (clientX >= this.xAwayFromSidebar) {
+ this.close();
+ } else if (clientX >= this.xSidebarEdge) {
+ this.willClose();
+ }
+ } else if (this.state === STATE_WILL_CLOSE) {
+ if (clientX >= this.xAwayFromSidebar) {
+ this.close();
+ } else if (clientX < this.xSidebarEdge) {
+ this.open();
+ }
+ }
+ },
+ onDocumentLeave() {
+ if (this.state === STATE_OPEN) {
+ this.willClose();
+ } else if (this.state === STATE_WILL_OPEN) {
+ this.close();
+ }
+ },
+ willClose() {
+ if (this.changeState(STATE_WILL_CLOSE)) {
+ this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ }
+ },
+ willOpen() {
+ if (this.changeState(STATE_WILL_OPEN)) {
+ this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ }
+ },
+ open() {
+ if (this.changeState(STATE_OPEN)) {
+ this.clearTimers();
+ }
+ },
+ close() {
+ if (this.changeState(STATE_CLOSED)) {
+ this.clearTimers();
+ }
+ },
+ clearTimers() {
+ clearTimeout(this.closeTimer);
+ clearTimeout(this.openTimer);
+ },
+ /**
+ * Switches to the new state, and emits a change event.
+ *
+ * If the given state is the current state, do nothing.
+ *
+ * @param {string} state The state to transition to.
+ * @returns {boolean} True if the state changed, false otherwise.
+ */
+ changeState(state) {
+ if (this.state === state) return false;
+
+ this.state = state;
+ this.$emit('change', state);
+ return true;
+ },
+ },
+ render() {
+ return null;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue
new file mode 100644
index 00000000000..2a805c86a3b
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue
@@ -0,0 +1,30 @@
+<script>
+import { MountingPortal } from 'portal-vue';
+import { SIDEBAR_PORTAL_ID, portalState } from '../constants';
+
+/**
+ * Use this component to render content into the sidebar.
+ *
+ * Arbitrary content is allowed, but nav items should be added using a Ruby
+ * Sidebars::Panel subclass instead.
+ *
+ * Only one instance of this component on a given page is supported. This is to
+ * avoid ordering issues and cluttering the sidebar.
+ */
+export default {
+ components: {
+ MountingPortal,
+ },
+ data() {
+ // This is shared state, by design. Do not mutate this state here.
+ return portalState;
+ },
+ mountSelector: `#${SIDEBAR_PORTAL_ID}`,
+};
+</script>
+
+<template>
+ <mounting-portal v-if="ready" :mount-to="$options.mountSelector" append>
+ <slot></slot>
+ </mounting-portal>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue
new file mode 100644
index 00000000000..1154a4357e0
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue
@@ -0,0 +1,17 @@
+<script>
+import { SIDEBAR_PORTAL_ID, portalState } from '../constants';
+
+export default {
+ mounted() {
+ portalState.ready = true;
+ },
+ beforeDestroy() {
+ portalState.ready = false;
+ },
+ mountId: SIDEBAR_PORTAL_ID,
+};
+</script>
+
+<template>
+ <div v-once :id="$options.mountId"></div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index c4b769dcf24..6b1efc4217c 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,20 +1,35 @@
<script>
-import { GlCollapse } from '@gitlab/ui';
-import { context } from '../mock_data';
+import { GlButton } from '@gitlab/ui';
+import { Mousetrap } from '~/lib/mousetrap';
+import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings';
+import { __ } from '~/locale';
+import { sidebarState } from '../constants';
+import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
-import ContextSwitcherToggle from './context_switcher_toggle.vue';
+import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcher from './context_switcher.vue';
import HelpCenter from './help_center.vue';
+import SidebarMenu from './sidebar_menu.vue';
+import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue';
export default {
- context,
components: {
- GlCollapse,
+ GlButton,
UserBar,
- ContextSwitcherToggle,
ContextSwitcher,
HelpCenter,
+ SidebarMenu,
+ SidebarPeekBehavior,
+ SidebarPortalTarget,
+ TrialStatusWidget: () =>
+ import('ee_component/contextual_sidebar/components/trial_status_widget.vue'),
+ TrialStatusPopover: () =>
+ import('ee_component/contextual_sidebar/components/trial_status_popover.vue'),
},
+ i18n: {
+ skipToMainContent: __('Skip to main content'),
+ },
+ inject: ['showTrialStatusWidget'],
props: {
sidebarData: {
type: Object,
@@ -23,29 +38,132 @@ export default {
},
data() {
return {
- contextSwitcherOpened: false,
+ sidebarState,
+ showPeekHint: false,
};
},
+ computed: {
+ menuItems() {
+ return this.sidebarData.current_menu_items || [];
+ },
+ peekClasses() {
+ return {
+ 'super-sidebar-peek-hint': this.showPeekHint,
+ 'super-sidebar-peek': this.sidebarState.isPeek,
+ };
+ },
+ },
+ watch: {
+ 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) {
+ if (newIsCollapsed) {
+ this.$refs['context-switcher'].close();
+ }
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(TOGGLE_SUPER_SIDEBAR));
+ },
+ methods: {
+ toggleSidebar() {
+ toggleSuperSidebarCollapsed(!isCollapsed(), true);
+ },
+ collapseSidebar() {
+ toggleSuperSidebarCollapsed(true, false);
+ },
+ onPeekChange(state) {
+ if (state === STATE_CLOSED) {
+ this.sidebarState.isPeek = false;
+ this.sidebarState.isCollapsed = true;
+ this.showPeekHint = false;
+ } else if (state === STATE_WILL_OPEN) {
+ this.sidebarState.isPeek = false;
+ this.sidebarState.isCollapsed = true;
+ this.showPeekHint = true;
+ } else {
+ this.sidebarState.isPeek = true;
+ this.sidebarState.isCollapsed = false;
+ this.showPeekHint = false;
+ }
+ },
+ onContextSwitcherToggled(open) {
+ this.sidebarState.contextSwitcherOpen = open;
+ },
+ },
};
</script>
<template>
- <aside
- id="super-sidebar"
- class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08"
- data-testid="super-sidebar"
- >
- <user-bar :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="$options.context" :expanded="contextSwitcherOpened" />
- <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
- <context-switcher />
- </gl-collapse>
+ <div>
+ <div class="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <gl-button
+ class="super-sidebar-skip-to gl-sr-only-focusable gl-fixed gl-left-0 gl-m-3"
+ href="#content-body"
+ variant="confirm"
+ >
+ {{ $options.i18n.skipToMainContent }}
+ </gl-button>
+ <aside
+ id="super-sidebar"
+ class="super-sidebar"
+ :class="peekClasses"
+ data-testid="super-sidebar"
+ data-qa-selector="navbar"
+ :inert="sidebarState.isCollapsed"
+ >
+ <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" />
+ <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2">
+ <trial-status-widget
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 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! nav-item-link gl-py-3"
+ />
+ <trial-status-popover />
</div>
- <div class="gl-p-3">
- <help-center :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"
+ :class="{ 'gl-overflow-auto': !sidebarState.contextSwitcherOpen }"
+ data-testid="nav-container"
+ >
+ <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"
+ :context-header="sidebarData.current_context_header"
+ @toggle="onContextSwitcherToggled"
+ />
+ <sidebar-menu
+ v-if="menuItems.length"
+ :items="menuItems"
+ :panel-type="sidebarData.panel_type"
+ :pinned-item-ids="sidebarData.pinned_items"
+ :update-pins-url="sidebarData.update_pins_url"
+ />
+ <sidebar-portal-target />
+ </div>
+ <div class="gl-p-3">
+ <help-center :sidebar-data="sidebarData" />
+ </div>
</div>
- </div>
- </aside>
+ </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>
+
+ <!--
+ Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid
+ setting up event listeners unnecessarily.
+ -->
+ <sidebar-peek-behavior v-if="sidebarState.isPeekable" @change="onPeekChange" />
+ </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..4fff5cf832e
--- /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: __('Hide sidebar'),
+ expandSidebar: __('Show 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 ee72e8eafb4..768914584e8 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,90 +1,215 @@
<script>
-import { GlAvatar, GlDropdown, GlIcon, 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 NewNavToggle from '~/nav/components/new_nav_toggle.vue';
+import {
+ destroyUserCountsManager,
+ createUserCountsManager,
+ userCounts,
+} from '~/super_sidebar/user_counts_manager';
import logo from '../../../../views/shared/_logo.svg';
+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: {
- GlAvatar,
- GlDropdown,
- GlIcon,
- CreateMenu,
- NewNavToggle,
Counter,
+ CreateMenu,
+ GlBadge,
+ GlButton,
MergeRequestMenu,
+ UserMenu,
+ SearchModal: () =>
+ import(
+ /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
+ ),
+ SuperSidebarToggle,
},
i18n: {
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', 'toggleNewNavEndpoint'],
+ inject: ['rootPath', 'isImpersonating'],
props: {
+ hasCollapseButton: {
+ default: true,
+ type: Boolean,
+ required: false,
+ },
sidebarData: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ mrMenuShown: false,
+ searchTooltip: this.$options.i18n.searchKbdHelp,
+ userCounts,
+ };
+ },
+ computed: {
+ mergeRequestTotalCount() {
+ return userCounts.assigned_merge_requests + userCounts.review_requested_merge_requests;
+ },
+ },
+ created() {
+ Object.assign(userCounts, this.sidebarData.user_counts);
+ createUserCountsManager();
+ },
+ mounted() {
+ document.addEventListener('todo:toggle', this.updateTodos);
+ },
+ beforeDestroy() {
+ document.removeEventListener('todo:toggle', this.updateTodos);
+ destroyUserCountsManager();
+ },
+ methods: {
+ updateTodos(e) {
+ userCounts.todos = e.detail.count || 0;
+ },
+ hideSearchTooltip() {
+ this.searchTooltip = '';
+ },
+ showSearchTooltip() {
+ this.searchTooltip = this.$options.i18n.searchKbdHelp;
+ },
+ },
};
</script>
<template>
<div class="user-bar">
- <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3">
- <div class="gl-flex-grow-1">
- <a v-safe-html="$options.logo" :href="rootPath"></a>
- </div>
+ <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"
+ class="tanuki-logo-container"
+ :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"
+ :src="sidebarData.logo_url"
+ class="gl-h-6"
+ />
+ <span v-else v-safe-html="$options.logo"></span>
+ </a>
+ <gl-badge
+ v-if="sidebarData.gitlab_com_and_canary"
+ variant="success"
+ :href="sidebarData.canary_toggle_com_url"
+ size="sm"
+ class="gl-ml-2"
+ >
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
+ <div class="gl-flex-grow-1"></div>
+ <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" />
- <button class="gl-border-none">
- <gl-icon name="search" class="gl-vertical-align-middle" />
- </button>
- <gl-dropdown data-testid="user-dropdown" variant="link" no-caret>
- <template #button-content>
- <gl-avatar :entity-name="sidebarData.name" :src="sidebarData.avatar_url" :size="32" />
- </template>
- <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled />
- </gl-dropdown>
+
+ <gl-button
+ id="super-sidebar-search"
+ v-gl-tooltip.bottom.hover.html="searchTooltip"
+ v-gl-modal="$options.SEARCH_MODAL_ID"
+ data-testid="super-sidebar-search-button"
+ data-qa-selector="global_search_button"
+ icon="search"
+ :aria-label="$options.i18n.search"
+ category="tertiary"
+ />
+ <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
+
+ <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"
+ :count="userCounts.assigned_issues"
: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"
- tabindex="-1"
icon="merge-request-open"
- :count="sidebarData.total_merge_requests_count"
+ :count="mergeRequestTotalCount"
: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"
- href="/dashboard/todos"
+ :count="userCounts.todos"
+ :href="sidebarData.todos_dashboard_path"
: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
new file mode 100644
index 00000000000..cd5a83c86cc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -0,0 +1,340 @@
+<script>
+import {
+ GlAvatar,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlButton,
+} from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+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/409005',
+ i18n: {
+ newNavigation: {
+ sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
+ },
+ setStatus: s__('SetStatusModal|Set status'),
+ editStatus: s__('SetStatusModal|Edit status'),
+ editProfile: s__('CurrentUser|Edit profile'),
+ preferences: s__('CurrentUser|Preferences'),
+ buyPipelineMinutes: s__('CurrentUser|Buy Pipeline minutes'),
+ oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'),
+ gitlabNext: s__('CurrentUser|Switch to GitLab Next'),
+ provideFeedback: s__('NorthstarNavigation|Provide feedback'),
+ startTrial: s__('CurrentUser|Start an Ultimate trial'),
+ signOut: __('Sign out'),
+ },
+ components: {
+ GlAvatar,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlButton,
+ NewNavToggle,
+ UserNameGroup,
+ },
+ directives: {
+ SafeHtml,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['toggleNewNavEndpoint'],
+ props: {
+ data: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ toggleText() {
+ return sprintf(__('%{user} user’s menu'), { user: this.data.name });
+ },
+ statusItem() {
+ const { busy, customized } = this.data.status;
+
+ const statusLabel =
+ busy || customized ? this.$options.i18n.editStatus : this.$options.i18n.setStatus;
+
+ return {
+ text: statusLabel,
+ extraAttrs: {
+ class: 'js-set-status-modal-trigger',
+ },
+ };
+ },
+ trialItem() {
+ return {
+ text: this.$options.i18n.startTrial,
+ href: this.data.trial.url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'start_trial',
+ },
+ };
+ },
+ showTrialItem() {
+ return this.data.trial?.has_start_trial;
+ },
+ editProfileItem() {
+ return {
+ text: this.$options.i18n.editProfile,
+ href: this.data.settings.profile_path,
+ extraAttrs: {
+ 'data-qa-selector': 'edit_profile_link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_edit_profile',
+ },
+ };
+ },
+ preferencesItem() {
+ return {
+ text: this.$options.i18n.preferences,
+ href: this.data.settings.profile_preferences_path,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_preferences',
+ },
+ };
+ },
+ addBuyPipelineMinutesMenuItem() {
+ return this.data.pipeline_minutes?.show_buy_pipeline_minutes;
+ },
+ buyPipelineMinutesItem() {
+ return {
+ text: this.$options.i18n.buyPipelineMinutes,
+ warningText: this.$options.i18n.oneOfGroupsRunningOutOfPipelineMinutes,
+ href: this.data.pipeline_minutes?.buy_pipeline_minutes_path,
+ extraAttrs: {
+ class: 'js-follow-link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'buy_pipeline_minutes',
+ },
+ };
+ },
+ gitlabNextItem() {
+ 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() {
+ return {
+ text: this.$options.i18n.provideFeedback,
+ href: this.$options.feedbackUrl,
+ extraAttrs: {
+ target: '_blank',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'provide_nav_feedback',
+ },
+ };
+ },
+ signOutGroup() {
+ return {
+ items: [
+ {
+ text: this.$options.i18n.signOut,
+ href: this.data.sign_out_link,
+ extraAttrs: {
+ 'data-method': 'post',
+ 'data-qa-selector': 'sign_out_link',
+ class: 'sign-out-link',
+ },
+ },
+ ],
+ };
+ },
+ statusModalData() {
+ const defaultData = {
+ 'data-current-emoji': '',
+ 'data-current-message': '',
+ 'data-default-emoji': 'speech_balloon',
+ };
+
+ const { busy, customized } = this.data.status;
+
+ if (!busy && !customized) {
+ return defaultData;
+ }
+
+ return {
+ ...defaultData,
+ 'data-current-emoji': this.data.status.emoji,
+ 'data-current-message': this.data.status.message,
+ 'data-current-availability': this.data.status.availability,
+ 'data-current-clear-status-after': this.data.status.clear_after,
+ };
+ },
+ buyPipelineMinutesCalloutData() {
+ return this.showNotificationDot
+ ? {
+ 'data-feature-id': this.data.pipeline_minutes.callout_attrs.feature_id,
+ 'data-dismiss-endpoint': this.data.pipeline_minutes.callout_attrs.dismiss_endpoint,
+ }
+ : {};
+ },
+ showNotificationDot() {
+ return this.data.pipeline_minutes?.show_notification_dot;
+ },
+ },
+ methods: {
+ onShow() {
+ this.initBuyCIMinsCallout();
+ },
+ closeDropdown() {
+ this.$refs.userDropdown.close();
+ },
+ initBuyCIMinsCallout() {
+ if (this.showNotificationDot) {
+ PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el);
+ }
+ },
+ /* 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,
+ 'track-label': label,
+ 'track-property': property,
+ } = this.data.pipeline_minutes.tracking_attrs;
+ 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>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ ref="userDropdown"
+ :popper-options="$options.popperOptions"
+ data-testid="user-dropdown"
+ data-qa-selector="user_menu"
+ @shown="onShow"
+ >
+ <template #toggle>
+ <gl-button category="tertiary" class="user-bar-item btn-with-notification">
+ <span class="gl-sr-only">{{ toggleText }}</span>
+ <gl-avatar
+ :size="24"
+ :entity-name="data.name"
+ :src="data.avatar_url"
+ aria-hidden="true"
+ data-qa-selector="user_avatar_content"
+ />
+ <span
+ v-if="showNotificationDot"
+ class="notification-dot-warning"
+ data-testid="buy-pipeline-minutes-notification-dot"
+ v-bind="data.pipeline_minutes.notification_dot_attrs"
+ >
+ </span>
+ </gl-button>
+ </template>
+
+ <user-name-group :user="data" />
+ <gl-disclosure-dropdown-group bordered>
+ <gl-disclosure-dropdown-item
+ v-if="data.status.can_update"
+ :item="statusItem"
+ data-testid="status-item"
+ @action="closeDropdown"
+ />
+
+ <gl-disclosure-dropdown-item
+ v-if="showTrialItem"
+ :item="trialItem"
+ data-testid="start-trial-item"
+ >
+ <template #list-item>
+ {{ trialItem.text }}
+ <gl-emoji data-name="rocket" />
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item :item="editProfileItem" data-testid="edit-profile-item" />
+
+ <gl-disclosure-dropdown-item :item="preferencesItem" data-testid="preferences-item" />
+
+ <gl-disclosure-dropdown-item
+ v-if="addBuyPipelineMinutesMenuItem"
+ ref="buyPipelineMinutesNotificationCallout"
+ :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">
+ <span>{{ buyPipelineMinutesItem.text }} <gl-emoji data-name="clock9" /></span>
+ <span
+ v-if="data.pipeline_minutes.show_with_subtext"
+ class="gl-font-sm small gl-pt-2 gl-text-orange-800"
+ >{{ buyPipelineMinutesItem.warningText }}</span
+ >
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item
+ v-if="data.gitlab_com_but_not_canary"
+ :item="gitlabNextItem"
+ data-testid="gitlab-next-item"
+ />
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group bordered>
+ <template #group-label>
+ <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span>
+ </template>
+ <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation />
+ <gl-disclosure-dropdown-item :item="feedbackItem" data-testid="feedback-item" />
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group
+ v-if="data.can_sign_out"
+ bordered
+ :group="signOutGroup"
+ data-testid="sign-out-group"
+ @action="trackSignOut"
+ />
+ </gl-disclosure-dropdown>
+
+ <div
+ v-if="data.status.can_update"
+ class="js-set-status-modal-wrapper"
+ v-bind="statusModalData"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
new file mode 100644
index 00000000000..dfaaaccf4a4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -0,0 +1,90 @@
+<script>
+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'),
+ },
+ },
+ components: {
+ GlBadge,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlTooltip,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ user: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ menuItem() {
+ const item = {
+ text: this.user.name,
+ };
+ 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',
+ 'data-qa-selector': 'user_profile_link',
+ };
+ }
+
+ return item;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-item :item="menuItem">
+ <template #list-item>
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span>
+ <span class="gl-font-weight-bold">
+ {{ user.name }}
+ </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>
+
+ <span
+ v-if="user.status.customized"
+ ref="statusTooltipTarget"
+ data-testid="user-menu-status"
+ class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm"
+ >
+ <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" />
+ <span v-safe-html="user.status.message" class="gl-text-truncate"></span>
+ <gl-tooltip
+ :target="() => $refs.statusTooltipTarget"
+ boundary="viewport"
+ placement="bottom"
+ >
+ <span v-safe-html="user.status.message"></span>
+ </gl-tooltip>
+ </span>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
new file mode 100644
index 00000000000..00ceaebe2cc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -0,0 +1,54 @@
+// Note: all constants defined here are considered internal implementation
+// details for the sidebar. They should not be imported by anything outside of
+// the super_sidebar directory.
+
+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,
+ isPeekable: false,
+});
+
+export const helpCenterState = Vue.observable({
+ showTanukiBotChatDrawer: false,
+});
+
+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 CLICK_PINNED_MENU_ITEM_ACTION = 'click_pinned_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;
+
+export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08';
diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql
new file mode 100644
index 00000000000..4b1e65be3fa
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql
@@ -0,0 +1,24 @@
+query searchUserProjectsAndGroups($username: String!, $search: String) {
+ projects(search: $search, sort: "latest_activity_desc", membership: true, first: 20) {
+ nodes {
+ id
+ name
+ namespace: nameWithNamespace
+ webUrl
+ avatarUrl
+ }
+ }
+
+ user(username: $username) {
+ id
+ groups(search: $search, first: 20) {
+ nodes {
+ id
+ name
+ namespace: fullPath
+ webUrl
+ avatarUrl
+ }
+ }
+ }
+}
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 0d1ac006df7..00000000000
--- a/app/assets/javascripts/super_sidebar/mock_data.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { s__ } from '~/locale';
-
-export const context = {
- title: 'Typeahead.js',
- link: '/',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32',
-};
-
-export const contextSwitcherItems = {
- yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
- 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/popper_max_size_modifier.js b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
new file mode 100644
index 00000000000..6581d521107
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
@@ -0,0 +1,43 @@
+import { detectOverflow } from '@popperjs/core';
+
+/**
+ * These modifiers were copied from the community modifier popper-max-size-modifier
+ * https://www.npmjs.com/package/popper-max-size-modifier.
+ * We are considering upgrading Popper.js to Floating UI, at which point the behavior this
+ * introduces will be available out of the box.
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213
+ */
+
+export const maxSize = {
+ name: 'maxSize',
+ enabled: true,
+ phase: 'main',
+ requiresIfExists: ['offset', 'preventOverflow', 'flip'],
+ fn({ state, name }) {
+ const overflow = detectOverflow(state);
+ const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 };
+ const { width, height } = state.rects.popper;
+ const [basePlacement] = state.placement.split('-');
+
+ const widthProp = basePlacement === 'left' ? 'left' : 'right';
+ const heightProp = basePlacement === 'top' ? 'top' : 'bottom';
+
+ state.modifiersData[name] = {
+ width: width - overflow[widthProp] - x,
+ height: height - overflow[heightProp] - y,
+ };
+ },
+};
+
+export const applyMaxSize = {
+ name: 'applyMaxSize',
+ enabled: true,
+ phase: 'write',
+ requires: ['maxSize'],
+ fn({ state }) {
+ // The `maxSize` modifier provides this data
+ const { width, height } = state.modifiersData.maxSize;
+ state.elements.popper.style.maxWidth = `${width}px`;
+ state.elements.popper.style.maxHeight = `${height}px`;
+ },
+};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index b9c7073df8c..63424277ffc 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,26 +1,131 @@
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);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+const getTrialStatusWidgetData = (sidebarData) => {
+ if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) {
+ const {
+ containerId,
+ trialDaysUsed,
+ trialDuration,
+ navIconImagePath,
+ percentageComplete,
+ planName,
+ plansHref,
+ } = convertObjectPropsToCamelCase(sidebarData.trial_status_widget_data_attrs);
+
+ const {
+ daysRemaining,
+ targetId,
+ trialEndDate,
+ namespaceId,
+ userName,
+ firstName,
+ lastName,
+ companyName,
+ glmContent,
+ } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs);
+
+ return {
+ showTrialStatusWidget: true,
+ containerId,
+ trialDaysUsed: Number(trialDaysUsed),
+ trialDuration: Number(trialDuration),
+ navIconImagePath,
+ percentageComplete: Number(percentageComplete),
+ planName,
+ plansHref,
+ daysRemaining,
+ targetId,
+ trialEndDate: new Date(trialEndDate),
+ user: { namespaceId, userName, firstName, lastName, companyName, glmContent },
+ };
+ }
+ return { showTrialStatusWidget: false };
+};
export const initSuperSidebar = () => {
const el = document.querySelector('.js-super-sidebar');
if (!el) return false;
- const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
+ const { rootPath, sidebar, toggleNewNavEndpoint, forceDesktopExpandedSidebar } = el.dataset;
+
+ bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar);
+ initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar));
+
+ 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,
name: 'SuperSidebarRoot',
+ apolloProvider,
provide: {
rootPath,
toggleNewNavEndpoint,
+ isImpersonating,
+ ...getTrialStatusWidgetData(sidebarData),
},
+ 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
new file mode 100644
index 00000000000..1a359533435
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -0,0 +1,61 @@
+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 isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS);
+
+// See documentation: https://design.gitlab.com/patterns/navigation#left-sidebar
+// NOTE: at 1200px nav sidebar should not overlap the content
+// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110
+export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
+
+export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true';
+
+export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
+ findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
+
+ sidebarState.isPeek = false;
+ sidebarState.isPeekable = Boolean(gon.features?.superSidebarPeek) && collapsed;
+ sidebarState.isCollapsed = collapsed;
+
+ if (saveCookie && isDesktopBreakpoint()) {
+ setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
+ expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
+ });
+ }
+};
+
+export const initSuperSidebarCollapsedState = (forceDesktopExpandedSidebar = false) => {
+ let collapsed = true;
+ if (isDesktopBreakpoint()) {
+ collapsed = forceDesktopExpandedSidebar ? false : getCollapsedCookie();
+ }
+ toggleSuperSidebarCollapsed(collapsed, false);
+};
+
+export const bindSuperSidebarCollapsedEvents = (forceDesktopExpandedSidebar = false) => {
+ let previousWindowWidth = window.innerWidth;
+
+ const callback = debounce(() => {
+ const newWindowWidth = window.innerWidth;
+ const widthChanged = previousWindowWidth !== newWindowWidth;
+
+ if (widthChanged) {
+ initSuperSidebarCollapsedState(forceDesktopExpandedSidebar);
+ }
+ previousWindowWidth = newWindowWidth;
+ }, 100);
+
+ window.addEventListener('resize', callback);
+
+ return () => window.removeEventListener('resize', callback);
+};
diff --git a/app/assets/javascripts/super_sidebar/user_counts_manager.js b/app/assets/javascripts/super_sidebar/user_counts_manager.js
new file mode 100644
index 00000000000..40c9fc43252
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/user_counts_manager.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import { getUserCounts } from '~/api/user_api';
+
+export const userCounts = Vue.observable({
+ last_update: 0,
+ // The following fields are part of
+ // https://docs.gitlab.com/ee/api/users.html#user-counts
+ todos: 0,
+ assigned_issues: 0,
+ assigned_merge_requests: 0,
+ review_requested_merge_requests: 0,
+});
+
+function updateCounts(payload = {}) {
+ if ((payload.last_update ?? 0) < userCounts.last_update) {
+ return;
+ }
+ for (const key in userCounts) {
+ if (Number.isInteger(payload[key])) {
+ userCounts[key] = payload[key];
+ }
+ }
+}
+
+let broadcastChannel = null;
+
+function broadcastUserCounts(data) {
+ broadcastChannel?.postMessage(data);
+}
+
+async function retrieveUserCountsFromApi() {
+ try {
+ const lastUpdate = Date.now();
+ const { data } = await getUserCounts();
+ const payload = { ...data, last_update: lastUpdate };
+ updateCounts(payload);
+ broadcastUserCounts(userCounts);
+ } catch (e) {
+ // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
+ console.error('Error retrieving user counts', e);
+ }
+}
+
+export function destroyUserCountsManager() {
+ document.removeEventListener('userCounts:fetch', retrieveUserCountsFromApi);
+ broadcastChannel?.close();
+ broadcastChannel = null;
+}
+
+/**
+ * The createUserCountsManager does three things:
+ * 1. Set the initial state of userCounts
+ * 2. Create a broadcast channel to communicate user count updates across tabs
+ * 3. Add event listeners for other parts in the app which:
+ * - Update todos
+ * - Trigger a refetch of all counts
+ */
+export function createUserCountsManager() {
+ destroyUserCountsManager();
+ document.addEventListener('userCounts:fetch', retrieveUserCountsFromApi);
+
+ if (window.BroadcastChannel && gon?.current_user_id) {
+ broadcastChannel = new BroadcastChannel(`user_counts_${gon?.current_user_id}`);
+ broadcastChannel.onmessage = (ev) => {
+ updateCounts(ev.data);
+ };
+ broadcastUserCounts(userCounts);
+ }
+}
diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js
new file mode 100644
index 00000000000..3b17a35c5bc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/utils.js
@@ -0,0 +1,87 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+
+/**
+ * This takes an array of project or groups that were stored in the local storage, to be shown in
+ * the context switcher, and sorts them by frequency and last access date.
+ * In the resulting array, the most popular item (highest frequency and most recent access date) is
+ * placed at the first index, while the least popular is at the last index.
+ *
+ * @param {Array} items The projects or groups stored in the local storage
+ * @returns The items, sorted by frequency and last access date
+ */
+const sortItemsByFrequencyAndLastAccess = (items) =>
+ items.sort((itemA, itemB) => {
+ // Sort all frequent items in decending order of frequency
+ // and then by lastAccessedOn with recent most first
+ if (itemA.frequency !== itemB.frequency) {
+ return itemB.frequency - itemA.frequency;
+ } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) {
+ return itemB.lastAccessedOn - itemA.lastAccessedOn;
+ }
+
+ return 0;
+ });
+
+// This imitates getTopFrequentItems from app/assets/javascripts/frequent_items/utils.js, but
+// adjusts the rules to accommodate for the context switcher's designs.
+export const getTopFrequentItems = (items, maxCount) => {
+ if (!Array.isArray(items)) return [];
+
+ const frequentItems = items.filter((item) => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY);
+ sortItemsByFrequencyAndLastAccess(frequentItems);
+
+ return frequentItems.slice(0, maxCount);
+};
+
+const updateItemAccess = (item) => {
+ const now = Date.now();
+ const neverAccessed = !item.lastAccessedOn;
+ const shouldUpdate =
+ neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
+ const currentFrequency = item.frequency ?? 0;
+
+ return {
+ ...item,
+ frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency,
+ lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn,
+ };
+};
+
+export const trackContextAccess = (username, context) => {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return false;
+ }
+
+ const storageKey = `${username}/frequent-${context.namespace}`;
+ const storedRawItems = localStorage.getItem(storageKey);
+ const storedItems = storedRawItems ? JSON.parse(storedRawItems) : [];
+ const existingItemIndex = storedItems.findIndex(
+ (cachedItem) => cachedItem.id === context.item.id,
+ );
+
+ if (existingItemIndex > -1) {
+ storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]);
+ } else {
+ const newItem = updateItemAccess(context.item);
+ if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) {
+ sortItemsByFrequencyAndLastAccess(storedItems);
+ storedItems.pop();
+ }
+ storedItems.push(newItem);
+ }
+
+ return localStorage.setItem(storageKey, JSON.stringify(storedItems));
+};
+
+export const formatContextSwitcherItems = (items) =>
+ items.map(({ id, name: title, namespace, avatarUrl: avatar, webUrl: link }) => ({
+ id,
+ title,
+ subtitle: truncateNamespace(namespace),
+ avatar,
+ link,
+ }));
+
+export const ariaCurrent = (isActive) => (isActive ? 'page' : null);
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index cb2bf24abc7..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,7 +12,12 @@ 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;
+ }
+
if (el.classList.contains('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return el.classList.add(gon.user_color_scheme);
diff --git a/app/assets/javascripts/tags/init_new_tag_ref_selector.js b/app/assets/javascripts/tags/init_new_tag_ref_selector.js
index 11c7516f16c..7667df00516 100644
--- a/app/assets/javascripts/tags/init_new_tag_ref_selector.js
+++ b/app/assets/javascripts/tags/init_new_tag_ref_selector.js
@@ -14,6 +14,7 @@ export default function initNewTagRefSelector() {
props: {
value: defaultBranchName,
name: hiddenInputName,
+ queryParams: { sort: 'updated_desc' },
projectId,
},
});
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index a7760ad5d0b..bb344ade344 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import 'deckar01-task_list';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
export default class TaskList {
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
index 6dae55bac50..551b5498571 100644
--- a/app/assets/javascripts/terraform/components/empty_state.vue
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -1,18 +1,27 @@
<script>
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+import InitCommandModal from './init_command_modal.vue';
export default {
+ COMMAND_MODAL_ID: 'init-command-modal',
i18n: {
title: s__("Terraform|Your project doesn't have any Terraform state files"),
- description: s__('Terraform|How to use GitLab-managed Terraform state?'),
+ buttonDoc: s__('Terraform|Explore documentation'),
+ buttonCopy: s__('Terraform|Copy Terraform init command'),
},
docsUrl: helpPagePath('user/infrastructure/iac/terraform_state'),
components: {
GlEmptyState,
- GlLink,
+ GlButton,
+ InitCommandModal,
},
+
+ directives: {
+ GlModalDirective,
+ },
+
props: {
image: {
type: String,
@@ -24,8 +33,18 @@ export default {
<template>
<gl-empty-state :svg-path="image" :title="$options.i18n.title">
- <template #description>
- <gl-link :href="$options.docsUrl">{{ $options.i18n.description }}</gl-link>
+ <template #actions>
+ <gl-button variant="confirm" :href="$options.docsUrl">
+ {{ $options.i18n.buttonDoc }}</gl-button
+ >
+ <gl-button
+ v-gl-modal-directive="$options.COMMAND_MODAL_ID"
+ data-testid="terraform-state-copy-init-command"
+ icon="copy-to-clipboard"
+ >{{ $options.i18n.buttonCopy }}</gl-button
+ >
+
+ <init-command-modal :modal-id="$options.COMMAND_MODAL_ID" />
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue
index 0d8a883972f..a63c2025e8b 100644
--- a/app/assets/javascripts/terraform/components/init_command_modal.vue
+++ b/app/assets/javascripts/terraform/components/init_command_modal.vue
@@ -26,20 +26,23 @@ export default {
},
stateName: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
computed: {
closeModalProps() {
return {
text: this.$options.i18n.closeText,
- attributes: [],
+ attributes: {},
};
},
},
methods: {
getModalInfoCopyStr() {
- const stateNameEncoded = encodeURIComponent(this.stateName);
+ const stateNameEncoded = this.stateName
+ ? encodeURIComponent(this.stateName)
+ : '<YOUR-STATE-NAME>';
return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
terraform init \\
diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue
index 773ecf1d5d5..586b3e96e44 100644
--- a/app/assets/javascripts/terraform/components/states_table_actions.vue
+++ b/app/assets/javascripts/terraform/components/states_table_actions.vue
@@ -69,7 +69,7 @@ export default {
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
@@ -81,7 +81,7 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.modalRemove,
- attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }],
+ attributes: { disabled: this.disableModalSubmit, variant: 'danger' },
};
},
commandModalId() {
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
index f098b447d10..af8cc732753 100644
--- a/app/assets/javascripts/terraform/components/terraform_list.vue
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -97,7 +97,7 @@ export default {
<gl-tab>
<template #title>
<p class="gl-m-0">
- {{ s__('Terraform|States') }}
+ {{ s__('Terraform|Terraform states') }}
<gl-badge v-if="statesCount">{{ statesCount }}</gl-badge>
</p>
</template>
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..950d2f2f05d
--- /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/toggles/index.js b/app/assets/javascripts/toggles/index.js
index 5848b3a424c..500fe8c1150 100644
--- a/app/assets/javascripts/toggles/index.js
+++ b/app/assets/javascripts/toggles/index.js
@@ -17,20 +17,12 @@ export const initToggle = (el) => {
return new Vue({
el,
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: parseBoolean(disabled),
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: parseBoolean(isLoading),
- },
- },
+ name: 'ToggleFromHtml',
+
data() {
return {
+ disabled: parseBoolean(disabled),
+ isLoading: parseBoolean(isLoading),
value: parseBoolean(isChecked),
};
},
diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue
index feaf9072ee2..eb1222d5130 100644
--- a/app/assets/javascripts/token_access/components/inbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import inboundAddProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql';
@@ -124,7 +124,7 @@ export default {
try {
const {
data: {
- ciCdSettingsUpdate: { errors },
+ projectCiCdSettingsUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: inboundUpdateCIJobTokenScopeMutation,
diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
deleted file mode 100644
index c774f37b1e4..00000000000
--- a/app/assets/javascripts/token_access/components/opt_in_jwt.vue
+++ /dev/null
@@ -1,125 +0,0 @@
-<script>
-import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { createAlert } from '~/flash';
-import { __, s__ } from '~/locale';
-import updateOptInJwtMutation from '../graphql/mutations/update_opt_in_jwt.mutation.graphql';
-import getOptInJwtSettingQuery from '../graphql/queries/get_opt_in_jwt_setting.query.graphql';
-import { LIMIT_JWT_ACCESS_SNIPPET, OPT_IN_JWT_HELP_LINK } from '../constants';
-
-export default {
- i18n: {
- labelText: s__('CICD|Limit JSON Web Token (JWT) access'),
- helpText: s__(
- `CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline. %{linkStart}Learn more.%{linkEnd}`,
- ),
- expandedText: s__(
- 'CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT.',
- ),
- copyToClipboard: __('Copy to clipboard'),
- fetchError: s__('CICD|There was a problem fetching the token access settings.'),
- updateError: s__('CICD|An error occurred while update the setting. Please try again.'),
- },
- components: {
- CodeInstruction,
- GlLink,
- GlLoadingIcon,
- GlSprintf,
- GlToggle,
- },
- inject: ['fullPath'],
- apollo: {
- optInJwt: {
- query: getOptInJwtSettingQuery,
- variables() {
- return {
- fullPath: this.fullPath,
- };
- },
- update({
- project: {
- ciCdSettings: { optInJwt },
- },
- }) {
- return optInJwt;
- },
- error() {
- createAlert({ message: this.$options.i18n.fetchError });
- },
- },
- },
- data() {
- return {
- optInJwt: null,
- };
- },
- computed: {
- isLoading() {
- return this.$apollo.queries.optInJwt.loading;
- },
- },
- methods: {
- async updateOptInJwt() {
- try {
- const {
- data: {
- ciCdSettingsUpdate: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: updateOptInJwtMutation,
- variables: {
- input: {
- fullPath: this.fullPath,
- optInJwt: this.optInJwt,
- },
- },
- });
-
- if (errors.length) {
- throw new Error(errors[0]);
- }
- } catch (error) {
- createAlert({ message: this.$options.i18n.updateError });
- }
- },
- },
- OPT_IN_JWT_HELP_LINK,
- LIMIT_JWT_ACCESS_SNIPPET,
-};
-</script>
-<template>
- <div>
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
- <template v-else>
- <gl-toggle
- v-model="optInJwt"
- class="gl-mt-5"
- :label="$options.i18n.labelText"
- @change="updateOptInJwt"
- >
- <template #help>
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="$options.OPT_IN_JWT_HELP_LINK" class="inline-link" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-toggle>
- <div v-if="optInJwt" class="gl-mt-5" data-testid="opt-in-jwt-expanded-section">
- <gl-sprintf :message="$options.i18n.expandedText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- <code-instruction
- class="gl-mt-3"
- :instruction="$options.LIMIT_JWT_ACCESS_SNIPPET"
- :copy-text="$options.i18n.copyToClipboard"
- multiline
- />
- </div>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue
index 0deae1a1d82..9c9b0d37b68 100644
--- a/app/assets/javascripts/token_access/components/outbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -9,9 +9,10 @@ import {
GlSprintf,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
@@ -19,6 +20,8 @@ import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.q
import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql';
import TokenProjectsTable from './token_projects_table.vue';
+// Note: This component will be removed in 17.0, as the outbound access token is getting deprecated
+// Some warnings are behind the `frozen_outbound_job_token_scopes` feature flag
export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
@@ -34,7 +37,14 @@ export default {
addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
+ outboundTokenAlertDeprecationMessage: s__(
+ `CICD|The %{boldStart}Limit CI_JOB_TOKEN%{boldEnd} scope is deprecated and will be removed the 17.0 milestone. Configure the %{boldStart}CI_JOB_TOKEN%{boldEnd} allowlist instead. %{linkStart}How do I do this?%{linkEnd}`,
+ ),
+ disableToggleWarning: s__('CICD|Disabling this feature is a permanent change.'),
},
+ deprecationDocumentationLink: helpPagePath('ci/jobs/ci_job_token', {
+ anchor: 'limit-your-projects-job-token-access',
+ }),
fields: [
{
key: 'project',
@@ -67,6 +77,7 @@ export default {
GlToggle,
TokenProjectsTable,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -116,13 +127,22 @@ export default {
ciJobTokenHelpPage() {
return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access');
},
+ disableOutboundToken() {
+ return (
+ this.glFeatures?.frozenOutboundJobTokenScopes &&
+ !this.glFeatures?.frozenOutboundJobTokenScopesOverride
+ );
+ },
+ disableTokenToggle() {
+ return !this.jobTokenScopeEnabled && this.disableOutboundToken;
+ },
},
methods: {
async updateCIJobTokenScope() {
try {
const {
data: {
- ciCdSettingsUpdate: { errors },
+ projectCiCdSettingsUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: updateCIJobTokenScopeMutation,
@@ -205,9 +225,33 @@ export default {
<div>
<gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
+ <gl-alert
+ v-if="disableOutboundToken"
+ class="gl-mb-3"
+ variant="warning"
+ :dismissible="false"
+ :show-icon="false"
+ data-testid="deprecation-alert"
+ >
+ <gl-sprintf :message="$options.i18n.outboundTokenAlertDeprecationMessage">
+ <template #bold="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.deprecationDocumentationLink"
+ class="inline-link"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<gl-toggle
v-model="jobTokenScopeEnabled"
:label="$options.i18n.toggleLabelTitle"
+ :disabled="disableTokenToggle"
@change="updateCIJobTokenScope"
>
<template #help>
@@ -216,6 +260,7 @@ export default {
<gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
{{ content }}
</gl-link>
+ <strong v-if="disableOutboundToken">{{ $options.i18n.disableToggleWarning }} </strong>
</template>
</gl-sprintf>
</template>
@@ -229,7 +274,9 @@ export default {
<template #default>
<gl-form-input
v-model="targetProjectPath"
+ :disabled="disableOutboundToken"
:placeholder="$options.i18n.addProjectPlaceholder"
+ data-testid="project-path-input"
/>
</template>
<template #footer>
@@ -240,7 +287,7 @@ export default {
</template>
</gl-card>
<gl-alert
- v-if="!jobTokenScopeEnabled"
+ v-if="!jobTokenScopeEnabled && !disableOutboundToken"
class="gl-mb-3"
variant="warning"
:dismissible="false"
diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue
index 59d59757735..167eebc8d9b 100644
--- a/app/assets/javascripts/token_access/components/token_access_app.vue
+++ b/app/assets/javascripts/token_access/components/token_access_app.vue
@@ -1,27 +1,18 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import OutboundTokenAccess from './outbound_token_access.vue';
import InboundTokenAccess from './inbound_token_access.vue';
-import OptInJwt from './opt_in_jwt.vue';
export default {
+ name: 'TokenAccessApp',
components: {
OutboundTokenAccess,
InboundTokenAccess,
- OptInJwt,
- },
- mixins: [glFeatureFlagMixin()],
- computed: {
- inboundTokenAccessEnabled() {
- return this.glFeatures.ciInboundJobTokenScope;
- },
},
};
</script>
<template>
<div>
- <inbound-token-access v-if="inboundTokenAccessEnabled" class="gl-pb-5" />
+ <inbound-token-access class="gl-pb-5" />
<outbound-token-access class="gl-py-5" />
- <opt-in-jwt />
</div>
</template>
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
index c00dd882895..ee88b4ec339 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -29,6 +29,9 @@ export default {
removeProject(project) {
this.$emit('removeProject', project);
},
+ namespaceFallback(namespace) {
+ return namespace?.fullPath || '';
+ },
},
};
</script>
@@ -51,7 +54,9 @@ export default {
</template>
<template #cell(namespace)="{ item }">
- <span data-testid="token-access-project-namespace">{{ item.namespace.fullPath }}</span>
+ <span data-testid="token-access-project-namespace">
+ {{ namespaceFallback(item.namespace) }}
+ </span>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/token_access/constants.js b/app/assets/javascripts/token_access/constants.js
deleted file mode 100644
index fb2128462f0..00000000000
--- a/app/assets/javascripts/token_access/constants.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { helpPagePath } from '~/helpers/help_page_helper';
-
-export const LIMIT_JWT_ACCESS_SNIPPET = `job_name:
- id_tokens:
- ID_TOKEN_1: # or any other name
- aud: "..." # sub-keyword to configure the token's audience
- secrets:
- TEST_SECRET:
- vault: db/prod
-`;
-
-export const OPT_IN_JWT_HELP_LINK = helpPagePath('ci/secrets/id_token_authentication', {
- anchor: 'automatic-id-token-authentication-with-hashicorp-vault',
-});
diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
index aac9feab237..6c85839df07 100644
--- a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
+++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
@@ -1,5 +1,5 @@
-mutation inboundUpdateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
+mutation inboundUpdateCIJobTokenScope($input: ProjectCiCdSettingsUpdateInput!) {
+ projectCiCdSettingsUpdate(input: $input) {
ciCdSettings {
inboundJobTokenScopeEnabled
}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
index d99f2e3597d..30a6bb6106a 100644
--- a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
+++ b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
@@ -1,5 +1,5 @@
-mutation updateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
+mutation updateCIJobTokenScope($input: ProjectCiCdSettingsUpdateInput!) {
+ projectCiCdSettingsUpdate(input: $input) {
ciCdSettings {
jobTokenScopeEnabled
}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
deleted file mode 100644
index c12b5646423..00000000000
--- a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
- ciCdSettings {
- optInJwt
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
deleted file mode 100644
index a1a216b7dc3..00000000000
--- a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-query getOptInJwtSetting($fullPath: ID!) {
- project(fullPath: $fullPath) {
- id
- ciCdSettings {
- optInJwt
- }
- }
-}
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 0253abe393e..9258d5eba45 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -20,6 +20,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
return new Vue({
el: containerEl,
+ name: 'TokenAccessAppsRoot',
apolloProvider,
provide: {
fullPath,
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 2593fbe6ed1..968e866eedd 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -5,14 +5,12 @@ 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,
+ plugins: window.snowplowPlugins || [],
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 fab18cefc60..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,58 +20,51 @@ 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_PROJECT = __('Project');
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
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.'),
},
];
@@ -87,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/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue
index e982d10f63b..37c9548ad64 100644
--- a/app/assets/javascripts/user_lists/components/add_user_modal.vue
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -19,11 +19,11 @@ export default {
modalOptions: {
actionPrimary: {
text: s__('UserLists|Add'),
- attributes: [{ 'data-testid': 'confirm-add-user-ids', variant: 'confirm' }],
+ attributes: { 'data-testid': 'confirm-add-user-ids', variant: 'confirm' },
},
actionCancel: {
text: s__('UserLists|Cancel'),
- attributes: [{ 'data-testid': 'cancel-add-user-ids' }],
+ attributes: { 'data-testid': 'cancel-add-user-ids' },
},
modalId: ADD_USER_MODAL_ID,
static: true,
diff --git a/app/assets/javascripts/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js
index 6db2e65cf04..6f5d483a4c7 100644
--- a/app/assets/javascripts/user_lists/store/edit/actions.js
+++ b/app/assets/javascripts/user_lists/store/edit/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getErrorMessages } from '../utils';
import * as types from './mutation_types';
@@ -17,6 +17,6 @@ export const updateUserList = ({ commit, state }, userList) => {
iid: userList.iid,
name: userList.name,
})
- .then(({ data }) => redirectTo(data.path))
+ .then(({ data }) => redirectTo(data.path)) // eslint-disable-line import/no-deprecated
.catch((response) => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
};
diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js
index 478fca40142..030f1f59212 100644
--- a/app/assets/javascripts/user_lists/store/new/actions.js
+++ b/app/assets/javascripts/user_lists/store/new/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getErrorMessages } from '../utils';
import * as types from './mutation_types';
@@ -10,6 +10,6 @@ export const createUserList = ({ commit, state }, userList) => {
...state.userList,
...userList,
})
- .then(({ data }) => redirectTo(data.path))
+ .then(({ data }) => redirectTo(data.path)) // eslint-disable-line import/no-deprecated
.catch((response) => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response)));
};
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 1af47b020f7..66e54b59187 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -6,6 +6,7 @@ import $ from 'jquery';
import { escape, template, uniqBy } from 'lodash';
import { AJAX_USERS_SELECT_PARAMS_MAP } from 'ee_else_ce/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
import axios from '~/lib/utils/axios_utils';
@@ -466,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'),
@@ -647,7 +650,7 @@ UsersSelect.prototype.users = function (query, options, callback) {
...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP),
};
- const isMergeRequest = options.issuableType === 'merge_request';
+ const isMergeRequest = options.issuableType === TYPE_MERGE_REQUEST;
const isEditMergeRequest = !options.issuableType && options.iid && options.targetBranch;
const isNewMergeRequest = !options.issuableType && !options.iid && options.targetBranch;
@@ -684,7 +687,7 @@ UsersSelect.prototype.renderRow = function (
img,
elsClassName,
) {
- const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : '';
+ const tooltip = issuableType === TYPE_MERGE_REQUEST && !user.can_merge ? __('Cannot merge') : '';
const tooltipClass = tooltip ? `has-tooltip` : '';
const selectedClass = selected === true ? 'is-active' : '';
const linkClasses = `${selectedClass} ${tooltipClass}`;
@@ -693,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
@@ -725,7 +729,7 @@ UsersSelect.prototype.renderRowAvatar = function (issuableType, user, img) {
}
const mergeIcon =
- issuableType === 'merge_request' && !user.can_merge
+ issuableType === TYPE_MERGE_REQUEST && !user.can_merge
? spriteIcon('warning-solid', 's12 merge-icon')
: '';
diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/validators/length_validator.js
index b2074fb1e39..6ce453fe40b 100644
--- a/app/assets/javascripts/pages/sessions/new/length_validator.js
+++ b/app/assets/javascripts/validators/length_validator.js
@@ -2,6 +2,16 @@ import InputValidator from '~/validators/input_validator';
const errorMessageClass = 'gl-field-error';
+export const isAboveMaxLength = (str, maxLength) => {
+ return str.length > parseInt(maxLength, 10);
+};
+
+export const isBelowMinLength = (value, minLength, allowEmpty) => {
+ const isValueNotAllowedOrNotEmpty = allowEmpty !== 'true' || value.length !== 0;
+ const isValueBelowMinLength = value.length < parseInt(minLength, 10);
+ return isValueBelowMinLength && isValueNotAllowedOrNotEmpty;
+};
+
export default class LengthValidator extends InputValidator {
constructor(opts = {}) {
super();
@@ -26,16 +36,17 @@ export default class LengthValidator extends InputValidator {
minLengthMessage,
maxLengthMessage,
maxLength,
+ allowEmpty,
} = this.inputDomElement.dataset;
this.invalidInput = false;
- if (value.length > parseInt(maxLength, 10)) {
+ if (isAboveMaxLength(value, maxLength)) {
this.invalidInput = true;
this.errorMessage = maxLengthMessage;
}
- if (value.length < parseInt(minLength, 10)) {
+ if (isBelowMinLength(value, minLength, allowEmpty)) {
this.invalidInput = true;
this.errorMessage = minLengthMessage;
}
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/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index 917ed259dd0..952ff9b18e9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -1,10 +1,21 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlPopover,
+ GlSprintf,
+ GlLink,
+ GlDropdown,
+ GlDropdownItem,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
export default {
components: {
GlButton,
+ GlPopover,
+ GlSprintf,
+ GlLink,
GlDropdown,
GlDropdownItem,
},
@@ -82,30 +93,46 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-flex-start">
<template v-if="hasOneOption">
- <gl-button
- v-for="(btn, index) in tertiaryButtons"
- :id="btn.id"
- :key="index"
- v-gl-tooltip.hover
- :title="setTooltip(btn)"
- :href="btn.href"
- :target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
- :data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="actionButtonQaSelector(btn)"
- :data-method="btn.dataMethod"
- :icon="btn.icon"
- :data-testid="btn.testId || 'extension-actions-button'"
- :variant="btn.variant || 'confirm'"
- :loading="btn.loading"
- :disabled="btn.loading"
- category="tertiary"
- size="small"
- class="gl-md-display-block gl-float-left"
- @click="onClickAction(btn)"
- >
- {{ btn.text }}
- </gl-button>
+ <span v-for="(btn, index) in tertiaryButtons" :key="index">
+ <gl-button
+ :id="btn.id"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
+ :href="btn.href"
+ :target="btn.target"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
+ :data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
+ >
+ {{ btn.text }}
+ </gl-button>
+ <gl-popover v-if="btn.popoverTarget" :target="btn.popoverTarget">
+ <template #title> {{ btn.popoverTitle }} </template>
+
+ <span v-if="btn.popoverLink">
+ <gl-sprintf :message="btn.popoverText">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="btn.popoverLink" target="_blank">
+ {{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else>
+ {{ btn.popoverText }}
+ </span>
+ </gl-popover>
+ </span>
</template>
<template v-if="hasMultipleOptions">
<gl-dropdown
@@ -134,30 +161,46 @@ export default {
{{ btn.text }}
</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-for="(btn, index) in tertiaryButtons"
- :id="btn.id"
- :key="index"
- v-gl-tooltip.hover
- :title="setTooltip(btn)"
- :href="btn.href"
- :target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
- :data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="actionButtonQaSelector(btn)"
- :data-method="btn.dataMethod"
- :icon="btn.icon"
- :data-testid="btn.testId || 'extension-actions-button'"
- :variant="btn.variant || 'confirm'"
- :loading="btn.loading"
- :disabled="btn.loading"
- category="tertiary"
- size="small"
- class="gl-display-none gl-md-display-block gl-float-left"
- @click="onClickAction(btn)"
- >
- {{ btn.text }}
- </gl-button>
+ <span v-for="(btn, index) in tertiaryButtons" :key="index">
+ <gl-button
+ :id="btn.id"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
+ :href="btn.href"
+ :target="btn.target"
+ :class="[{ 'gl-mr-1': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
+ :data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-display-none gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
+ >
+ {{ btn.text }}
+ </gl-button>
+ <gl-popover v-if="btn.popoverTarget" :target="btn.popoverTarget">
+ <template #title> {{ btn.popoverTitle }} </template>
+
+ <span v-if="btn.popoverLink">
+ <gl-sprintf :message="btn.popoverText">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="btn.popoverLink" target="_blank">
+ {{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else>
+ {{ btn.popoverText }}
+ </span>
+ </gl-popover>
+ </span>
</template>
</div>
</template>
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 4b65d6fd9ac..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,32 +1,34 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+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';
-import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
-import { humanizeInvalidApproversRules } from './humanized_text';
+import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
export default {
name: 'MRWidgetApprovals',
components: {
- MrWidgetContainer,
- MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
+ StateContainer,
GlButton,
GlSprintf,
- GlLink,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
+ provide: {
+ expandDetailsTooltip: __('Expand eligible approvers'),
+ collapseDetailsTooltip: __('Collapse eligible approvers'),
+ },
props: {
mr: {
type: Object,
@@ -56,13 +58,16 @@ export default {
required: false,
default: false,
},
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- fetchingApprovals: true,
hasApprovalAuthError: false,
isApproving: false,
- updatedCount: 0,
};
},
computed: {
@@ -70,7 +75,7 @@ export default {
return this.mr.approvalsWidgetType === 'base';
},
isApproved() {
- return Boolean(this.approvals.approved);
+ return Boolean(this.approvals.approved || this.approvedBy.length);
},
isOptional() {
return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length;
@@ -78,32 +83,40 @@ export default {
hasAction() {
return Boolean(this.action);
},
- approvals() {
- return this.mr.approvals || {};
- },
invalidRules() {
- return this.approvals.invalid_approvers_rules || [];
+ 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.approvals.merge_request_approvers_available && this.invalidRules.length;
+ return this.mr.mergeRequestApproversAvailable && this.invalidRules.length;
},
- invalidRulesText() {
- return humanizeInvalidApproversRules(this.invalidRules);
+ hasInvalidApprovedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length;
+ },
+ hasInvalidFailedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.length;
},
approvedBy() {
- return this.approvals.approved_by ? this.approvals.approved_by.map((x) => x.user) : [];
+ return this.approvals.approvedBy?.nodes || [];
},
userHasApproved() {
- return Boolean(this.approvals.user_has_approved);
+ return this.approvedBy.some(
+ (approver) => getIdFromGraphQLId(approver.id) === gon.current_user_id,
+ );
},
userCanApprove() {
- return Boolean(this.approvals.user_can_approve);
+ return Boolean(this.approvals.userPermissions.canApprove);
},
showApprove() {
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
@@ -129,24 +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;
},
- },
- created() {
- this.refreshApprovals()
- .then(() => {
- this.fetchingApprovals = false;
- })
- .catch(() =>
- this.alerts.push(
- createAlert({
- message: FETCH_ERROR,
- }),
- ),
- );
+ 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() {
@@ -196,16 +214,14 @@ export default {
this.isApproving = true;
this.clearError();
return serviceFn()
- .then((data) => {
- this.mr.setApprovals(data);
- this.updatedCount += 1;
-
+ .then(() => {
if (!window.gon?.features?.realtimeMrStatusChange) {
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('ApprovalUpdated');
}
- this.$emit('updated');
+ // TODO: Remove this line when we move to Apollo subscriptions
+ this.$apollo.queries.approvals.refetch();
})
.catch(errFn)
.then(() => {
@@ -216,21 +232,30 @@ export default {
FETCH_LOADING,
linkToInvalidRules: INVALID_RULES_DOCS_PATH,
i18n: {
- invalidRuleSingular: s__(
- 'mrWidget|Approval rule %{rules} is invalid. GitLab has approved this rule automatically to unblock the merge request. %{link}',
+ 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|Approval rules %{rules} are invalid. GitLab has approved these rules automatically to unblock the merge request. %{link}',
+ 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="fetchingApprovals">{{ $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">
@@ -239,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"
>
@@ -252,21 +277,15 @@ export default {
/>
<approvals-summary
v-else
- :project-path="mr.targetProjectFullPath"
- :iid="`${mr.iid}`"
- :updated-count="updatedCount"
+ :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 #link>
- <gl-link :href="$options.linkToInvalidRules" target="_blank">
- {{ $options.i18n.learnMore }}
- </gl-link>
+ <template #danger="{ content }">
+ <span class="gl-font-weight-bold text-danger">{{ content }}</span>
</template>
</gl-sprintf>
</div>
@@ -277,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 697d953874c..367395f4446 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,5 +1,5 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
+import { GlLink, GlPopover } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -10,40 +10,24 @@ import {
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/mappers';
-import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql';
export default {
- apollo: {
- approvalState: {
- query: approvedByQuery,
- variables() {
- return {
- projectPath: this.projectPath,
- iid: this.iid,
- };
- },
- update: (data) => data.project.mergeRequest,
- },
- },
components: {
- GlSkeletonLoader,
+ GlLink,
+ GlPopover,
UserAvatarList,
},
props: {
- projectPath: {
- type: String,
- required: true,
+ multipleApprovalRulesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- iid: {
- type: String,
+ approvalState: {
+ type: Object,
required: true,
},
- updatedCount: {
- type: Number,
- required: false,
- default: 0,
- },
- multipleApprovalRulesAvailable: {
+ disableCommittersApproval: {
type: Boolean,
required: false,
default: false,
@@ -51,7 +35,7 @@ export default {
},
data() {
return {
- approvalState: {},
+ isUserAvatarListExpanded: false,
};
},
computed: {
@@ -130,13 +114,23 @@ 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;
},
},
- watch: {
- updatedCount() {
- this.$apollo.queries.approvalState.refetch();
+ methods: {
+ onUserAvatarListExpanded() {
+ this.isUserAvatarListExpanded = true;
+ },
+ onUserAvatarListCollapsed() {
+ this.isUserAvatarListExpanded = false;
},
},
};
@@ -144,27 +138,26 @@ export default {
<template>
<div data-qa-selector="approvals_summary_content">
- <div
- v-if="$apollo.queries.approvalState.loading"
- class="gl-display-inline-block gl-vertical-align-middle"
- style="width: 132px; height: 24px"
- >
- <gl-skeleton-loader :width="132" :height="24">
- <rect width="100" height="24" x="0" y="0" rx="4" />
- <circle cx="120" cy="12" r="12" />
- </gl-skeleton-loader>
- </div>
- <template v-else>
- <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
- <template v-if="hasApprovers">
- <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"
- :img-size="24"
- :items="approvers"
- />
- </template>
+ <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
+ <template v-if="hasApprovers">
+ <span v-if="approvalLeftMessage">{{ message }}</span>
+ <span v-else class="gl-font-weight-bold">{{ message }}</span>
+ <user-avatar-list
+ class="gl-display-inline-block"
+ :class="{ 'gl-pt-1': isUserAvatarListExpanded }"
+ :img-size="24"
+ :items="approvers"
+ @expanded="onUserAvatarListExpanded"
+ @collapsed="onUserAvatarListCollapsed"
+ />
+ </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/approvals/queries/approved_by.query.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql
index c8cae6a8885..437ae578cd0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
query approvedBy($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
id
@@ -5,12 +7,12 @@ query approvedBy($projectPath: ID!, $iid: String!) {
id
approvedBy {
nodes {
- id
- name
- avatarUrl
- webUrl
+ ...User
}
}
+ userPermissions {
+ canApprove
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql
new file mode 100644
index 00000000000..d5092d9ae1a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+subscription mergeRequestApprovalStateUpdated($issuableId: IssuableID!) {
+ mergeRequestApprovalStateUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ approvedBy {
+ nodes {
+ ...User
+ }
+ }
+ userPermissions {
+ canApprove
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index d6d1cae4029..306ed664326 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index 39a6086e0d5..f4029c39225 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -7,10 +7,7 @@ import {
GlLink,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { s__, __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import ReviewAppLink from '../review_app_link.vue';
export default {
@@ -22,7 +19,6 @@ export default {
GlIcon,
GlLink,
GlSearchBoxByType,
- ModalCopyButton,
ReviewAppLink,
},
directives: {
@@ -54,13 +50,6 @@ export default {
filteredChanges() {
return this.deployment?.changes?.filter((change) => change.path.includes(this.searchTerm));
},
- isSafeUrl() {
- return isSafeURL(this.deploymentExternalUrl);
- },
- },
- i18n: {
- copy: __('Copy URL'),
- copyTitle: s__('Environments|Copy live environment URL'),
},
};
</script>
@@ -68,20 +57,11 @@ export default {
<span class="gl-display-inline-flex">
<gl-button-group v-if="shouldRenderDropdown" size="small">
<review-app-link
- v-if="isSafeUrl"
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="deploy-link js-deploy-url inline"
+ css-class="deploy-link js-deploy-url gl-display-inline"
/>
- <modal-copy-button
- v-else
- :title="$options.i18n.copyTitle"
- :text="deploymentExternalUrl"
- size="small"
- >
- {{ $options.i18n.copy }}
- </modal-copy-button>
<gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown">
<template #button-content>
<gl-icon
@@ -110,22 +90,12 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
- <template v-else>
- <review-app-link
- v-if="isSafeUrl"
- :display="appButtonText"
- :link="deploymentExternalUrl"
- size="small"
- css-class="deploy-link js-deploy-url inline"
- />
- <modal-copy-button
- v-else
- :title="$options.i18n.copyTitle"
- :text="deploymentExternalUrl"
- size="small"
- >
- {{ $options.i18n.copy }}
- </modal-copy-button>
- </template>
+ <review-app-link
+ v-else
+ :display="appButtonText"
+ :link="deploymentExternalUrl"
+ size="small"
+ css-class="deploy-link js-deploy-url gl-display-inline"
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index b78293a9815..028f5370028 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -313,7 +313,7 @@ export default {
:status="statusIconName"
:is-loading="isLoadingSummary"
:class="{ 'gl-cursor-pointer': isCollapsible }"
- class="gl-p-5"
+ class="gl-pl-5 gl-pr-4 gl-py-4"
@mousedown="onRowMouseDown"
@mouseup="onRowMouseUp"
>
@@ -381,7 +381,7 @@ export default {
v-else-if="hasFullData"
:items="fullData"
:min-item-size="32"
- class="report-block-container gl-px-5 gl-py-0"
+ class="report-block-container gl-p-0"
>
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
@@ -389,7 +389,7 @@ export default {
:class="{
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
}"
- class="gl-py-3 gl-pl-7"
+ class="gl-py-3 gl-pl-9"
data-testid="extension-list-item"
>
<gl-intersection-observer
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_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
index c762922d890..b192ccfa379 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
@@ -31,7 +31,7 @@ export default {
};
</script>
<template>
- <h4 class="js-mr-widget-author">
+ <h4 class="js-mr-widget-author gl-flex-grow-1">
{{ actionText }}
<mr-widget-author :author="author" />
<span class="sr-only">{{ dateReadable }} ({{ dateTitle }})</span>
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/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index e31e69d0f3a..116bb251831 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -38,6 +38,7 @@ export default {
:size="size"
target="_blank"
rel="noopener noreferrer nofollow"
+ is-unsafe-link
:class="cssClass"
data-track-action="open_review_app"
data-track-label="review_app"
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 f7d6f7b4345..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,15 +73,15 @@ 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" />
- <div class="media-body">
+ <div class="media-body gl-display-flex gl-align-items-center">
<slot></slot>
</div>
</div>
@@ -78,7 +97,7 @@ export default {
'gl-display-flex gl-align-items-center': actions.length,
'gl-md-display-flex gl-align-items-center gl-flex-wrap gl-gap-3': !actions.length,
}"
- class="media-body gl-line-height-24"
+ class="media-body gl-line-height-normal"
>
<slot></slot>
<div
@@ -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 38f7d3d2c96..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
@@ -2,7 +2,7 @@
import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
@@ -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 46392565088..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
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
@@ -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 ec6c2cf34c0..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
@@ -1,6 +1,7 @@
<script>
-import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { GlButton, GlLink, GlModal, GlSkeletonLoader } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
@@ -19,6 +20,28 @@ const i18n = {
export default {
name: 'MRWidgetRebase',
i18n,
+ modal: {
+ id: 'rebase-security-risk-modal',
+ title: s__('mrWidget|Are you sure you want to rebase?'),
+ actionPrimary: {
+ text: s__('mrWidget|Rebase'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ runPipelinesInTheParentProjectHelpPath: helpPagePath(
+ '/ci/pipelines/merge_request_pipelines.html',
+ {
+ anchor: 'run-pipelines-in-the-parent-project',
+ },
+ ),
apollo: {
state: {
query: rebaseQuery,
@@ -30,11 +53,18 @@ export default {
},
components: {
BoldText,
- GlSkeletonLoader,
GlButton,
+ GlLink,
+ GlModal,
+ GlSkeletonLoader,
StateContainer,
},
mixins: [mergeRequestQueryVariablesMixin],
+ inject: {
+ canCreatePipelineInTargetProject: {
+ default: false,
+ },
+ },
props: {
mr: {
type: Object,
@@ -84,6 +114,21 @@ export default {
(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline)
);
},
+ isForkMergeRequest() {
+ return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
+ },
+ isLatestPipelineCreatedInTargetProject() {
+ const latestPipeline = this.state.pipelines.nodes[0];
+
+ return latestPipeline?.project?.fullPath === this.mr.targetProjectFullPath;
+ },
+ shouldShowSecurityWarning() {
+ return (
+ this.canCreatePipelineInTargetProject &&
+ this.isForkMergeRequest &&
+ !this.isLatestPipelineCreatedInTargetProject
+ );
+ },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -110,6 +155,13 @@ export default {
rebaseWithoutCi() {
return this.rebase({ skipCi: true });
},
+ tryRebase() {
+ if (this.shouldShowSecurityWarning) {
+ this.$refs.modal.show();
+ } else {
+ this.rebase();
+ }
+ },
checkRebaseStatus(continuePolling, stopPolling) {
this.service
.poll()
@@ -142,71 +194,109 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" :status="status" :is-loading="isLoading">
- <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>
- </template>
- <template v-if="!isLoading">
- <span
- v-if="rebaseInProgress || isMakingRequest"
- class="gl-ml-0! gl-text-body!"
- data-testid="rebase-message"
- >{{ s__('mrWidget|Rebase in progress') }}</span
- >
- <span
- v-if="!rebaseInProgress && !canPushToSourceBranch"
- class="gl-text-body! gl-ml-0!"
- data-testid="rebase-message"
- >
- <bold-text :message="$options.i18n.rebaseError" />
- </span>
- <div
- v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
- >
+ <div>
+ <state-container
+ :status="status"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
+ <template #loading>
+ <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">
<span
- v-if="!rebasingError"
- class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
+ v-if="rebaseInProgress || isMakingRequest"
+ class="gl-ml-0! gl-text-body!"
data-testid="rebase-message"
- data-qa-selector="no_fast_forward_message_content"
+ >{{ s__('mrWidget|Rebase in progress') }}</span
>
- <bold-text :message="$options.i18n.rebaseError" />
- </span>
<span
- v-else
- class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
+ v-if="!rebaseInProgress && !canPushToSourceBranch"
+ class="gl-text-body! gl-ml-0!"
data-testid="rebase-message"
- >{{ rebasingError }}</span
>
- </div>
- </template>
- <template v-if="!isLoading" #actions>
- <gl-button
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- data-qa-selector="mr_rebase_button"
- data-testid="standard-rebase-button"
- class="gl-align-self-start"
- @click="rebase"
- >
- {{ s__('mrWidget|Rebase') }}
- </gl-button>
- <gl-button
- v-if="showRebaseWithoutPipeline"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- category="secondary"
- data-testid="rebase-without-ci-button"
- class="gl-align-self-start gl-mr-2"
- @click="rebaseWithoutCi"
- >
- {{ s__('mrWidget|Rebase without pipeline') }}
- </gl-button>
- </template>
- </state-container>
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
+ <div
+ v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
+ class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
+ >
+ <span
+ v-if="!rebasingError"
+ class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
+ data-testid="rebase-message"
+ data-qa-selector="no_fast_forward_message_content"
+ >
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
+ <span
+ v-else
+ class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
+ data-testid="rebase-message"
+ >{{ rebasingError }}</span
+ >
+ </div>
+ </template>
+ <template v-if="!isLoading" #actions>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ data-qa-selector="mr_rebase_button"
+ data-testid="standard-rebase-button"
+ class="gl-align-self-start"
+ @click="tryRebase"
+ >
+ {{ s__('mrWidget|Rebase') }}
+ </gl-button>
+ <gl-button
+ v-if="showRebaseWithoutPipeline"
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ class="gl-align-self-start gl-mr-2"
+ @click="rebaseWithoutCi"
+ >
+ {{ s__('mrWidget|Rebase without pipeline') }}
+ </gl-button>
+ </template>
+ </state-container>
+
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modal.id"
+ :title="$options.modal.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="rebase"
+ >
+ <p>
+ {{
+ s__(
+ 'Pipelines|Rebasing creates a pipeline that runs code originating from a forked project merge request. Consequently there are potential security implications, such as the exposure of CI variables.',
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__(
+ "Pipelines|You should review the code thoroughly before running this pipeline with the parent project's CI/CD resources.",
+ )
+ }}
+ </p>
+ <p>
+ {{ s__('Pipelines|If you are unsure, ask a project maintainer to review it for you.') }}
+ </p>
+ <gl-link :href="$options.runPipelinesInTheParentProjectHelpPath" target="_blank">
+ {{ s__('Pipelines|More Information') }}
+ </gl-link>
+ </gl-modal>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index 850a4e2fd56..30cd9fa752f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-merge-requests-md.svg?url';
import api from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -12,25 +11,19 @@ export default {
GlSprintf,
GlLink,
},
- directives: {
- SafeHtml,
- },
props: {
mr: {
type: Object,
required: true,
},
},
- data() {
- return { emptyStateSVG };
- },
methods: {
onClickNewFile() {
api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file');
},
},
ciHelpPage: helpPagePath('ci/quick_start/index.html'),
- safeHtmlConfig: { ADD_TAGS: ['use'] },
+ EMPTY_STATE_SVG_URL,
};
</script>
@@ -38,15 +31,18 @@ export default {
<div class="mr-widget-body mr-widget-empty-state">
<div class="row">
<div
- class="artwork col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center"
+ class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-150 pb-0 pt-0"
>
- <span v-safe-html:[$options.safeHtmlConfig]="emptyStateSVG"></span>
+ <img
+ :alt="s__('mrWidgetNothingToMerge|This merge request contains no changes.')"
+ :src="$options.EMPTY_STATE_SVG_URL"
+ />
</div>
- <div class="text col-md-7 order-md-first col-12">
+ <div class="text col-md-9 col-12">
<p class="highlight">
{{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }}
</p>
- <p>
+ <p data-testid="nothing-to-merge-body">
<gl-sprintf
:message="
s__(
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 bb8990a48b1..f120680b440 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
@@ -11,11 +11,12 @@ import {
GlTooltipDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
-import { isEmpty } from 'lodash';
+import { isEmpty, isNil } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
-import { createAlert } from '~/flash';
+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,
@@ -86,7 +88,7 @@ export default {
this.squashCommitMessage = this.state.defaultSquashCommitMessage;
}
- if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
+ if (!isNil(this.state.mergeTrainsCount) && !this.pollingInterval) {
this.initPolling();
}
},
@@ -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,14 +545,14 @@ 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-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5"
data-testid="delete-source-branch-checkbox"
>
{{ __('Delete source branch') }}
@@ -536,14 +564,13 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- class="gl-mr-5 gl-mb-3 gl-md-mb-0"
+ class="gl-mr-5"
/>
<gl-form-checkbox
v-if="shouldShowSquashEdit || shouldShowMergeEdit"
v-model="editCommitMessage"
data-testid="widget_edit_commit_message"
- class="gl-display-flex gl-align-items-center gl-mb-3 gl-md-mb-0"
>
{{ __('Edit commit message') }}
</gl-form-checkbox>
@@ -587,9 +614,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 +695,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 +751,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/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 1413a46b4b9..97ef96fe382 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -39,13 +39,13 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex">
<gl-form-checkbox
v-gl-tooltip
:checked="value"
:disabled="isDisabled"
name="squash"
- class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ class="js-squash-checkbox gl-mr-2"
data-qa-selector="squash_checkbox"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
@@ -57,7 +57,7 @@ export default {
v-gl-tooltip
:href="helpPath"
:title="$options.i18n.helpLabel"
- class="gl-text-blue-600"
+ class="gl-text-blue-600 gl-line-height-1"
target="_blank"
>
<gl-icon name="question-o" />
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 02d4f2499fe..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
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import MergeRequest from '~/merge_request';
import BoldText from '~/vue_merge_request_widget/components/bold_text.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 73129a86877..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-p-5 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/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
index b64f9c148d1..e67924d28ab 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
@@ -79,7 +79,7 @@ export default {
</script>
<template>
<div
- class="gl-w-full gl-display-flex"
+ class="gl-display-flex"
:class="{
'gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline': level === 2,
'gl-align-items-center': level === 3,
@@ -91,7 +91,7 @@ export default {
:name="widgetName"
:icon-name="statusIconName"
/>
- <div class="gl-w-full">
+ <div class="gl-w-full gl-min-w-0">
<div class="gl-display-flex">
<slot name="header">
<div v-if="header" class="gl-mb-2">
@@ -135,7 +135,7 @@ export default {
/>
</div>
</div>
- <div class="gl-display-flex gl-align-items-baseline gl-w-full">
+ <div class="gl-display-flex gl-align-items-baseline">
<status-icon
v-if="statusIconName && header"
:level="2"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 85ae298fcea..18503720814 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -2,6 +2,11 @@ import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
+export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4;
+
+export const STATE_QUERY_POLLING_INTERVAL_DEFAULT = 5000;
+export const STATE_QUERY_POLLING_INTERVAL_BACKOFF = 2;
+
export const SUCCESS = 'success';
export const WARNING = 'warning';
export const INFO = 'info';
@@ -163,9 +168,6 @@ export const EXTENSION_ICON_CLASS = {
severityUnknown: 'gl-text-gray-400',
};
-export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
-export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
-
export const TELEMETRY_WIDGET_VIEWED = 'WIDGET_VIEWED';
export const TELEMETRY_WIDGET_EXPANDED = 'WIDGET_EXPANDED';
export const TELEMETRY_WIDGET_FULL_REPORT_CLICKED = 'WIDGET_FULL_REPORT_CLICKED';
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
index 4f9bba1e0cb..713c9e610b3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -63,7 +63,9 @@ export default {
this.collapsedData.newErrors.map((e) => {
return fullData.push({
- text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: {
prependText: i18n.prependText,
text: `${e.file_path}:${e.line}`,
@@ -77,7 +79,9 @@ export default {
this.collapsedData.resolvedErrors.map((e) => {
return fullData.push({
- text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: {
prependText: i18n.prependText,
text: `${e.file_path}:${e.line}`,
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 8d596465970..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';
@@ -13,7 +9,18 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ MergeRequestApprovalState: {
+ merge: true,
+ },
+ },
+ },
+ },
+ ),
});
export default () => {
@@ -22,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: {
@@ -29,6 +40,9 @@ export default () => {
artifactsEndpointPlaceholder: gl.mrWidgetData.artifacts_endpoint_placeholder,
falsePositiveDocUrl: gl.mrWidgetData.false_positive_doc_url,
canViewFalsePositive: parseBoolean(gl.mrWidgetData.can_view_false_positive),
+ canCreatePipelineInTargetProject: parseBoolean(
+ gl.mrWidgetData.can_create_pipeline_in_target_project,
+ ),
},
...MrWidgetOptions,
apolloProvider,
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 7d0871f696b..3228c09c9b6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -1,9 +1,76 @@
+import mergeRequestApprovalStateUpdated from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql';
+import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
+
+import { createAlert } from '~/alert';
+
+import { convertToGraphQLId } from '../../graphql_shared/utils';
+import { TYPENAME_MERGE_REQUEST } from '../../graphql_shared/constants';
+
+import { FETCH_ERROR } from '../components/approvals/messages';
+
export default {
+ apollo: {
+ approvals: {
+ query: approvedByQuery,
+ variables() {
+ return {
+ projectPath: this.mr.targetProjectFullPath,
+ iid: `${this.mr.iid}`,
+ };
+ },
+ update: (data) => data.project.mergeRequest,
+ result({ data }) {
+ const { mergeRequest } = data.project;
+
+ this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval;
+
+ this.mr.setApprovals(mergeRequest);
+ },
+ error() {
+ createAlert({
+ message: FETCH_ERROR,
+ });
+ },
+ subscribeToMore: {
+ document: mergeRequestApprovalStateUpdated,
+ variables() {
+ return {
+ issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr.id),
+ };
+ },
+ skip() {
+ return !this.mr?.id || !this.isRealtimeEnabled;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { mergeRequestApprovalStateUpdated: queryResult },
+ },
+ },
+ ) {
+ if (queryResult) {
+ this.mr.setApprovals(queryResult);
+ }
+ },
+ },
+ },
+ },
data() {
return {
alerts: [],
+ approvals: {},
+ disableCommittersApproval: false,
};
},
+ computed: {
+ isRealtimeEnabled() {
+ // This mixin needs glFeatureFlagsMixin, but fatals if it's included here.
+ // Parents that include this mixin (approvals) should also include the
+ // glFeatureFlagsMixin mixin, or this will always be false.
+ return Boolean(this.glFeatures?.realtimeApprovals);
+ },
+ },
methods: {
clearError() {
this.$emit('clearError');
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 ecbee6544ab..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
@@ -1,5 +1,5 @@
<script>
-import { isEmpty } from 'lodash';
+import { isEmpty, clamp } from 'lodash';
import {
registerExtension,
registeredExtensions,
@@ -9,11 +9,10 @@ import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/ap
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
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 '~/flash';
-import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+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';
@@ -44,7 +43,13 @@ import UnresolvedDiscussionsState from './components/states/unresolved_discussio
import WorkInProgressState from './components/states/work_in_progress.vue';
import ExtensionsContainer from './components/extensions/container';
import WidgetContainer from './components/widget/app.vue';
-import { STATE_MACHINE, stateToComponentMap } from './constants';
+import {
+ STATE_MACHINE,
+ stateToComponentMap,
+ STATE_QUERY_POLLING_INTERVAL_DEFAULT,
+ STATE_QUERY_POLLING_INTERVAL_BACKOFF,
+ FOUR_MINUTES_IN_MS,
+} from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
@@ -99,6 +104,7 @@ export default {
apollo: {
state: {
query: getStateQuery,
+ notifyOnNetworkStatusChange: true,
manual: true,
skip() {
return !this.mr;
@@ -106,10 +112,19 @@ export default {
variables() {
return this.mergeRequestQueryVariables;
},
- result({ data: { project } }) {
- if (project) {
- this.mr.setGraphqlData(project);
- this.loading = false;
+ pollInterval() {
+ return this.pollInterval;
+ },
+ result(response) {
+ if (!response.loading) {
+ this.pollInterval = this.apolloStateQueryPollingInterval;
+
+ if (response.data?.project) {
+ this.mr.setGraphqlData(response.data.project);
+ this.loading = false;
+ }
+ } else {
+ this.checkStatus(undefined, undefined, false);
}
},
subscribeToMore: {
@@ -140,6 +155,10 @@ export default {
},
},
mixins: [mergeRequestQueryVariablesMixin],
+ provide: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
props: {
mrData: {
type: Object,
@@ -158,9 +177,27 @@ export default {
loading: true,
recomputeComponentName: 0,
issuableId: false,
+ startingPollInterval: STATE_QUERY_POLLING_INTERVAL_DEFAULT,
+ pollInterval: STATE_QUERY_POLLING_INTERVAL_DEFAULT,
};
},
computed: {
+ apolloStateQueryMaxPollingInterval() {
+ return this.startingPollInterval + FOUR_MINUTES_IN_MS;
+ },
+ apolloStateQueryPollingInterval() {
+ if (this.startingPollInterval < 0) {
+ return 0;
+ }
+
+ const unboundedInterval = STATE_QUERY_POLLING_INTERVAL_BACKOFF * this.pollInterval;
+
+ return clamp(
+ unboundedInterval,
+ this.startingPollInterval,
+ this.apolloStateQueryMaxPollingInterval,
+ );
+ },
shouldRenderApprovals() {
return this.mr.state !== 'nothingToMerge';
},
@@ -193,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(
@@ -231,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;
@@ -284,7 +321,8 @@ export default {
mounted() {
MRWidgetService.fetchInitialData()
.then(({ data, headers }) => {
- this.startingPollInterval = Number(headers['POLL-INTERVAL']);
+ this.startingPollInterval =
+ Number(headers['POLL-INTERVAL']) || STATE_QUERY_POLLING_INTERVAL_DEFAULT;
this.initWidget(data);
})
.catch(() =>
@@ -295,9 +333,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
- if (this.pollingInterval) {
- this.pollingInterval.destroy();
- }
if (this.deploymentsInterval) {
this.deploymentsInterval.destroy();
@@ -332,7 +367,6 @@ export default {
this.initPostMergeDeploymentsPolling();
}
- this.initPolling();
this.bindEventHubListeners();
eventHub.$on('mr.discussion.updated', this.checkStatus);
@@ -363,8 +397,10 @@ export default {
createService(store) {
return new MRWidgetService(this.getServiceEndpoints(store));
},
- checkStatus(cb, isRebased) {
- this.$apollo.queries.state.refetch();
+ checkStatus(cb, isRebased, refetch = true) {
+ if (refetch) {
+ this.$apollo.queries.state.refetch();
+ }
return this.service
.checkStatus()
@@ -384,22 +420,11 @@ export default {
);
},
setFaviconHelper() {
- if (this.mr.ciStatusFaviconPath) {
- return setFaviconOverlay(this.mr.ciStatusFaviconPath);
+ if (this.mr.faviconOverlayPath) {
+ return setFaviconOverlay(this.mr.faviconOverlayPath);
}
return Promise.resolve();
},
- initPolling() {
- if (this.startingPollInterval <= 0) return;
-
- this.pollingInterval = new SmartInterval({
- callback: this.checkStatus,
- startingInterval: this.startingPollInterval,
- maxInterval: this.startingPollInterval + secondsToMilliseconds(4 * 60),
- hiddenInterval: secondsToMilliseconds(6 * 60),
- incrementByFactorOf: 2,
- });
- },
initDeploymentsPolling() {
this.deploymentsInterval = this.deploymentsPoll(this.fetchPreMergeDeployments);
},
@@ -453,7 +478,6 @@ export default {
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
- Project.initRefSwitcher();
}
})
.catch(() =>
@@ -476,10 +500,10 @@ export default {
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
resumePolling() {
- this.pollingInterval?.resume();
+ this.$apollo.queries.state.startPolling(this.pollInterval);
},
stopPolling() {
- this.pollingInterval?.stopTimer();
+ this.$apollo.queries.state.stopPolling();
},
bindEventHubListeners() {
eventHub.$on('MRWidgetUpdateRequested', (cb) => {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
index c7b53db1221..a6b35f20776 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
@@ -1,6 +1,7 @@
subscription getStateSubscription($issuableId: IssuableID!) {
mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
... on MergeRequest {
+ id
detailedMergeStatus
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
index 283177267d4..79ac87b7c37 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
@@ -8,6 +8,15 @@ query rebaseQuery($projectPath: ID!, $iid: String!) {
userPermissions {
pushToSourceBranch
}
+ pipelines {
+ nodes {
+ id
+ project {
+ id
+ fullPath
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 81cb20475cc..cead42b12ae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -32,5 +32,5 @@ export default function deviseState() {
) {
return stateKey.readyToMerge;
}
- return null;
+ return stateKey.checking;
}
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 f6a7ef58c10..39ae1fda9f4 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;
@@ -269,7 +270,7 @@ export default class MergeRequestStore {
this.conflictsDocsPath = data.conflicts_docs_path;
this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
- this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.codeCoverageCheckHelpPagePath = data.code_coverage_check_help_page_path;
this.licenseComplianceDocsPath = data.license_compliance_docs_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
@@ -294,6 +295,9 @@ export default class MergeRequestStore {
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
this.secretDetectionComparisonPath = data.secret_detection_comparison_path;
+
+ this.sastComparisonPathV2 = data.new_sast_comparison_path;
+ this.secretDetectionComparisonPathV2 = data.new_secret_detection_comparison_path;
}
get isNothingToMergeState() {
@@ -356,12 +360,11 @@ export default class MergeRequestStore {
initApprovals() {
this.isApproved = this.isApproved || false;
- this.approvals = this.approvals || null;
}
setApprovals(data) {
- this.approvals = data;
this.isApproved = data.approved || false;
+ this.approvals = true;
this.setState();
}
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/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
index 634b7da3def..93581dbbd40 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
@@ -33,7 +33,11 @@ export default {
</script>
<template>
- <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
+ <li
+ :id="noteAnchorId"
+ class="timeline-entry note system-note note-wrapper gl-p-0!"
+ data-qa-selector="alert_system_note_container"
+ >
<div class="gl-display-inline-flex gl-align-items-center gl-relative">
<div
class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
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/alert_details/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js
index 26477a3a66a..616d5c259b9 100644
--- a/app/assets/javascripts/vue_shared/alert_details/router.js
+++ b/app/assets/javascripts/vue_shared/alert_details/router.js
@@ -5,26 +5,9 @@ import { joinPaths } from '~/lib/utils/url_utility';
Vue.use(VueRouter);
export default function createRouter(base) {
- const router = new VueRouter({
+ return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [{ path: '/:tabId', name: 'tab' }],
});
-
- /*
- Backward-compatible behavior. Redirects hash mode URLs to history mode ones.
- Ex: from #/overview to /overview
- from #/metrics to /metrics
- from #/activity to /activity
- */
- router.beforeEach((to, _, next) => {
- if (to.hash.startsWith('#/')) {
- const path = to.fullPath.substring(2);
- next(path);
- } else {
- next();
- }
- });
-
- return router;
}
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_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 52a5d6e1b86..7b5ded9348f 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -71,7 +71,7 @@ export default {
<ci-icon :status="status" />
<template v-if="showText">
- <span class="gl-ml-2">{{ status.text }}</span>
+ <span class="gl-ml-2 gl-white-space-nowrap">{{ status.text }}</span>
</template>
</gl-link>
</template>
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 fb7105bd416..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,13 +1,12 @@
<script>
+import { v4 as uuidv4 } from 'uuid';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { CHART_CONTAINER_HEIGHT } from './constants';
export default {
name: 'CiCdAnalyticsAreaChart',
components: {
GlAreaChart,
- ResizableChartContainer,
},
props: {
chartData: {
@@ -19,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,24 +35,22 @@ export default {
<p>
<slot></slot>
</p>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- v-bind="$attrs"
- :width="width"
- :height="$options.chartContainerHeight"
- :data="chartData"
- :include-legend-avg-max="false"
- :option="areaChartOptions"
- >
- <template #tooltip-title>
- <slot name="tooltip-title"></slot>
- </template>
- <template #tooltip-content>
- <slot name="tooltip-content"></slot>
- </template>
- </gl-area-chart>
+ <gl-area-chart
+ v-bind="$attrs"
+ :key="chartKey"
+ responsive
+ width="auto"
+ :height="$options.chartContainerHeight"
+ :data="chartData"
+ :include-legend-avg-max="false"
+ :option="areaChartOptions"
+ >
+ <template #tooltip-title>
+ <slot name="tooltip-title"></slot>
+ </template>
+ <template #tooltip-content>
+ <slot name="tooltip-content"></slot>
</template>
- </resizable-chart-container>
+ </gl-area-chart>
</div>
</template>
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/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
index 75386a3cd01..2f28ae5e0e2 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
@@ -1,6 +1,6 @@
<script>
import { isString } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { DEFAULT_COLOR, COLOR_WIDGET_COLOR, DROPDOWN_VARIANT, ISSUABLE_COLORS } from './constants';
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index d0a634d8e54..65a601ed927 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -60,13 +60,11 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [
- {
- variant: 'danger',
- disabled: !this.isValid,
- 'data-qa-selector': 'confirm_danger_modal_button',
- },
- ],
+ attributes: {
+ variant: 'danger',
+ disabled: !this.isValid,
+ 'data-qa-selector': 'confirm_danger_modal_button',
+ },
};
},
actionCancel() {
diff --git a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
index 56e6399a1b7..f62bfb551df 100644
--- a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
@@ -147,13 +147,5 @@ export default {
</template>
</gl-sprintf>
</span>
-
- <div
- class="diff-stats-additions-deletions-collapsed gl-float-right gl-display-none"
- data-testid="diff-stats-additions-deletions-collapsed"
- >
- <span class="gl-text-green-600 gl-font-weight-bold">+{{ added }}</span>
- <span class="gl-text-red-500 gl-font-weight-bold">-{{ deleted }}</span>
- </div>
</div>
</template>
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/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index d14d8c9b92e..1510d2f357a 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -38,7 +38,7 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
- <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
+ <gl-alert v-if="showAlert" v-bind="$attrs" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>
</div>
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..1a3220d8db9 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,
@@ -70,6 +75,7 @@ export default {
computed: {
selected: {
set(value) {
+ this.$emit('input', value);
this.selectedValue = value;
this.selectedText =
value === null ? null : this.items.find((item) => item.value === value).text;
@@ -155,6 +161,7 @@ export default {
},
onReset() {
this.selected = null;
+ this.$emit('input', null);
},
onBottomReached() {
this.fetchEntities(this.page + 1);
@@ -176,6 +183,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/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 680f229d5e8..18f9d26a13d 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
+import { Mousetrap, addStopCallback } from '~/lib/mousetrap';
import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import Item from './item.vue';
@@ -10,8 +10,6 @@ import Item from './item.vue';
export const MAX_FILE_FINDER_RESULTS = 40;
export const FILE_FINDER_ROW_HEIGHT = 55;
-const originalStopCallback = Mousetrap.prototype.stopCallback;
-
export default {
components: {
GlIcon,
@@ -140,7 +138,7 @@ export default {
this.toggle(!this.visible);
});
- Mousetrap.prototype.stopCallback = function customStopCallback(e, el, combo) {
+ addStopCallback(function fileFinderStopCallback(e, el, combo) {
if (
(combo === 't' && el.classList.contains('dropdown-input-field')) ||
el.classList.contains('inputarea')
@@ -150,8 +148,8 @@ export default {
return false;
}
- return originalStopCallback.call(this, e, el, combo);
- };
+ return undefined;
+ });
},
methods: {
toggle(visible) {
@@ -221,7 +219,12 @@ export default {
</script>
<template>
- <div v-if="visible" class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div
+ v-if="visible"
+ data-testid="overlay"
+ class="file-finder-overlay"
+ @mousedown.self="toggle(false)"
+ >
<div class="dropdown-menu diff-file-changes file-finder show">
<div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
@@ -231,6 +234,7 @@ export default {
type="search"
class="dropdown-input-field"
autocomplete="off"
+ data-testid="search-input"
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
@@ -241,6 +245,7 @@ export default {
/>
<gl-icon
name="close"
+ data-testid="clear-search-input"
class="dropdown-input-clear"
role="button"
:aria-label="__('Clear search input')"
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index dfeb12d5cf5..721f87ff4d6 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -168,7 +168,7 @@ export default {
.file-row {
display: flex;
align-items: center;
- height: 32px;
+ height: var(--file-row-height, 32px);
padding: 4px 8px;
margin-left: -8px;
margin-right: -8px;
diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue
index 5afb2408c7e..b436872e463 100644
--- a/app/assets/javascripts/vue_shared/components/file_row_header.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue
@@ -15,7 +15,7 @@ export default {
</script>
<template>
- <div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path">
+ <div class="file-row-header bg-white sticky-top gl-px-2 js-file-row-header" :title="path">
<gl-truncate :text="path" position="middle" class="bold" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 34f64dddc41..88062bf245f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -12,7 +12,7 @@ import {
import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { SORT_DIRECTION } from './constants';
@@ -99,6 +99,11 @@ export default {
required: false,
default: false,
},
+ termsAsTokens: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -356,7 +361,9 @@ export default {
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
+ :search-text-option-label="__('Search for this text')"
:show-friendly-text="showFriendlyText"
+ :terms-as-tokens="termsAsTokens"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
index 8a6053b7001..f3d46de3437 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
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/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
index 741395b3193..fff8a95c193 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
index c8aeac75645..63ffded9e8e 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
@@ -1,10 +1,10 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { ITEM_TYPE } from '~/groups/constants';
import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql';
@@ -43,7 +43,7 @@ export default {
return this.config.defaultContacts || OPTIONS_NONE_ANY;
},
namespace() {
- return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
index ff0571031b5..126066fbbbe 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
@@ -1,10 +1,10 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { ITEM_TYPE } from '~/groups/constants';
import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql';
@@ -43,7 +43,7 @@ export default {
return this.config.defaultOrganizations || OPTIONS_NONE_ANY;
},
namespace() {
- return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 9c30ec67d5a..c69a2927ec9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 9449e071a0d..6a7dd6131e2 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index b9ee4d51db1..81b8a6c78fc 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
index 6d681aab3ca..a251035b683 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
index 28e65c1185f..c294c23abfc 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { compact } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
index 26c50345c19..f22a17b4603 100644
--- a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
+++ b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
@@ -1,8 +1,7 @@
-<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
-<template functional>
- <footer class="form-actions d-flex justify-content-between">
- <div><slot name="prepend"></slot></div>
- <div><slot></slot></div>
- <div><slot name="append"></slot></div>
+<template>
+ <footer class="gl-mt-5 footer-block">
+ <slot name="prepend"></slot>
+ <slot></slot>
+ <slot name="append"></slot>
</footer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 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/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
index 9a88ab44f3d..e3eacf4495d 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
@@ -1,5 +1,4 @@
-import { issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import blockingIssuesQuery from './graphql/blocking_issues.query.graphql';
import blockingEpicsQuery from './graphql/blocking_epics.query.graphql';
@@ -7,7 +6,7 @@ export const blockingIssuablesQueries = {
[TYPE_ISSUE]: {
query: blockingIssuesQuery,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
query: blockingEpicsQuery,
},
};
diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
index f5b4870d59f..7bea4409c03 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
@@ -1,9 +1,8 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
-import { issuableTypes } from '~/boards/constants';
import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
import { blockingIssuablesQueries } from './constants';
@@ -12,12 +11,12 @@ export default {
i18n: {
issuableType: {
[TYPE_ISSUE]: __('issue'),
- [issuableTypes.epic]: __('epic'),
+ [TYPE_EPIC]: __('epic'),
},
},
graphQLIdType: {
[TYPE_ISSUE]: TYPENAME_ISSUE,
- [issuableTypes.epic]: TYPENAME_EPIC,
+ [TYPE_EPIC]: TYPENAME_EPIC,
},
referenceFormatter: {
[TYPE_ISSUE]: (r) => r.split('/')[1],
@@ -43,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, issuableTypes.epic].includes(value);
+ return [TYPE_ISSUE, TYPE_EPIC].includes(value);
},
},
},
@@ -88,7 +87,7 @@ export default {
},
computed: {
isEpic() {
- return this.issuableType === issuableTypes.epic;
+ return this.issuableType === TYPE_EPIC;
},
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
index bc6b5d3176f..0f8ff5291a4 100644
--- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlListbox } from '@gitlab/ui';
+import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
const MIN_ITEMS_COUNT_FOR_SEARCHING = 10;
@@ -10,9 +10,9 @@ export default {
},
components: {
GlFormGroup,
- GlListbox,
+ GlCollapsibleListbox,
},
- model: GlListbox.model,
+ model: GlCollapsibleListbox.model,
props: {
label: {
type: String,
@@ -39,7 +39,7 @@ export default {
default: null,
},
items: {
- type: GlListbox.props.items.type,
+ type: GlCollapsibleListbox.props.items.type,
required: true,
},
disabled: {
@@ -116,7 +116,7 @@ export default {
<template>
<component :is="wrapperComponent" :label="label" :description="description" v-bind="$attrs">
- <gl-listbox
+ <gl-collapsible-listbox
:selected="selected"
:toggle-text="toggleText"
:items="filteredItems"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
new file mode 100644
index 00000000000..897ca2f84d2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { updateText } from '~/lib/utils/text_markdown';
+import savedRepliesQuery from './saved_replies.query.graphql';
+
+export default {
+ apollo: {
+ savedReplies: {
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ skip() {
+ return !this.shouldFetchCommentTemplates;
+ },
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlButton,
+ GlTooltip,
+ },
+ props: {
+ newCommentTemplatePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ shouldFetchCommentTemplates: false,
+ savedReplies: [],
+ commentTemplateSearch: '',
+ loadingSavedReplies: false,
+ };
+ },
+ computed: {
+ filteredSavedReplies() {
+ 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 }));
+ },
+ },
+ mounted() {
+ this.tooltipTarget = this.$el.querySelector('.js-comment-template-toggle');
+ },
+ methods: {
+ fetchCommentTemplates() {
+ this.shouldFetchCommentTemplates = true;
+ },
+ 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();
+ });
+ }
+ },
+ },
+ popperOptions: { strategy: 'fixed' },
+};
+</script>
+
+<template>
+ <span>
+ <gl-collapsible-listbox
+ :header-text="__('Insert comment template')"
+ :items="filteredSavedReplies"
+ :toggle-text="__('Insert comment template')"
+ text-sr-only
+ toggle-class="js-comment-template-toggle"
+ icon="comment-lines"
+ category="tertiary"
+ placement="right"
+ searchable
+ size="small"
+ class="comment-template-dropdown"
+ :popper-options="$options.popperOptions"
+ :searching="$apollo.queries.savedReplies.loading"
+ @shown="fetchCommentTemplates"
+ @search="setCommentTemplateSearch"
+ @select="onSelect"
+ >
+ <template #list-item="{ 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>
+ </div>
+ </div>
+ </template>
+ <template #footer>
+ <div
+ 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="newCommentTemplatePath"
+ category="tertiary"
+ block
+ class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
+ >{{ __('Add a new comment template') }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-collapsible-listbox>
+ <gl-tooltip :target="() => tooltipTarget">
+ {{ __('Insert comment template') }}
+ </gl-tooltip>
+ </span>
+</template>
+
+<style>
+.comment-template-dropdown .gl-new-dropdown-panel {
+ width: 350px;
+}
+
+.comment-template-dropdown .gl-new-dropdown-item-check-icon {
+ display: none;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
new file mode 100644
index 00000000000..e88c7f75745
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { create } from '~/drawio/markdown_field_editor_facade';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ uploadsPath: {
+ type: String,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ getTextArea() {
+ return document.querySelector('.js-gfm-input');
+ },
+ launchDrawioEditor() {
+ launchDrawioEditor({
+ editorFacade: create({
+ uploadsPath: this.uploadsPath,
+ textArea: this.getTextArea(),
+ markdownPreviewPath: this.markdownPreviewPath,
+ }),
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-gl-tooltip
+ :title="__('Insert or edit diagram')"
+ :aria-label="__('Insert or edit diagram')"
+ category="tertiary"
+ icon="diagram"
+ size="small"
+ @click="launchDrawioEditor"
+ />
+</template>
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
deleted file mode 100644
index 6702a81e747..00000000000
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- size: {
- type: String,
- required: false,
- default: 'medium',
- },
- value: {
- type: String,
- required: true,
- },
- },
- computed: {
- markdownEditorSelected() {
- return this.value === 'markdown';
- },
- text() {
- return this.markdownEditorSelected ? __('View rich text') : __('View markdown');
- },
- },
-};
-</script>
-<template>
- <gl-dropdown
- category="tertiary"
- data-qa-selector="editing_mode_switcher"
- :size="size"
- :text="text"
- right
- >
- <gl-dropdown-item
- is-check-item
- :is-checked="!markdownEditorSelected"
- @click="$emit('input', 'richText')"
- ><div class="gl-font-weight-bold">{{ __('Rich text') }}</div>
- <div class="gl-text-secondary">
- {{ __('View the formatted output in real-time as you edit.') }}
- </div>
- </gl-dropdown-item>
- <gl-dropdown-item
- is-check-item
- :is-checked="markdownEditorSelected"
- @click="$emit('input', 'markdown')"
- ><div class="gl-font-weight-bold">{{ __('Markdown') }}</div>
- <div class="gl-text-secondary">
- {{ __('View and edit markdown, with the option to preview the formatted output.') }}
- </div></gl-dropdown-item
- >
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
new file mode 100644
index 00000000000..645975ca565
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ markdownEditorSelected() {
+ return this.value === 'markdown';
+ },
+ text() {
+ return this.markdownEditorSelected ? __('Switch to rich text') : __('Switch to Markdown');
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ class="btn btn-default btn-sm gl-button btn-default-tertiary"
+ data-qa-selector="editing_mode_switcher"
+ @click="$emit('input')"
+ >{{ text }}</gl-button
+ >
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/eventhub.js b/app/assets/javascripts/vue_shared/components/markdown/eventhub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/eventhub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 6f4cddbdfa2..602a83132e4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,8 +1,8 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
import { debounce, unescape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import GLForm from '~/gl_form';
import SafeHtml from '~/vue_shared/directives/safe_html';
import axios from '~/lib/utils/axios_utils';
@@ -27,6 +27,7 @@ export default {
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -62,6 +63,11 @@ export default {
required: false,
default: true,
},
+ removeBorder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
quickActionsDocsPath: {
type: String,
required: false,
@@ -132,6 +138,11 @@ export default {
required: false,
default: false,
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -240,7 +251,7 @@ export default {
immediate: true,
handler(newVal) {
if (!newVal) {
- this.showWriteTab();
+ this.hidePreview();
}
},
},
@@ -272,7 +283,7 @@ export default {
}
},
methods: {
- showPreviewTab() {
+ showPreview() {
if (this.previewMarkdown) return;
this.previewMarkdown = true;
@@ -292,7 +303,7 @@ export default {
this.renderMarkdown();
}
},
- showWriteTab() {
+ hidePreview() {
this.markdownPreview = '';
this.previewMarkdown = false;
},
@@ -344,7 +355,9 @@ export default {
<template>
<div
ref="gl-form"
- :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
+ :class="{
+ 'gl-border-none! gl-shadow-none!': removeBorder,
+ }"
class="js-vue-markdown-field md-area position-relative gfm-form"
:data-uploads-path="uploadsPath"
>
@@ -355,10 +368,16 @@ export default {
:enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :drawio-enabled="drawioEnabled"
+ data-testid="markdownHeader"
:restricted-tool-bar-items="restrictedToolBarItems"
- @preview-markdown="showPreviewTab"
- @write-markdown="showWriteTab"
+ :show-content-editor-switcher="showContentEditorSwitcher"
+ @showPreview="showPreview"
+ @hidePreview="hidePreview"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
+ @enableContentEditor="$emit('enableContentEditor')"
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
@@ -375,8 +394,6 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
:show-comment-tool-bar="showCommentToolBar"
- :show-content-editor-switcher="showContentEditorSwitcher"
- @enableContentEditor="$emit('enableContentEditor')"
/>
</div>
</div>
@@ -384,7 +401,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="js-vue-md-preview md-preview-holder"
+ class="js-vue-md-preview md-preview-holder gl-px-5"
>
<suggestions
v-if="hasSuggestion"
@@ -401,13 +418,13 @@ export default {
v-show="previewMarkdown"
ref="markdown-preview"
v-safe-html:[$options.safeHtmlConfig]="markdownPreview"
- class="js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md md-preview-holder gl-px-5"
></div>
</template>
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
- class="referenced-commands"
+ class="referenced-commands gl-mx-2 gl-mb-2 gl-px-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
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 e83441e59a2..8802f364665 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
+import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
import {
keysFor,
@@ -10,23 +10,37 @@ import {
INDENT_LINE,
OUTDENT_LINE,
} from '~/behaviors/shortcuts/keybindings';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getModifierKey } from '~/constants';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
+import { updateText } from '~/lib/utils/text_markdown';
import ToolbarButton from './toolbar_button.vue';
+import DrawioToolbarButton from './drawio_toolbar_button.vue';
+import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
+import EditorModeSwitcher from './editor_mode_switcher.vue';
export default {
components: {
ToolbarButton,
GlPopover,
GlButton,
- GlTabs,
- GlTab,
+ DrawioToolbarButton,
+ CommentTemplatesDropdown,
+ AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'),
+ EditorModeSwitcher,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ newCommentTemplatePath: {
+ default: null,
+ },
+ editorAiActions: { default: () => [] },
+ },
props: {
previewMarkdown: {
type: Boolean,
@@ -62,6 +76,26 @@ export default {
required: false,
default: () => [],
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showContentEditorSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -92,6 +126,9 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
+ showEditorModeSwitcher() {
+ return this.showContentEditorSwitcher && !this.previewMarkdown;
+ },
},
watch: {
showSuggestPopover() {
@@ -99,14 +136,14 @@ export default {
},
},
mounted() {
- $(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
+ $(document).on('markdown-preview:show.vue', this.showMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.hideMarkdownPreview);
this.updateSuggestPopoverVisibility();
},
beforeDestroy() {
- $(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
+ $(document).off('markdown-preview:show.vue', this.showMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.hideMarkdownPreview);
},
methods: {
async updateSuggestPopoverVisibility() {
@@ -120,19 +157,15 @@ export default {
(form.find('.js-vue-markdown-field').length && $(this.$el).closest('form')[0] === form[0])
);
},
-
- previewMarkdownTab(event, form) {
- if (event.target.blur) event.target.blur();
+ showMarkdownPreview(_, form) {
if (!this.isValid(form)) return;
- this.$emit('preview-markdown');
+ this.$emit('showPreview');
},
-
- writeMarkdownTab(event, form) {
- if (event.target.blur) event.target.blur();
+ hideMarkdownPreview(_, form) {
if (!this.isValid(form)) return;
- this.$emit('write-markdown');
+ this.$emit('hidePreview');
},
handleSuggestDismissed() {
this.$emit('handleSuggestDismissed');
@@ -163,6 +196,28 @@ export default {
$gfmForm.find('.div-dropzone').click();
$gfmTextarea.focus();
},
+ insertIntoTextarea(text) {
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+ if (textArea) {
+ const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated using AI')}_`;
+ updateText({
+ textArea,
+ tag: generatedByText,
+ cursorOffset: 0,
+ wrap: false,
+ });
+ }
+ },
+ handleEditorModeChanged() {
+ this.$emit('enableContentEditor');
+ },
+ switchPreview() {
+ if (this.previewMarkdown) {
+ this.hideMarkdownPreview();
+ } else {
+ this.showMarkdownPreview();
+ }
+ },
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@@ -173,211 +228,241 @@ export default {
outdent: keysFor(OUTDENT_LINE),
},
i18n: {
- writeTabTitle: __('Write'),
- previewTabTitle: __('Preview'),
+ preview: __('Preview'),
+ hidePreview: __('Continue editing'),
},
};
</script>
<template>
- <div class="md-header">
- <gl-tabs content-class="gl-display-none">
- <gl-tab
- title-link-class="gl-py-4 gl-px-3 js-md-write-button"
- :title="$options.i18n.writeTabTitle"
- :active="!previewMarkdown"
- data-testid="write-tab"
- @click="writeMarkdownTab($event)"
- />
- <gl-tab
- v-if="enablePreview"
- title-link-class="gl-py-4 gl-px-3 js-md-preview-button"
- :title="$options.i18n.previewTabTitle"
- :active="previewMarkdown"
- data-testid="preview-tab"
- @click="previewMarkdownTab($event)"
- />
-
- <template #tabs-end>
- <div
- data-testid="md-header-toolbar"
- :class="{ 'gl-display-none!': previewMarkdown }"
- class="md-header-toolbar gl-ml-auto gl-py-2 gl-justify-content-center"
- >
- <template v-if="canSuggest">
- <toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
- :prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
- />
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="confirm"
- category="primary"
- size="small"
- data-qa-selector="dismiss_suggestion_popover_button"
- @click="handleSuggestDismissed"
- >
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
- <toolbar-button
- tag="**"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.bold"
- icon="bold"
- />
+ <div class="md-header gl-bg-gray-50 gl-px-2 gl-rounded-base gl-mx-2 gl-mt-2">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ :class="{
+ 'gl-justify-content-end': previewMarkdown,
+ 'gl-justify-content-space-between': !previewMarkdown,
+ }"
+ >
+ <div
+ data-testid="md-header-toolbar"
+ class="md-header-toolbar gl-display-flex gl-py-2 gl-flex-wrap"
+ :class="{ 'gl-display-none!': previewMarkdown }"
+ >
+ <template v-if="canSuggest">
<toolbar-button
- tag="_"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.italic"
- icon="italic"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('strikethrough')"
- tag="~~"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.strikethrough"
- icon="strikethrough"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('quote')"
- :prepend="true"
- :tag="tag"
- :button-title="__('Insert a quote')"
- icon="quote"
- @click="handleQuote"
- />
- <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
- <toolbar-button
- tag="[{text}](url)"
- tag-select="url"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.link"
- icon="link"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('bullet-list')"
+ ref="suggestButton"
+ :tag="mdSuggestion"
:prepend="true"
- tag="- "
- :button-title="__('Add a bullet list')"
- icon="list-bulleted"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('numbered-list')"
- :prepend="true"
- tag="1. "
- :button-title="__('Add a numbered list')"
- icon="list-numbered"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('task-list')"
- :prepend="true"
- tag="- [ ] "
- :button-title="__('Add a checklist')"
- icon="list-task"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('indent')"
- class="gl-display-none"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.indent"
- command="indentLines"
- icon="list-indent"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
+ @click="handleSuggestDismissed"
/>
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('outdent')"
- class="gl-display-none"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.outdent"
- command="outdentLines"
- icon="list-outdent"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('collapsible-section')"
- :tag="mdCollapsibleSection"
- :prepend="true"
- tag-select="Click to expand"
- :button-title="__('Add a collapsible section')"
- icon="details-block"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('table')"
- :tag="mdTable"
- :prepend="true"
- :button-title="__('Add a table')"
- icon="table"
- />
- <gl-button
- v-if="!restrictedToolBarItems.includes('attach-file')"
- v-gl-tooltip
- :title="__('Attach a file or image')"
- data-testid="button-attach-file"
- category="tertiary"
- icon="paperclip"
- @click="handleAttachFile"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('full-screen')"
- class="js-zen-enter"
- :prepend="true"
- :button-title="__('Go full screen')"
- icon="maximize"
- />
- </div>
- </template>
- </gl-tabs>
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
+ >
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="confirm"
+ category="primary"
+ size="small"
+ data-qa-selector="dismiss_suggestion_popover_button"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
+ <ai-actions-dropdown
+ v-if="editorAiActions.length"
+ :actions="editorAiActions"
+ @input="insertIntoTextarea"
+ />
+ <toolbar-button
+ tag="**"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.bold"
+ icon="bold"
+ />
+ <toolbar-button
+ tag="_"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.italic"
+ icon="italic"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('strikethrough')"
+ tag="~~"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.strikethrough"
+ icon="strikethrough"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('quote')"
+ :prepend="true"
+ :tag="tag"
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ @click="handleQuote"
+ />
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
+ <toolbar-button
+ tag="[{text}](url)"
+ tag-select="url"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.link"
+ icon="link"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('bullet-list')"
+ :prepend="true"
+ tag="- "
+ :button-title="__('Add a bullet list')"
+ icon="list-bulleted"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('numbered-list')"
+ :prepend="true"
+ tag="1. "
+ :button-title="__('Add a numbered list')"
+ icon="list-numbered"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('task-list')"
+ :prepend="true"
+ tag="- [ ] "
+ :button-title="__('Add a checklist')"
+ icon="list-task"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('indent')"
+ class="gl-display-none"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.indent"
+ command="indentLines"
+ icon="list-indent"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('outdent')"
+ class="gl-display-none"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.outdent"
+ command="outdentLines"
+ icon="list-outdent"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('collapsible-section')"
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('table')"
+ :tag="mdTable"
+ :prepend="true"
+ :button-title="__('Add a table')"
+ icon="table"
+ />
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('attach-file')"
+ v-gl-tooltip
+ :aria-label="__('Attach a file or image')"
+ :title="__('Attach a file or image')"
+ class="gl-mr-2"
+ data-testid="button-attach-file"
+ category="tertiary"
+ icon="paperclip"
+ size="small"
+ @click="handleAttachFile"
+ />
+ <drawio-toolbar-button
+ v-if="drawioEnabled"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ />
+ <comment-templates-dropdown
+ v-if="newCommentTemplatePath && glFeatures.savedReplies"
+ :new-comment-template-path="newCommentTemplatePath"
+ />
+ </div>
+ <div class="switch-preview gl-py-2 gl-display-flex gl-align-items-center gl-ml-auto">
+ <editor-mode-switcher
+ v-if="showEditorModeSwitcher"
+ size="small"
+ class="gl-mr-2"
+ value="markdown"
+ @input="handleEditorModeChanged"
+ />
+ <gl-button
+ v-if="enablePreview"
+ data-testid="preview-toggle"
+ value="preview"
+ :label="$options.i18n.previewTabTitle"
+ class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal!"
+ size="small"
+ category="tertiary"
+ @click="switchPreview"
+ >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button
+ >
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('full-screen')"
+ v-gl-tooltip
+ :class="{ 'gl-display-none!': previewMarkdown }"
+ class="js-zen-enter gl-ml-2"
+ category="tertiary"
+ icon="maximize"
+ size="small"
+ :title="__('Go full screen')"
+ :prepend="true"
+ :aria-label="__('Go full screen')"
+ />
+ </div>
+ </div>
</div>
</template>
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 7e6b0e4a63b..d9d4056e997 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -1,8 +1,16 @@
<script>
+import Autosize from 'autosize';
import axios from '~/lib/utils/axios_utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants';
+import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave';
+import { setUrlParams, joinPaths } from '~/lib/utils/url_utility';
+import {
+ EDITING_MODE_MARKDOWN_FIELD,
+ EDITING_MODE_CONTENT_EDITOR,
+ CLEAR_AUTOSAVE_ENTRY_EVENT,
+} from '../../constants';
import MarkdownField from './field.vue';
+import eventHub from './eventhub';
export default {
components: {
@@ -18,19 +26,15 @@ export default {
type: String,
required: true,
},
- renderMarkdownPath: {
- type: String,
- required: true,
+ setFacade: {
+ type: Function,
+ required: false,
+ default: null,
},
- markdownDocsPath: {
+ renderMarkdownPath: {
type: String,
required: true,
},
- quickActionsDocsPath: {
- type: String,
- required: false,
- default: '',
- },
uploadsPath: {
type: String,
required: false,
@@ -41,7 +45,17 @@ export default {
required: false,
default: true,
},
- enablePreview: {
+ formFieldProps: {
+ type: Object,
+ required: true,
+ validator: (prop) => prop.id && prop.name,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutocomplete: {
type: Boolean,
required: false,
default: true,
@@ -51,27 +65,32 @@ export default {
required: false,
default: () => ({}),
},
- enableAutocomplete: {
+ supportsQuickActions: {
type: Boolean,
required: false,
- default: true,
+ default: false,
},
- formFieldProps: {
- type: Object,
- required: true,
- validator: (prop) => prop.id && prop.name,
+ autosaveKey: {
+ type: String,
+ required: false,
+ default: null,
},
- autofocus: {
- type: Boolean,
+ markdownDocsPath: {
+ type: String,
required: false,
- default: false,
+ default: '',
},
- supportsQuickActions: {
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
type: Boolean,
required: false,
default: false,
},
- useBottomToolbar: {
+ disabled: {
type: Boolean,
required: false,
default: false,
@@ -79,6 +98,7 @@ export default {
},
data() {
return {
+ markdown: this.value || (this.autosaveKey ? getDraft(this.autosaveKey) : '') || '',
editingMode: EDITING_MODE_MARKDOWN_FIELD,
autofocused: false,
};
@@ -92,24 +112,71 @@ export default {
return this.autofocus && !this.autofocused ? 'end' : false;
},
},
+ watch: {
+ value(val) {
+ this.markdown = val;
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
+ },
mounted() {
this.autofocusTextarea();
+
+ this.$emit('input', this.markdown);
+ this.saveDraft();
+
+ this.setFacade?.({
+ getValue: () => this.getValue(),
+ setValue: (val) => this.setValue(val),
+ });
+
+ eventHub.$on(CLEAR_AUTOSAVE_ENTRY_EVENT, this.clearDraft);
+ },
+ beforeDestroy() {
+ eventHub.$off(CLEAR_AUTOSAVE_ENTRY_EVENT, this.clearDraft);
},
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);
+
+ this.saveDraft();
},
updateMarkdownFromMarkdownField({ target }) {
+ this.markdown = target.value;
this.$emit('input', target.value);
+
+ this.saveDraft();
+ this.autosizeTextarea();
},
renderMarkdown(markdown) {
- return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body);
+ const url = setUrlParams(
+ { render_quick_actions: this.supportsQuickActions },
+ joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath),
+ );
+ return axios.post(url, { text: markdown }).then(({ data }) => data.body);
},
onEditingModeChange(editingMode) {
this.editingMode = editingMode;
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);
@@ -126,40 +193,67 @@ export default {
setEditorAsAutofocused() {
this.autofocused = true;
},
+ saveDraft() {
+ if (!this.autosaveKey) return;
+ if (this.markdown) updateDraft(this.autosaveKey, this.markdown);
+ else clearDraft(this.autosaveKey);
+ },
+ clearDraft(key) {
+ if (!this.autosaveKey || key !== this.autosaveKey) return;
+ clearDraft(this.autosaveKey);
+ },
+ togglePreview(value) {
+ if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ this.$refs.markdownField.previewMarkdown = value;
+ }
+ },
+ autosizeTextarea() {
+ if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ this.$nextTick(() => {
+ Autosize.update(this.$refs.textarea);
+ });
+ }
+ },
},
};
</script>
<template>
- <div>
+ <div class="md-area gl-px-0! gl-overflow-hidden">
<local-storage-sync
- v-model="editingMode"
- storage-key="gl-wiki-content-editor-enabled"
+ :value="editingMode"
+ as-string
+ storage-key="gl-markdown-editor-mode"
@input="onEditingModeRestored"
/>
<markdown-field
v-if="!isContentEditorActive"
+ ref="markdownField"
+ v-bind="$attrs"
+ data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
can-attach-file
+ :textarea-value="markdown"
+ :uploads-path="uploadsPath"
:enable-autocomplete="enableAutocomplete"
- :textarea-value="value"
+ :autocomplete-data-sources="autocompleteDataSources"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- :autocomplete-data-sources="autocompleteDataSources"
- :uploads-path="uploadsPath"
- :enable-preview="enablePreview"
- show-content-editor-switcher
- class="bordered-box"
+ :show-content-editor-switcher="enableContentEditor"
+ :drawio-enabled="drawioEnabled"
+ :remove-border="true"
@enableContentEditor="onEditingModeChange('contentEditor')"
+ @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<textarea
v-bind="formFieldProps"
ref="textarea"
- :value="value"
- class="note-textarea js-gfm-input js-autosize markdown-area"
+ :value="markdown"
+ class="note-textarea js-gfm-input markdown-area"
dir="auto"
:data-supports-quick-actions="supportsQuickActions"
- data-qa-selector="markdown_editor_form_field"
+ :data-qa-selector="formFieldProps['data-qa-selector'] || 'markdown_editor_form_field'"
+ :disabled="disabled"
@input="updateMarkdownFromMarkdownField"
@keydown="$emit('keydown', $event)"
>
@@ -168,11 +262,17 @@ export default {
</markdown-field>
<div v-else>
<content-editor
+ ref="contentEditor"
:render-markdown="renderMarkdown"
:uploads-path="uploadsPath"
- :markdown="value"
+ :markdown="markdown"
+ :quick-actions-docs-path="quickActionsDocsPath"
:autofocus="contentEditorAutofocused"
- :use-bottom-toolbar="useBottomToolbar"
+ :placeholder="formFieldProps.placeholder"
+ :drawio-enabled="drawioEnabled"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
@@ -180,7 +280,7 @@ export default {
/>
<input
v-bind="formFieldProps"
- :value="value"
+ :value="markdown"
data-qa-selector="markdown_editor_form_field"
type="hidden"
/>
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..ac4f06a665d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -0,0 +1,116 @@
+import Vue from 'vue';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants';
+import MarkdownEditor from './markdown_editor.vue';
+import eventHub from './eventhub';
+
+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);
+}
+
+function mountAutosaveClearOnSubmit(autosaveKey) {
+ const resetAutosaveButtons = document.querySelectorAll('.js-reset-autosave');
+ if (resetAutosaveButtons.length === 0) {
+ return;
+ }
+
+ for (const resetAutosaveButton of resetAutosaveButtons) {
+ resetAutosaveButton.addEventListener('click', () => {
+ eventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, autosaveKey);
+ });
+ }
+}
+
+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);
+ const autosaveKey = `autosave/${document.location.pathname}/${searchTerm}/description`;
+
+ // 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,
+ enableAutocomplete: true,
+ autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
+ supportsQuickActions: true,
+ },
+ });
+ },
+ });
+
+ mountAutosaveClearOnSubmit(autosaveKey);
+
+ return facade;
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql b/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql
new file mode 100644
index 00000000000..9b9d4c89254
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql
@@ -0,0 +1,12 @@
+query getSavedReplies {
+ currentUser {
+ id
+ savedReplies {
+ nodes {
+ id
+ name
+ content
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index c307601e670..6d1cadf15be 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -1,7 +1,7 @@
<script>
import Vue from 'vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.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/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index e8be242f660..4733afb7504 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
-import EditorModeDropdown from './editor_mode_dropdown.vue';
export default {
components: {
@@ -9,7 +8,6 @@ export default {
GlLoadingIcon,
GlSprintf,
GlIcon,
- EditorModeDropdown,
},
props: {
markdownDocsPath: {
@@ -31,30 +29,21 @@ export default {
required: false,
default: true,
},
- showContentEditorSwitcher: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
},
- methods: {
- handleEditorModeChanged(mode) {
- if (mode === 'richText') {
- this.$emit('enableContentEditor');
- }
- },
- },
};
</script>
<template>
- <div v-if="showCommentToolBar" class="comment-toolbar clearfix">
- <div class="toolbar-text">
+ <div
+ v-if="showCommentToolBar"
+ class="comment-toolbar gl-mx-2 gl-mb-2 gl-px-4 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base clearfix"
+ >
+ <div class="toolbar-text gl-font-sm">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
:message="
@@ -62,7 +51,9 @@ export default {
"
>
<template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</template>
@@ -75,18 +66,22 @@ export default {
"
>
<template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
<template #keyboard="{ content }">
<kbd>{{ content }}</kbd>
</template>
<template #quickActionsDocsLink="{ content }">
- <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</template>
</div>
- <span v-if="canAttachFile" class="uploading-container gl-line-height-32">
+ <span v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32">
<span class="uploading-progress-container hide">
<gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
@@ -111,7 +106,7 @@ export default {
<gl-button
variant="link"
category="primary"
- class="retry-uploading-link gl-vertical-align-baseline"
+ class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!"
>
{{ content }}
</gl-button>
@@ -120,7 +115,7 @@ export default {
<gl-button
variant="link"
category="primary"
- class="markdown-selector attach-new-file gl-vertical-align-baseline"
+ class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!"
>
{{ content }}
</gl-button>
@@ -130,17 +125,10 @@ export default {
<gl-button
variant="link"
category="primary"
- class="button-cancel-uploading-files gl-vertical-align-baseline hide"
+ class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!"
>
{{ __('Cancel') }}
</gl-button>
</span>
- <editor-mode-dropdown
- v-if="showContentEditorSwitcher"
- size="small"
- class="gl-float-right gl-line-height-28 gl-display-block"
- value="markdown"
- @input="handleEditorModeChanged"
- />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 5ca21522d33..636c89c99d4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -92,7 +92,8 @@ export default {
:icon="icon"
type="button"
category="tertiary"
- class="js-md"
+ size="small"
+ class="js-md gl-mr-3"
data-container="body"
@click="$emit('click', $event)"
/>
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
index 1c4e8d332a9..6f91463365b 100644
--- a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 41c92fdba4f..d4f50e347cb 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -61,11 +61,6 @@ export default {
required: false,
default: 'primary',
},
- size: {
- type: String,
- required: false,
- default: 'medium',
- },
},
computed: {
modalDomId() {
@@ -108,9 +103,6 @@ export default {
:title="title"
:aria-label="title"
:category="category"
- :size="size"
icon="copy-to-clipboard"
- >
- <slot></slot>
- </gl-button>
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
index b079181bd10..e09a6e2e811 100644
--- a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
@@ -6,7 +6,7 @@ import {
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 78a7fed6293..9ea04553536 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -2,7 +2,7 @@
import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-const NoteableTypeText = {
+const noteableTypeText = {
Issue: __('issue'),
Epic: __('epic'),
MergeRequest: __('merge request'),
@@ -53,7 +53,7 @@ export default {
return this.isConfidential && this.isLocked;
},
noteableTypeText() {
- return NoteableTypeText[this.noteableType];
+ return noteableTypeText[this.noteableType];
},
confidentialContextText() {
return sprintf(__('This is a confidential %{noteableTypeText}.'), {
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/pagination/constants.js b/app/assets/javascripts/vue_shared/components/pagination/constants.js
index 748ad178c70..f8a6d37dea1 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/constants.js
+++ b/app/assets/javascripts/vue_shared/components/pagination/constants.js
@@ -1,8 +1,5 @@
import { s__ } from '~/locale';
-export const PAGINATION_UI_BUTTON_LIMIT = 4;
-export const UI_LIMIT = 6;
-export const SPREAD = '...';
export const PREV = s__('Pagination|Prev');
export const NEXT = s__('Pagination|Next');
export const FIRST = s__('Pagination|« First');
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index f65cc8bf2f3..3a279b93774 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -56,5 +56,6 @@ export default {
:src="projectAvatarUrl"
:alt="avatarAlt"
:size="size"
+ :fallback-on-error="true"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 16bc8070dc1..bdc8ffee90a 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -52,7 +52,7 @@ export default {
<div
class="gl-display-flex gl-align-items-center gl-flex-wrap project-namespace-name-container"
>
- <gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
+ <gl-icon v-if="selected" data-testid="selected-icon" name="mobile-issue-close" />
<project-avatar
:project-id="project.id"
:project-avatar-url="projectAvatarUrl"
@@ -61,16 +61,18 @@ export default {
/>
<div
v-if="truncatedNamespace"
+ data-testid="project-namespace"
:title="projectNameWithNamespace"
- class="text-secondary text-truncate js-project-namespace"
+ class="text-secondary text-truncate"
>
{{ truncatedNamespace }}
<span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
</div>
<div
v-safe-html="highlightedProjectName"
+ data-testid="project-name"
:title="project.name"
- class="js-project-name text-truncate"
+ class="text-truncate"
></div>
</div>
</gl-button>
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..11aa7b91745
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -0,0 +1,41 @@
+<script>
+import ProjectsListItem from './projects_list_item.vue';
+
+export default {
+ components: { ProjectsListItem },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * topics: string[];
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ * descriptionHtml: string;
+ * updatedAt: string;
+ * }[]
+ */
+ 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..266cce29e50
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -0,0 +1,257 @@
+<script>
+import {
+ GlAvatarLabeled,
+ GlIcon,
+ GlLink,
+ GlBadge,
+ GlTooltipDirective,
+ GlPopover,
+ GlSprintf,
+} from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
+
+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';
+import { truncate } from '~/lib/utils/text_utility';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+const MAX_TOPICS_TO_SHOW = 3;
+const MAX_TOPIC_TITLE_LENGTH = 15;
+
+export default {
+ i18n: {
+ stars: __('Stars'),
+ forks: __('Forks'),
+ issues: __('Issues'),
+ archived: __('Archived'),
+ topics: __('Topics'),
+ topicsPopoverTargetText: __('+ %{count} more'),
+ moreTopics: __('More topics'),
+ updated: __('Updated'),
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlLink,
+ GlBadge,
+ GlPopover,
+ GlSprintf,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * topics: string[];
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ * descriptionHtml: string;
+ * updatedAt: string;
+ * }
+ */
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ topicsPopoverTarget: uniqueId('project-topics-popover-'),
+ };
+ },
+ 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;
+ },
+ hasTopics() {
+ return this.project.topics.length;
+ },
+ visibleTopics() {
+ return this.project.topics.slice(0, MAX_TOPICS_TO_SHOW);
+ },
+ popoverTopics() {
+ return this.project.topics.slice(MAX_TOPICS_TO_SHOW);
+ },
+ },
+ methods: {
+ numberToMetricPrefix,
+ topicPath(topic) {
+ return `/explore/projects/topics/${encodeURIComponent(topic)}`;
+ },
+ topicTitle(topic) {
+ return truncate(topic, MAX_TOPIC_TITLE_LENGTH);
+ },
+ topicTooltipTitle(topic) {
+ // Matches conditional in app/assets/javascripts/lib/utils/text_utility.js#L88
+ if (topic.length - 1 > MAX_TOPIC_TITLE_LENGTH) {
+ return topic;
+ }
+
+ return null;
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="projects-list-item 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>
+ <div
+ v-if="project.descriptionHtml"
+ v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
+ class="gl-font-sm gl-overflow-hidden gl-line-height-20 description"
+ data-testid="project-description"
+ ></div>
+ <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+ <div
+ class="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"
+ >
+ <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
+ <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ <template v-if="popoverTopics.length">
+ <div
+ :id="topicsPopoverTarget"
+ class="gl-p-2 gl-text-secondary"
+ role="button"
+ tabindex="0"
+ >
+ <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
+ <template #count>{{ popoverTopics.length }}</template>
+ </gl-sprintf>
+ </div>
+ <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
+ <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
+ <div
+ v-for="topic in popoverTopics"
+ :key="topic"
+ class="gl-p-2 gl-display-inline-block"
+ >
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ </div>
+ </gl-popover>
+ </template>
+ </div>
+ </div>
+ </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-pl-10 gl-md-pl-0 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 class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3">
+ <span>{{ $options.i18n.updated }}</span>
+ <time-ago-tooltip :time="project.updatedAt" />
+ </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/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index c990baaa2f3..730e9e1c6cc 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -110,10 +110,14 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100">
+ <div
+ class="gl-md-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100"
+ >
+ <!-- `gl-w-full gl-md-w-15` forces fixed width needed to prevent
+ filtered component to grow beyond available width -->
<gl-filtered-search
v-model="internalFilter"
- class="gl-mr-4 gl-flex-grow-1"
+ class="gl-w-full gl-md-w-15 gl-mr-4 gl-flex-grow-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
@submit="submitSearch"
@@ -121,6 +125,10 @@ export default {
/>
<gl-sorting
data-testid="registry-sort-dropdown"
+ class="gl-mt-3 gl-md-mt-0 gl-w-full gl-md-w-auto"
+ dropdown-class="gl-w-full"
+ dropdown-toggle-class="gl-inset-border-1-gray-400!"
+ sort-direction-toggle-class="gl-inset-border-1-gray-400!"
:text="sortText"
:is-ascending="isSortAscending"
:sort-direction-tool-tip="sortDirectionData.tooltip"
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
deleted file mode 100644
index 02cb7785ef4..00000000000
--- a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import $ from 'jquery';
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
-
-export default {
- data() {
- return {
- width: 0,
- height: 0,
- };
- },
- beforeDestroy() {
- this.contentResizeHandler.off('content.resize', this.debouncedResize);
- window.removeEventListener('resize', this.debouncedResize);
- },
- created() {
- this.debouncedResize = debounceByAnimationFrame(this.onResize);
-
- // Handle when we explicictly trigger a custom resize event
- this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize);
-
- // Handle window resize
- window.addEventListener('resize', this.debouncedResize);
- },
- methods: {
- onResize() {
- // Slot dimensions
- const { clientWidth, clientHeight } = this.$refs.chartWrapper;
- this.width = clientWidth;
- this.height = clientHeight;
- },
- },
-};
-</script>
-
-<template>
- <div ref="chartWrapper">
- <slot :width="width" :height="height"> </slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index b66c89d1372..23b5af7fe5f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -1,4 +1,5 @@
import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
@@ -68,3 +69,10 @@ export const AWS_EASY_BUTTONS = [
),
},
];
+
+export const LEGACY_REGISTER_HELP_URL = helpPagePath(
+ 'architecture/blueprints/runner_tokens/index.md',
+ {
+ anchor: 'using-the-authentication-token-in-place-of-the-registration-token',
+ },
+);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 22d9b88fa41..94aa7bd2f88 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -7,14 +7,22 @@ import {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
GlResizeObserverDirective,
} from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql';
-import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants';
+import {
+ PLATFORM_DOCKER,
+ PLATFORM_KUBERNETES,
+ PLATFORM_AWS,
+ LEGACY_REGISTER_HELP_URL,
+} from './constants';
import RunnerCliInstructions from './instructions/runner_cli_instructions.vue';
import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue';
@@ -30,13 +38,16 @@ export default {
GlDropdownItem,
GlModal,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
RunnerDockerInstructions,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
modalId: {
type: String,
@@ -91,7 +102,7 @@ export default {
shown: false,
platforms: [],
selectedPlatform: null,
- showAlert: false,
+ showErrorAlert: false,
platformsButtonGroupVertical: false,
};
},
@@ -111,6 +122,14 @@ export default {
return null;
}
},
+ showDeprecationAlert() {
+ return (
+ // create_runner_workflow_for_admin
+ this.glFeatures.createRunnerWorkflowForAdmin ||
+ // create_runner_workflow_for_namespace
+ this.glFeatures.createRunnerWorkflowForNamespace
+ );
+ },
},
updated() {
// Refocus on dom changes, after loading data
@@ -136,7 +155,9 @@ export default {
// get focused when setting a `defaultPlatformName`.
// This method refocuses the expected button.
// See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open
- this.$refs[this.selectedPlatform?.name]?.[0].$el.focus();
+ this.$nextTick(() => {
+ this.$refs[this.selectedPlatform?.name]?.[0]?.$el.focus();
+ });
},
selectPlatform(platform) {
this.selectedPlatform = platform;
@@ -145,7 +166,7 @@ export default {
return this.selectedPlatform.name === platform.name;
},
toggleAlert(state) {
- this.showAlert = state;
+ this.showErrorAlert = state;
},
onPlatformsButtonResize() {
if (bp.getBreakpointSize() === 'xs') {
@@ -161,7 +182,12 @@ export default {
downloadInstallBinary: s__('Runners|Download and install binary'),
downloadLatestBinary: s__('Runners|Download latest binary'),
fetchError: s__('Runners|An error has occurred fetching instructions'),
+ deprecationAlertTitle: s__('Runners|Support for registration tokens is deprecated'),
+ deprecationAlertContent: s__(
+ "Runners|In GitLab Runner 15.6, the use of registration tokens and runner parameters in the 'register' command was deprecated. They have been replaced by authentication tokens. %{linkStart}How does this impact my current registration workflow?%{linkEnd}",
+ ),
},
+ LEGACY_REGISTER_HELP_URL,
};
</script>
<template>
@@ -174,7 +200,22 @@ export default {
v-on="$listeners"
@shown="onShown"
>
- <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ <gl-alert
+ v-if="showDeprecationAlert"
+ :title="$options.i18n.deprecationAlertTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ <gl-sprintf :message="$options.i18n.deprecationAlertContent">
+ <template #link="{ content }">
+ <gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL"
+ >{{ content }} <gl-icon name="external-link"
+ /></gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert v-if="showErrorAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
</gl-alert>
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 1925c5d4064..7b7d3d48d9e 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -2,6 +2,7 @@
import { debounce, isEmpty } from 'lodash';
import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants';
import Editor from '~/editor/source_editor';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
function initSourceEditor({ el, ...args }) {
const editor = new Editor({
@@ -10,10 +11,12 @@ function initSourceEditor({ el, ...args }) {
},
});
- return editor.createInstance({
- el,
- ...args,
- });
+ return markRaw(
+ editor.createInstance({
+ el,
+ ...args,
+ }),
+ );
}
export default {
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/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 15335ea6edc..58db1ceda95 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -141,8 +141,9 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control';
-export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
-
-// We fallback to highlighting these languages with Rouge, see the following issue for more detail:
-// https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013
-export const LEGACY_FALLBACKS = ['python'];
+/**
+ * We fallback to highlighting these languages with Rouge, see the following issues for more detail:
+ * Python: https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013
+ * HAML: https://github.com/highlightjs/highlight.js/issues/3783
+ * */
+export const LEGACY_FALLBACKS = ['python', 'haml'];
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 0d466df1b7f..7c9a1bcd8cc 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -1,8 +1,8 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
+import { DATE_TIME_FORMATS, DEFAULT_DATE_TIME_FORMAT } from '~/lib/utils/datetime_utility';
import timeagoMixin from '../mixins/timeago';
-import '~/lib/utils/datetime_utility';
/**
* Port of ruby helper time_ago_with_tooltip
@@ -15,7 +15,7 @@ export default {
mixins: [timeagoMixin],
props: {
time: {
- type: [String, Number],
+ type: [String, Number, Date],
required: true,
},
tooltipPlacement: {
@@ -28,10 +28,16 @@ export default {
required: false,
default: '',
},
+ dateTimeFormat: {
+ type: String,
+ required: false,
+ default: DEFAULT_DATE_TIME_FORMAT,
+ validator: (timeFormat) => DATE_TIME_FORMATS.includes(timeFormat),
+ },
},
computed: {
timeAgo() {
- return this.timeFormatted(this.time);
+ return this.timeFormatted(this.time, this.dateTimeFormat);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
index 09414e679bb..bda88a48e48 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
@@ -21,6 +21,11 @@ export default {
required: false,
default: 'top',
},
+ boundary: {
+ type: String,
+ required: false,
+ default: '',
+ },
truncateTarget: {
type: [String, Function],
required: false,
@@ -44,6 +49,8 @@ export default {
title: this.title,
placement: this.placement,
disabled: this.tooltipDisabled,
+ // Only set the tooltip boundary if it's truthy
+ ...(this.boundary && { boundary: this.boundary }),
};
},
},
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
new file mode 100644
index 00000000000..c3b43d40adf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
@@ -0,0 +1,9 @@
+import { __ } from '~/locale';
+
+export const SHOW_MORE = __('Show more');
+export const SHOW_LESS = __('Show less');
+export const STATES = {
+ INITIAL: 'initial',
+ TRUNCATED: 'truncated',
+ EXTENDED: 'extended',
+};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
new file mode 100644
index 00000000000..6a7ac72c31e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
@@ -0,0 +1,26 @@
+import { escape } from 'lodash';
+import TruncatedText from './truncated_text.vue';
+
+export default {
+ component: TruncatedText,
+ title: 'vue_shared/truncated_text',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { TruncatedText },
+ props: Object.keys(argTypes),
+ template: `
+ <truncated-text v-bind="$props">
+ <template v-if="${'default' in args}" v-slot>
+ <span style="white-space: pre-line;">${escape(args.default)}</span>
+ </template>
+ </truncated-text>
+ `,
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ lines: 3,
+ mobileLines: 10,
+ default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'),
+};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
new file mode 100644
index 00000000000..96fc04ec825
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlResizeObserverDirective, GlButton } from '@gitlab/ui';
+import { STATES, SHOW_MORE, SHOW_LESS } from './constants';
+
+export default {
+ name: 'TruncatedText',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ props: {
+ lines: {
+ type: Number,
+ required: false,
+ default: 3,
+ },
+ mobileLines: {
+ type: Number,
+ required: false,
+ default: 10,
+ },
+ },
+ data() {
+ return {
+ state: STATES.INITIAL,
+ };
+ },
+ computed: {
+ showTruncationToggle() {
+ return this.state !== STATES.INITIAL;
+ },
+ truncationToggleText() {
+ if (this.state === STATES.TRUNCATED) {
+ return SHOW_MORE;
+ }
+ return SHOW_LESS;
+ },
+ styleObject() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return { '--lines': this.lines, '--mobile-lines': this.mobileLines };
+ },
+ isTruncated() {
+ return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden';
+ },
+ },
+ methods: {
+ onResize({ target }) {
+ if (target.scrollHeight > target.offsetHeight) {
+ this.state = STATES.TRUNCATED;
+ } else if (this.state === STATES.TRUNCATED) {
+ this.state = STATES.INITIAL;
+ }
+ },
+ toggleTruncation() {
+ if (this.state === STATES.TRUNCATED) {
+ this.state = STATES.EXTENDED;
+ } else if (this.state === STATES.EXTENDED) {
+ this.state = STATES.TRUNCATED;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <article
+ ref="content"
+ v-gl-resize-observer="onResize"
+ :class="isTruncated"
+ :style="styleObject"
+ >
+ <slot></slot>
+ </article>
+ <gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{
+ truncationToggleText
+ }}</gl-button>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index a001b6bdf24..23fbf211d54 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -149,7 +149,7 @@ export default {
>
<slot>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
@click="openFileUpload"
>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 1a81da3eb0d..00720f27934 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -55,6 +55,11 @@ export default {
required: false,
default: '',
},
+ imgCssWrapperClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
imgSize: {
type: [Number, Object],
required: true,
@@ -89,6 +94,7 @@ export default {
<template>
<gl-avatar-link :href="linkHref" class="user-avatar-link">
<user-avatar-image
+ :class="imgCssWrapperClasses"
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
@@ -105,7 +111,7 @@ export default {
v-gl-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
- class="gl-ml-3"
+ class="gl-ml-1"
data-testid="user-avatar-link-username"
>
{{ username }}
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 167db3ce1f2..335f9ab1df4 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -60,9 +60,11 @@ export default {
methods: {
expand() {
this.isExpanded = true;
+ this.$emit('expanded');
},
collapse() {
this.isExpanded = false;
+ this.$emit('collapsed');
},
},
};
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 d06bc7b8f98..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,
@@ -10,7 +11,7 @@ import {
} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { glEmojiTag } from '~/emoji';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
import Tracking from '~/tracking';
@@ -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/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index edcfabe7da3..abd3575d020 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -11,7 +11,7 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
@@ -149,7 +149,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
searchUsersVariables() {
const variables = {
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/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 28bec63b244..3c08142e2b9 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,8 +1,7 @@
<script>
-import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui';
+import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
-import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
@@ -34,9 +33,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
- GlPopover,
ConfirmForkModal,
- UserCalloutDismisser,
},
i18n,
mixins: [glFeatureFlagsMixin()],
@@ -312,9 +309,6 @@ export default {
},
};
},
- displayVscodeWebIdeCallout() {
- return this.glFeatures.vscodeWebIde && !this.showEditButton;
- },
},
mounted() {
this.resetPreferredEditor();
@@ -340,11 +334,6 @@ export default {
this.select(KEY_WEB_IDE);
},
- dismissCalloutOnActionClicked(dismiss) {
- if (this.displayVscodeWebIdeCallout) {
- dismiss();
- }
- },
},
webIdeButtonId: 'web-ide-link',
PREFERRED_EDITOR_KEY,
@@ -352,84 +341,38 @@ export default {
</script>
<template>
- <user-callout-dismisser
- :skip-query="!displayVscodeWebIdeCallout"
- feature-name="vscode_web_ide_callout"
- >
- <template #default="{ dismiss, shouldShowCallout }">
- <div class="gl-sm-ml-3">
- <actions-button
- :id="$options.webIdeButtonId"
- :actions="actions"
- :selected-key="selection"
- :variant="isBlob ? 'confirm' : 'default'"
- :category="isBlob ? 'primary' : 'secondary'"
- :show-action-tooltip="!displayVscodeWebIdeCallout || !shouldShowCallout"
- @select="select"
- @actionClicked="dismissCalloutOnActionClicked(dismiss)"
- />
- <local-storage-sync
- :storage-key="$options.PREFERRED_EDITOR_KEY"
- :value="selection"
- as-string
- @input="select"
- />
- <gl-modal
- v-if="computedShowGitpodButton && !gitpodEnabled"
- v-model="showEnableGitpodModal"
- v-bind="enableGitpodModalProps"
- >
- <gl-sprintf :message="$options.i18n.modal.content">
- <template #link="{ content }">
- <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-modal>
- <confirm-fork-modal
- v-if="showWebIdeButton || showEditButton"
- v-model="showForkModal"
- :modal-id="forkModalId"
- :fork-path="forkPath"
- />
- <gl-popover
- v-if="displayVscodeWebIdeCallout"
- :target="$options.webIdeButtonId"
- :show="shouldShowCallout"
- :css-classes="['web-ide-promo-popover']"
- :boundary-padding="80"
- show-close-button
- triggers="manual"
- @close-button-clicked="dismiss"
- >
- <img
- :src="webIdePromoPopoverImg"
- class="web-ide-promo-popover-illustration"
- width="280"
- height="140"
- />
- <div class="gl-mx-2">
- <h5 class="gl-mt-3 gl-mb-3">{{ __('The new Web IDE') }}</h5>
- <p>
- {{
- __(
- 'VS Code in your browser. View code and make changes from the same UI as in your local IDE.',
- )
- }}
- </p>
- <gl-link
- class="gl-button btn btn-confirm block gl-mb-4 gl-mt-5"
- variant="confirm"
- category="primary"
- target="_blank"
- :href="webIdeUrl"
- block
- @click="dismissCalloutOnActionClicked(dismiss)"
- >
- {{ __('Try it out now') }}
- </gl-link>
- </div>
- </gl-popover>
- </div>
- </template>
- </user-callout-dismisser>
+ <div class="gl-sm-ml-3">
+ <actions-button
+ :id="$options.webIdeButtonId"
+ :actions="actions"
+ :selected-key="selection"
+ :variant="isBlob ? 'confirm' : 'default'"
+ :category="isBlob ? 'primary' : 'secondary'"
+ show-action-tooltip
+ @select="select"
+ />
+ <local-storage-sync
+ :storage-key="$options.PREFERRED_EDITOR_KEY"
+ :value="selection"
+ as-string
+ @input="select"
+ />
+ <gl-modal
+ v-if="computedShowGitpodButton && !gitpodEnabled"
+ v-model="showEnableGitpodModal"
+ v-bind="enableGitpodModalProps"
+ >
+ <gl-sprintf :message="$options.i18n.modal.content">
+ <template #link="{ content }">
+ <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ <confirm-fork-modal
+ v-if="showWebIdeButton || showEditButton"
+ v-model="showForkModal"
+ :modal-id="forkModalId"
+ :fork-path="forkPath"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index fd151751372..3896e963a53 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -1,5 +1,5 @@
import { __, n__, sprintf } from '~/locale';
-import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants';
const INTERVALS = {
minute: 'minute',
@@ -75,8 +75,6 @@ export const timeRanges = [
/* eslint-enable @gitlab/require-i18n-strings */
export const defaultTimeRange = timeRanges.find((tr) => tr.default);
-export const getTimeWindow = (timeWindowName) =>
- timeRanges.find((tr) => tr.name === timeWindowName);
export const AVATAR_SHAPE_OPTION_CIRCLE = 'circle';
export const AVATAR_SHAPE_OPTION_RECT = 'rect';
@@ -87,7 +85,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
'Only %{workspaceType} members with %{permissions} can view or be notified about this %{issuableType}.',
),
{
- workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'),
+ workspaceType: workspaceType === WORKSPACE_PROJECT ? __('project') : __('group'),
issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'),
permissions:
issuableType === TYPE_ISSUE
@@ -98,3 +96,4 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField';
export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor';
+export const CLEAR_AUTOSAVE_ENTRY_EVENT = 'markdown_clear_autosave_entry';
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index fc0ff78e7b4..b9a09c2521c 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -1,4 +1,5 @@
import { __ } from '~/locale';
+import { getInstanceFromDirective } from '~/lib/utils/vue3compat/get_instance_from_directive';
/**
* Validation messages will take priority based on the property order.
@@ -45,8 +46,7 @@ const getInputElement = (el) => {
const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true);
-const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
- const { form } = context;
+const createValidator = (feedbackMap) => ({ el, form, reportInvalidInput = false }) => {
const { name } = el;
if (!name) {
@@ -69,6 +69,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
formField.state = reportInvalidInput ? isValid : isValid || null;
formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : '';
+ // eslint-disable-next-line no-param-reassign
form.state = isEveryFieldValid(form);
};
@@ -102,12 +103,13 @@ export default function initValidation(customFeedbackMap = {}) {
const elDataMap = new WeakMap();
return {
- inserted(element, binding, { context }) {
+ inserted(element, binding, vnode) {
const { arg: showGlobalValidation } = binding;
const el = getInputElement(element);
const { form: formEl } = el;
+ const instance = getInstanceFromDirective({ binding, vnode });
- const validate = createValidator(context, feedbackMap);
+ const validate = createValidator(feedbackMap);
const elData = { validate, isTouched: false, isBlurred: false };
elDataMap.set(el, elData);
@@ -121,7 +123,7 @@ export default function initValidation(customFeedbackMap = {}) {
el.addEventListener('blur', function markAsBlurred({ target }) {
if (elData.isTouched) {
elData.isBlurred = true;
- validate({ el: target, reportInvalidInput: true });
+ validate({ el: target, form: instance.form, reportInvalidInput: true });
// this event handler can be removed, since the live-feedback in `update` takes over
el.removeEventListener('blur', markAsBlurred);
}
@@ -131,15 +133,16 @@ export default function initValidation(customFeedbackMap = {}) {
formEl.addEventListener('submit', focusFirstInvalidInput);
}
- validate({ el, reportInvalidInput: showGlobalValidation });
+ validate({ el, form: instance.form, reportInvalidInput: showGlobalValidation });
},
- update(element, binding) {
+ update(element, binding, vnode) {
const el = getInputElement(element);
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
- validate({ el, reportInvalidInput: showValidationFeedback });
+ const instance = getInstanceFromDirective({ binding, vnode });
+ validate({ el, form: instance.form, reportInvalidInput: showValidationFeedback });
},
};
}
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
index c12ffaac40a..79946ebaecd 100644
--- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -1,12 +1,14 @@
export default (Vue) => {
Vue.mixin({
- provide: {
- glFeatures:
- {
- ...window.gon?.features,
- // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
- ...window.gon?.licensed_features,
- } || {},
+ provide() {
+ return {
+ glFeatures:
+ {
+ ...window.gon?.features,
+ // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
+ ...window.gon?.licensed_features,
+ } || {},
+ };
},
});
};
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
new file mode 100644
index 00000000000..4211b9578a2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -0,0 +1,77 @@
+import { s__, __, sprintf } from '~/locale';
+
+export const AUTOCOMPLETE_ERROR_MESSAGE = s__(
+ 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
+);
+
+export const ALL_GITLAB = __('All GitLab');
+export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab');
+
+export const SEARCH_DESCRIBED_BY_DEFAULT = s__(
+ 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
+);
+export const SEARCH_DESCRIBED_BY_WITH_RESULTS = s__(
+ 'GlobalSearch|Type for new suggestions to appear below.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN = s__(
+ 'GlobalSearch|Type and press the enter key to submit search.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN = SEARCH_DESCRIBED_BY_WITH_RESULTS;
+export const SEARCH_DESCRIBED_BY_UPDATED = s__(
+ 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
+);
+export const SEARCH_RESULTS_LOADING = s__('GlobalSearch|Search results are loading');
+export const SEARCH_RESULTS_SCOPE = s__('GlobalSearch|in %{scope}');
+export const KBD_HELP = sprintf(
+ s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
+ { 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');
+
+export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
+
+export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
+
+export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
+
+export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
+
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
+
+export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
+
+export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
+
+export const USERS_CATEGORY = s__('GlobalSearch|Users');
+
+export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
+
+export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
+
+export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
+
+export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
+
+export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
+
+export const HELP_CATEGORY = s__('GlobalSearch|Help');
+
+export const SEARCH_RESULTS_ORDER = [
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ USERS_CATEGORY,
+ IN_THIS_PROJECT_CATEGORY,
+ SETTINGS_CATEGORY,
+ HELP_CATEGORY,
+];
+export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER;
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index 2644befc902..a19b568801d 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -1,11 +1,11 @@
<script>
import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants';
import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue';
+import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants';
export default {
- LabelSelectVariant: DropdownVariant,
+ VARIANT_EMBEDDED,
components: {
GlForm,
GlFormInput,
@@ -105,7 +105,7 @@ export default {
:labels-list-title="__('Select label')"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
- :variant="$options.LabelSelectVariant.Embedded"
+ :variant="$options.VARIANT_EMBEDDED"
@updateSelectedLabels="handleUpdateSelectedLabels"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
index b3f9c8d9fcd..efb6e626c07 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlIcon } from '@gitlab/ui';
+import { GlFormGroup } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
import { __ } from '~/locale';
@@ -7,7 +7,6 @@ import { __ } from '~/locale';
export default {
components: {
GlFormGroup,
- GlIcon,
LabelsSelect,
},
inject: [
@@ -50,10 +49,6 @@ export default {
<gl-form-group class="row" label-class="gl-display-none">
<label class="col-12 gl-display-flex gl-align-center gl-mb-1">
{{ $options.i18n.fieldLabel }}
- <div class="gl-ml-3">
- <gl-icon name="labels" />
- <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span>
- </div>
</label>
<div class="col-12">
<div class="issuable-form-select-holder">
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..62a32d8942a 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,20 +94,22 @@ 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),
});
+ } else if (this.issuable.updatedAt !== this.issuable.createdAt) {
+ return sprintf(__('updated %{timeAgo}'), {
+ timeAgo: getTimeago().format(this.issuable.updatedAt),
+ });
}
- return sprintf(__('updated %{timeAgo}'), {
- timeAgo: getTimeago().format(this.issuable.updatedAt),
- });
+ return undefined;
},
issuableTitleProps() {
if (this.isIssuableUrlExternal) {
@@ -200,6 +203,11 @@ export default {
</gl-form-checkbox>
<div class="issuable-main-info">
<div data-testid="issuable-title" class="issue-title title">
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="issuable.type"
+ show-tooltip-on-hover
+ />
<gl-icon
v-if="issuable.confidential"
v-gl-tooltip
@@ -226,18 +234,13 @@ 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 }}
</span>
</div>
<div class="issuable-info">
- <work-item-type-icon
- v-if="showWorkItemTypeIcon"
- :work-item-type="issuable.type"
- show-tooltip-on-hover
- />
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
@@ -265,7 +268,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 gl-text-gray-500!"
>
<span class="author">{{ author.name }}</span>
</gl-link>
@@ -285,8 +288,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 +297,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 +314,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..95108933a0b 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,
@@ -316,6 +317,7 @@ export default {
:show-checkbox="showBulkEditSidebar"
:checkbox-checked="allIssuablesChecked"
:show-friendly-text="showFilteredSearchFriendlyText"
+ terms-as-tokens
class="gl-flex-grow-1 gl-border-t-none row-content-block"
data-qa-selector="issuable_search_container"
@checked-input="handleAllIssuablesCheckedInput"
@@ -371,7 +373,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/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index f6b864dfde0..7ece3b60bd5 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -1,33 +1,28 @@
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
-export const IssuableStates = {
- Opened: 'opened',
- Closed: 'closed',
- All: 'all',
-};
-
-export const IssuableListTabs = [
+export const issuableListTabs = [
{
id: 'state-opened',
- name: IssuableStates.Opened,
+ name: STATUS_OPEN,
title: __('Open'),
titleTooltip: __('Filter by issues that are currently opened.'),
},
{
id: 'state-closed',
- name: IssuableStates.Closed,
+ name: STATUS_CLOSED,
title: __('Closed'),
titleTooltip: __('Filter by issues that are currently closed.'),
},
{
id: 'state-all',
- name: IssuableStates.All,
+ name: STATUS_ALL,
title: __('All'),
titleTooltip: __('Show all issues.'),
},
];
-export const AvailableSortOptions = [
+export const availableSortOptions = [
{
id: 1,
title: __('Created date'),
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 d78530239a5..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
@@ -3,6 +3,7 @@ import { GlLink } from '@gitlab/ui';
import TaskList from '~/task_list';
+import { TYPE_ISSUE } from '~/issues/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import IssuableDescription from './issuable_description.vue';
@@ -112,7 +113,7 @@ export default {
* task lists in Issue, Test Cases and Incidents
* as all of those are derived from `issue`.
*/
- dataType: 'issue',
+ dataType: TYPE_ISSUE,
fieldName: 'description',
lockVersion: this.taskListLockVersion,
selector: '.js-detail-page-description',
@@ -138,7 +139,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description js-detail-page-description content-block">
+ <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/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index 1f23fdfaafd..3d4eebb9524 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -9,11 +9,11 @@ import {
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { STATUS_OPEN } from '~/issues/constants';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
components: {
@@ -80,7 +80,7 @@ export default {
},
computed: {
badgeVariant() {
- return this.issuableState === IssuableStates.Opened ? 'success' : 'info';
+ return this.issuableState === STATUS_OPEN ? 'success' : 'info';
},
authorId() {
return getIdFromGraphQLId(`${this.author.id}`);
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index fd94245b7c9..c33e803c7e1 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
i18n: {
@@ -39,7 +39,7 @@ export default {
},
computed: {
badgeVariant() {
- return this.issuable.state === IssuableStates.Opened ? 'success' : 'info';
+ return this.issuable.state === STATUS_OPEN ? 'success' : 'info';
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 2a0256548a8..61e45fa5195 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -5,8 +5,8 @@ import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetim
*/
export default {
methods: {
- timeFormatted(time) {
- const timeago = getTimeago();
+ timeFormatted(time, format) {
+ const timeago = getTimeago(format);
return timeago.format(time, timeagoLanguageCode);
},
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..caa85d3eaaf 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,7 @@ export default {
};
</script>
<template>
- <div class="container gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-flex-direction-column">
<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 318adec2319..c8c7deff882 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
@@ -3,34 +3,31 @@ import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue';
+import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
+import { sidebarState, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants';
import LegacyContainer from './components/legacy_container.vue';
import WelcomePage from './components/welcome.vue';
export default {
+ JS_TOGGLE_EXPAND_CLASS,
components: {
NewTopLevelGroupAlert,
GlBreadcrumb,
GlIcon,
WelcomePage,
LegacyContainer,
- CreditCardVerification: () =>
- import('ee_component/namespaces/verification/components/credit_card_verification.vue'),
+ SuperSidebarToggle,
},
directives: {
SafeHtml,
},
- inject: {
- verificationRequired: {
- default: false,
- },
- },
props: {
title: {
type: String,
required: true,
},
- initialBreadcrumb: {
- type: String,
+ initialBreadcrumbs: {
+ type: Array,
required: true,
},
panels: {
@@ -51,7 +48,6 @@ export default {
data() {
return {
activePanelName: null,
- verificationCompleted: false,
};
},
@@ -60,6 +56,10 @@ export default {
return this.panels.find((p) => p.name === this.activePanelName);
},
+ detailProps() {
+ return this.activePanel.detailProps || {};
+ },
+
details() {
return this.activePanel.details || this.activePanel.description;
},
@@ -69,18 +69,15 @@ export default {
},
breadcrumbs() {
- if (!this.activePanel) {
- return null;
- }
-
- return [
- { text: this.initialBreadcrumb, href: '#' },
- { text: this.activePanel.title, href: `#${this.activePanel.name}` },
- ];
- },
-
- shouldVerify() {
- return this.verificationRequired && !this.verificationCompleted;
+ return this.activePanel
+ ? [
+ ...this.initialBreadcrumbs,
+ {
+ text: this.activePanel.title,
+ href: `#${this.activePanel.name}`,
+ },
+ ]
+ : this.initialBreadcrumbs;
},
showNewTopLevelGroupAlert() {
@@ -90,6 +87,10 @@ export default {
return this.activePanel.detailProps.parentGroupName === '';
},
+
+ showSuperSidebarToggle() {
+ return gon.use_new_navigation && sidebarState.isCollapsed;
+ },
},
created() {
@@ -116,34 +117,45 @@ export default {
localStorage.setItem(this.persistenceKey, this.activePanelName);
}
},
- onVerified() {
- this.verificationCompleted = true;
- },
},
};
</script>
<template>
- <credit-card-verification v-if="shouldVerify" @verified="onVerified" />
- <welcome-page v-else-if="!activePanelName" :panels="panels" :title="title">
- <template #footer>
- <slot name="welcome-footer"> </slot>
- </template>
- </welcome-page>
- <div v-else class="row">
- <div class="col-lg-3">
- <div v-safe-html="activePanel.illustration" class="gl-text-white"></div>
- <h4>{{ activePanel.title }}</h4>
+ <div>
+ <div
+ class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <super-sidebar-toggle
+ v-if="showSuperSidebarToggle"
+ class="gl-mr-2"
+ :class="$options.JS_TOGGLE_EXPAND_CLASS"
+ />
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
+ </div>
- <p v-if="hasTextDetails">{{ details }}</p>
- <component :is="details" v-else v-bind="activePanel.detailProps || {}" />
+ <template v-if="activePanel">
+ <div class="gl-display-flex gl-align-items-center gl-py-5">
+ <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
+ <div class="col">
+ <h4>{{ activePanel.title }}</h4>
+
+ <p v-if="hasTextDetails">{{ details }}</p>
+ <component :is="details" v-else v-bind="detailProps" />
+ </div>
+
+ <slot name="extra-description"></slot>
+ </div>
+ <div>
+ <new-top-level-group-alert v-if="showNewTopLevelGroupAlert" />
+ <legacy-container :key="activePanel.name" :selector="activePanel.selector" />
+ </div>
+ </template>
- <slot name="extra-description"></slot>
- </div>
- <div class="col-lg-9">
- <new-top-level-group-alert v-if="showNewTopLevelGroupAlert" />
- <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs" />
- <legacy-container :key="activePanel.name" :selector="activePanel.selector" />
- </div>
+ <welcome-page v-else :panels="panels" :title="title">
+ <template #footer>
+ <slot name="welcome-footer"></slot>
+ </template>
+ </welcome-page>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js
index fb52b31c2c8..bfea2bedd40 100644
--- a/app/assets/javascripts/vue_shared/plugins/global_toast.js
+++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js
@@ -2,7 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
Vue.use(GlToast);
-export const instance = new Vue();
+const instance = new Vue();
export default function showGlobalToast(...args) {
return instance.$toast.show(...args);
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..fe408354f66 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,7 +1,8 @@
<script>
import { GlButton } from '@gitlab/ui';
import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { parseErrorMessage } from '~/lib/utils/error_message';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
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);
+ redirectTo(successPath); // eslint-disable-line import/no-deprecated
} 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/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
index a4fb30a03a1..4c2b082242b 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
@@ -1,6 +1,6 @@
<script>
import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
index 6fe98764fcd..5e8199c1bcd 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
@@ -2,7 +2,7 @@ export const SEVERITY_CLASS_NAME_MAP = {
critical: 'gl-text-red-800',
high: 'gl-text-red-600',
medium: 'gl-text-orange-400',
- low: 'gl-text-orange-200',
+ low: 'gl-text-orange-300',
info: 'gl-text-blue-400',
unknown: 'gl-text-gray-400',
};
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index fafbd02634f..a1d75e08be9 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -20,14 +20,15 @@ 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';
export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning';
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/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index b739baad5d7..0cff5edf628 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import ReportSection from '~/ci/reports/components/report_section.vue';
import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants';
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/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 8ec8482657d..8bb8b6101d4 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -63,7 +63,7 @@ export default {
:options="$options.states"
:disabled="disabled"
data-testid="work-item-state-select"
- class="gl-w-auto hide-select-decoration gl-pl-3"
+ class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1"
:class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
/>
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 1c0fed2dde9..1dc6d341811 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -48,6 +48,7 @@ export default {
id="item-title"
ref="titleEl"
role="textbox"
+ data-testid="work-item-title"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
diff --git a/app/assets/javascripts/work_items/components/notes/activity_filter.vue b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
deleted file mode 100644
index 71784d3a807..00000000000
--- a/app/assets/javascripts/work_items/components/notes/activity_filter.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { ASC, DESC } from '~/notes/constants';
-import { TRACKING_CATEGORY_SHOW, WORK_ITEM_NOTES_SORT_ORDER_KEY } from '~/work_items/constants';
-
-const SORT_OPTIONS = [
- { key: DESC, text: __('Newest first'), dataid: 'js-newest-first' },
- { key: ASC, text: __('Oldest first'), dataid: 'js-oldest-first' },
-];
-
-export default {
- SORT_OPTIONS,
- components: {
- GlDropdown,
- GlDropdownItem,
- LocalStorageSync,
- },
- mixins: [Tracking.mixin()],
- props: {
- sortOrder: {
- type: String,
- default: ASC,
- required: false,
- },
- loading: {
- type: Boolean,
- default: false,
- required: false,
- },
- workItemType: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- persistSortOrder: true,
- };
- },
- computed: {
- tracking() {
- return {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_track_notes_sorting',
- property: `type_${this.workItemType}`,
- };
- },
- selectedSortOption() {
- const isSortOptionValid = this.sortOrder === ASC || this.sortOrder === DESC;
- return isSortOptionValid ? SORT_OPTIONS.find(({ key }) => this.sortOrder === key) : ASC;
- },
- getDropdownSelectedText() {
- return this.selectedSortOption.text;
- },
- },
- methods: {
- setDiscussionSortDirection(direction) {
- this.$emit('updateSavedSortOrder', direction);
- },
- fetchSortedDiscussions(direction) {
- if (this.isSortDropdownItemActive(direction)) {
- return;
- }
- this.track('notes_sort_order_changed');
- this.$emit('changeSortOrder', direction);
- },
- isSortDropdownItemActive(sortDir) {
- return sortDir === this.sortOrder;
- },
- },
- WORK_ITEM_NOTES_SORT_ORDER_KEY,
-};
-</script>
-
-<template>
- <div
- id="discussion-preferences"
- data-testid="discussion-preferences"
- class="gl-display-inline-block gl-vertical-align-bottom gl-w-full gl-sm-w-auto"
- >
- <local-storage-sync
- :value="sortOrder"
- :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
- :persist="persistSortOrder"
- as-string
- @input="setDiscussionSortDirection"
- />
- <gl-dropdown
- :id="`discussion-preferences-dropdown-${workItemType}`"
- class="gl-xs-w-full"
- size="small"
- :text="getDropdownSelectedText"
- :disabled="loading"
- right
- >
- <div id="discussion-sort">
- <gl-dropdown-item
- v-for="{ text, key, dataid } in $options.SORT_OPTIONS"
- :key="text"
- :data-testid="dataid"
- is-check-item
- :is-checked="isSortDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
- </div>
-</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 bca061f5e01..f8dfa1c7f01 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -70,7 +70,7 @@ export default {
return [];
},
noteAnchorId() {
- return `note_${this.note.id}`;
+ return `note_${this.noteId}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
@@ -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_activity_sort_filter.vue b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue
new file mode 100644
index 00000000000..1ead16c944b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue
@@ -0,0 +1,116 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ loading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ sortFilterProp: {
+ type: String,
+ required: true,
+ },
+ filterOptions: {
+ type: Array,
+ required: true,
+ },
+ trackingLabel: {
+ type: String,
+ required: true,
+ },
+ trackingAction: {
+ type: String,
+ required: true,
+ },
+ filterEvent: {
+ type: String,
+ required: true,
+ },
+ defaultSortFilterProp: {
+ type: String,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: this.trackingLabel,
+ property: `type_${this.workItemType}`,
+ };
+ },
+ getDropdownSelectedText() {
+ return this.selectedSortOption.text;
+ },
+ selectedSortOption() {
+ return (
+ this.filterOptions.find(({ key }) => this.sortFilterProp === key) ||
+ this.defaultSortFilterProp
+ );
+ },
+ },
+ methods: {
+ setDiscussionFilterOption(filterValue) {
+ this.$emit(this.filterEvent, filterValue);
+ },
+ fetchFilteredDiscussions(filterValue) {
+ if (this.isSortDropdownItemActive(filterValue)) {
+ return;
+ }
+ this.track(this.trackingAction);
+ this.$emit(this.filterEvent, filterValue);
+ },
+ isSortDropdownItemActive(value) {
+ return value === this.sortFilterProp;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-inline-block gl-vertical-align-bottom">
+ <local-storage-sync
+ :value="sortFilterProp"
+ :storage-key="storageKey"
+ as-string
+ @input="setDiscussionFilterOption"
+ />
+ <gl-dropdown
+ class="gl-xs-w-full"
+ size="small"
+ :text="getDropdownSelectedText"
+ :disabled="loading"
+ right
+ >
+ <gl-dropdown-item
+ v-for="{ text, key, testid } in filterOptions"
+ :key="text"
+ :data-testid="testid"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchFilteredDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
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 b3f17aff2ae..e10a82b5197 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,13 +1,11 @@
<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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { updateCommentState } from '~/work_items/graphql/cache_utils';
-import { getWorkItemQuery } from '../../utils';
+import { __ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
import WorkItemCommentLocked from './work_item_comment_locked.vue';
@@ -18,31 +16,21 @@ export default {
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
- GlAvatar,
- GlButton,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
},
- mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
discussionId: {
type: String,
required: false,
@@ -67,28 +55,43 @@ 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,
};
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
@@ -110,15 +113,20 @@ export default {
property: `type_${this.workItemType}`,
};
},
- markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
+ timelineEntryInnerClass() {
+ return {
+ 'timeline-entry-inner': this.isNewDiscussion,
+ };
},
- timelineEntryClass() {
+ timelineContentClass() {
+ return {
+ 'timeline-content': true,
+ 'gl-border-0! gl-pl-0!': !this.addPadding,
+ };
+ },
+ parentClass() {
return {
- 'timeline-entry gl-mb-3': true,
- 'gl-p-4': this.addPadding,
+ 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing,
};
},
isProjectArchived() {
@@ -127,6 +135,21 @@ export default {
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
+ workItemState() {
+ return this.workItem?.state;
+ },
+ commentButtonText() {
+ return this.isNewDiscussion ? __('Comment') : __('Reply');
+ },
+ timelineEntryClass() {
+ return {
+ 'timeline-entry note-form': this.isNewDiscussion,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this
+ .isNewDiscussion,
+ 'gl-bg-white! gl-pt-0!': this.isEditing,
+ };
+ },
},
watch: {
autofocus: {
@@ -142,8 +165,6 @@ export default {
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', commentText);
- const { queryVariables, fetchByIid } = this;
-
try {
this.track('add_work_item_comment');
@@ -157,26 +178,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]);
}
- updateCommentState(store, createNoteData, fetchByIid, queryVariables);
},
});
- 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>
@@ -189,23 +240,38 @@ export default {
:work-item-type="workItemType"
:is-project-archived="isProjectArchived"
/>
- <div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
- <work-item-comment-form
- v-if="isEditing"
- :work-item-type="workItemType"
- :aria-label="__('Add a comment')"
- :is-submitting="isSubmitting"
- :autosave-key="autosaveKey"
- @submitForm="updateWorkItem"
- @cancelEditing="cancelEditing"
- />
- <gl-button
- v-else
- class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
- @click="isEditing = true"
- >{{ __('Add a comment') }}</gl-button
- >
+ <div v-else :class="timelineEntryInnerClass">
+ <div :class="timelineContentClass">
+ <div :class="parentClass">
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :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"
+ />
+ <textarea
+ v-else
+ 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>
</li>
</template>
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 fd407fd9d9f..cea28b30d42 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,36 +150,91 @@ 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>
- <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"
- :form-field-props="formFieldProps"
- data-testid="work-item-add-comment"
- class="gl-mb-3"
- autofocus
- use-bottom-toolbar
- @input="setCommentText"
- @keydown.meta.enter="$emit('submitForm', commentText)"
- @keydown.ctrl.enter="$emit('submitForm', commentText)"
- @keydown.esc.stop="cancelEditing"
- />
- <gl-button
- category="primary"
- variant="confirm"
- data-testid="confirm-button"
- :loading="isSubmitting"
- @click="$emit('submitForm', commentText)"
- >{{ commentButtonText }}
- </gl-button>
- <gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing"
- >{{ __('Cancel') }}
- </gl-button>
- </form>
+ <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-5"
+ use-bottom-toolbar
+ supports-quick-actions
+ :autofocus="autofocus"
+ @input="setCommentText"
+ @keydown.meta.enter="$emit('submitForm', commentText)"
+ @keydown.ctrl.enter="$emit('submitForm', commentText)"
+ @keydown.esc.stop="cancelEditing"
+ />
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="confirm-button"
+ :disabled="!commentText.length"
+ :loading="isSubmitting"
+ @click="$emit('submitForm', commentText)"
+ >{{ commentButtonText }}
+ </gl-button>
+ <gl-button
+ data-testid="cancel-button"
+ category="primary"
+ class="gl-ml-3"
+ :loading="updateInProgress"
+ @click="cancelButtonAction"
+ >{{ cancelButtonText }}
+ </gl-button>
+ </form>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
index f837d025b7f..c1b6903cf17 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
@@ -45,8 +45,10 @@ export default {
</script>
<template>
- <div class="disabled-comment text-center">
- <span class="issuable-note-warning gl-display-inline-block">
+ <div class="disabled-comment gl-text-center gl-relative gl-mt-3">
+ <span
+ class="issuable-note-warning gl-display-inline-block gl-w-full gl-px-5 gl-py-4 gl-rounded-base"
+ >
<gl-icon name="lock" class="gl-mr-2" />
<template v-if="isProjectArchived">
{{ $options.constantOptions.projectArchivedWarning }}
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 bda00f978b9..e98e03f76fd 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
@@ -1,5 +1,6 @@
<script>
-import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ASC } from '~/notes/constants';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue';
@@ -11,24 +12,19 @@ import WorkItemAddNote from './work_item_add_note.vue';
export default {
components: {
TimelineEntryItem,
- GlAvatarLink,
- GlAvatar,
WorkItemNote,
WorkItemAddNote,
ToggleRepliesWidget,
DiscussionNotesRepliesWrapper,
WorkItemNoteReplying,
},
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- queryVariables: {
- type: Object,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
@@ -36,11 +32,6 @@ export default {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
discussion: {
type: Array,
required: true,
@@ -50,13 +41,38 @@ export default {
default: ASC,
required: false,
},
+ isModal: {
+ type: Boolean,
+ 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 {
- isExpanded: false,
+ isExpanded: true,
autofocus: false,
isReplying: false,
replyingText: '',
+ showForm: false,
};
},
computed: {
@@ -66,11 +82,20 @@ export default {
author() {
return this.note.author;
},
+ noteId() {
+ return getIdFromGraphQLId(this.note.id);
+ },
noteAnchorId() {
- return `note_${this.note.id}`;
+ return `note_${this.noteId}`;
+ },
+ isTarget() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ targetNoteHash() {
+ return getLocationHash();
},
hasReplies() {
- return this.replies?.length;
+ return Boolean(this.replies?.length);
},
replies() {
if (this.discussion?.length > 1) {
@@ -81,23 +106,29 @@ export default {
discussionId() {
return this.discussion[0]?.discussion?.id || '';
},
+ shouldShowReplyForm() {
+ return this.showForm || this.hasReplies;
+ },
+ isOnlyCommentOfAThread() {
+ return !this.hasReplies && !this.showForm;
+ },
},
methods: {
showReplyForm() {
+ this.showForm = true;
this.isExpanded = true;
this.autofocus = true;
},
hideReplyForm() {
+ this.showForm = false;
this.isExpanded = this.hasReplies;
this.autofocus = false;
},
toggleDiscussion() {
this.isExpanded = !this.isExpanded;
- this.autofocus = 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;
@@ -113,76 +144,107 @@ export default {
</script>
<template>
+ <work-item-note
+ v-if="isOnlyCommentOfAThread"
+ :is-first-note="true"
+ :note="note"
+ :discussion-id="discussionId"
+ :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"
+ :work-item-iid="workItemIid"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', note)"
+ @error="$emit('error', $event)"
+ />
<timeline-entry-item
- :id="noteAnchorId"
+ v-else
:class="{ 'internal-note': note.internal }"
- :data-note-id="note.id"
- class="note note-wrapper note-comment gl-px-0"
+ :data-note-id="noteId"
+ class="note note-discussion gl-px-0"
>
- <div class="timeline-avatar gl-float-left">
- <gl-avatar-link :href="author.webUrl">
- <gl-avatar
- :src="author.avatarUrl"
- :entity-name="author.username"
- :alt="author.name"
- :size="32"
- />
- </gl-avatar-link>
- </div>
-
<div class="timeline-content">
- <div class="discussion-body">
- <div class="discussion-wrapper">
- <div class="discussion-notes">
- <ul class="notes">
- <work-item-note
- :is-first-note="true"
- :note="note"
- :discussion-id="discussionId"
- :work-item-type="workItemType"
- :class="{ 'gl-mb-5': hasReplies }"
- @startReplying="showReplyForm"
- @deleteNote="$emit('deleteNote', note)"
- @error="$emit('error', $event)"
- />
- <discussion-notes-replies-wrapper>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="!isExpanded"
- :replies="replies"
- @toggle="toggleDiscussion({ discussionId })"
+ <div class="discussion">
+ <div class="discussion-body">
+ <div class="discussion-wrapper">
+ <div class="discussion-notes">
+ <ul class="notes">
+ <work-item-note
+ :is-first-note="true"
+ :note="note"
+ :discussion-id="discussionId"
+ :has-replies="hasReplies"
+ :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"
+ :work-item-iid="workItemIid"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', note)"
+ @error="$emit('error', $event)"
/>
- <template v-if="isExpanded">
- <template v-for="reply in replies">
- <work-item-note
- :key="threadKey(reply)"
+ <discussion-notes-replies-wrapper>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ @toggle="toggleDiscussion({ discussionId })"
+ />
+ <template v-if="isExpanded">
+ <template v-for="reply in replies">
+ <work-item-note
+ :key="threadKey(reply)"
+ :discussion-id="discussionId"
+ :note="reply"
+ :work-item-type="workItemType"
+ :is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', reply)"
+ @reportAbuse="$emit('reportAbuse', reply)"
+ @error="$emit('error', $event)"
+ />
+ </template>
+ <work-item-note-replying v-if="isReplying" :body="replyingText" />
+ <work-item-add-note
+ v-if="shouldShowReplyForm"
+ :notes-form="false"
+ :autofocus="autofocus"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
:discussion-id="discussionId"
- :note="reply"
:work-item-type="workItemType"
+ :sort-order="sortOrder"
+ :add-padding="true"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
@startReplying="showReplyForm"
- @deleteNote="$emit('deleteNote', reply)"
+ @cancelEditing="hideReplyForm"
+ @replied="onReplied"
+ @replying="onReplying"
@error="$emit('error', $event)"
/>
</template>
- <work-item-note-replying v-if="isReplying" :body="replyingText" />
- <work-item-add-note
- :autofocus="autofocus"
- :query-variables="queryVariables"
- :full-path="fullPath"
- :work-item-id="workItemId"
- :fetch-by-iid="fetchByIid"
- :discussion-id="discussionId"
- :work-item-type="workItemType"
- :sort-order="sortOrder"
- :add-padding="true"
- @cancelEditing="hideReplyForm"
- @replied="onReplied"
- @replying="onReplying"
- @error="$emit('error', $event)"
- />
- </template>
- </discussion-notes-replies-wrapper>
- </ul>
+ </discussion-notes-replies-wrapper>
+ </ul>
+ </div>
</div>
</div>
</div>
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
new file mode 100644
index 00000000000..e7a80bf39fb
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+} from '~/work_items/constants';
+
+export default {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ i18n: {
+ information: s__(
+ "WorkItem|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options.",
+ ),
+ },
+ components: {
+ GlButton,
+ GlIcon,
+ GlSprintf,
+ },
+ methods: {
+ selectFilter(value) {
+ this.$emit('changeFilter', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry note note-wrapper discussion-filter-note">
+ <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">
+ <gl-sprintf :message="$options.i18n.information">
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+
+ <div class="discussion-filter-actions">
+ <gl-button
+ class="gl-mr-2 gl-mt-3"
+ data-testid="show-all-activity"
+ @click="selectFilter($options.WORK_ITEM_NOTES_FILTER_ALL_NOTES)"
+ >
+ {{ __('Show all activity') }}
+ </gl-button>
+ <gl-button
+ class="gl-mt-3"
+ data-testid="show-comments-only"
+ @click="selectFilter($options.WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS)"
+ >
+ {{ __('Show comments only') }}
+ </gl-button>
+ </div>
+ </div>
+ </li>
+</template>
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 5dd21a5f76f..75b0970a89e 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,23 +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 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 workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
name: 'WorkItemNoteThread',
- i18n: {
- moreActionsText: __('More actions'),
- deleteNoteText: __('Delete comment'),
- },
components: {
TimelineEntryItem,
NoteBody,
@@ -25,15 +28,20 @@ export default {
NoteActions,
GlAvatar,
GlAvatarLink,
- GlDropdown,
- GlDropdownItem,
WorkItemCommentForm,
EditedAt,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemIid: {
+ type: String,
+ required: true,
+ },
note: {
type: Object,
required: true,
@@ -43,10 +51,39 @@ export default {
required: false,
default: false,
},
+ hasReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
workItemType: {
type: String,
required: true,
},
+ isModal: {
+ type: Boolean,
+ 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 {
@@ -54,18 +91,31 @@ export default {
};
},
computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_note_actions',
+ property: `type_${this.workItemType}`,
+ };
+ },
author() {
return this.note.author;
},
entryClass() {
return {
'note note-wrapper note-comment': true,
- 'gl-p-4': !this.isFirstNote,
+ target: this.isTarget,
+ 'inner-target': this.isTarget && !this.isFirstNote,
};
},
showReply() {
return this.note.userPermissions.createNote && this.isFirstNote;
},
+ noteHeaderClass() {
+ return {
+ 'note-header': true,
+ };
+ },
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.note.id}-comment`;
@@ -76,6 +126,50 @@ export default {
hasAdminPermission() {
return this.note.userPermissions.adminNote;
},
+ noteAnchorId() {
+ return `note_${getIdFromGraphQLId(this.note.id)}`;
+ },
+ isTarget() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ targetNoteHash() {
+ return getLocationHash();
+ },
+ noteUrl() {
+ return this.note.url;
+ },
+ hasAwardEmojiPermission() {
+ return this.note.userPermissions.awardEmoji;
+ },
+ isAuthorAnAssignee() {
+ return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length);
+ },
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ canReportAbuse() {
+ return getIdFromGraphQLId(this.author.id) !== this.currentUserId;
+ },
+ },
+ apollo: {
+ workItem: {
+ query: workItemByIidQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItems?.nodes[0];
+ },
+ skip() {
+ return !this.workItemIid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
},
methods: {
showReplyForm() {
@@ -86,8 +180,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: {
@@ -114,13 +208,75 @@ 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>
<template>
- <timeline-entry-item :class="entryClass">
- <div v-if="!isFirstNote" :key="note.id" class="timeline-avatar gl-float-left">
+ <timeline-entry-item :id="noteAnchorId" :class="entryClass">
+ <div :key="note.id" class="timeline-avatar gl-float-left">
<gl-avatar-link :href="author.webUrl">
<gl-avatar
:src="author.avatarUrl"
@@ -130,57 +286,63 @@ export default {
/>
</gl-avatar-link>
</div>
- <work-item-comment-form
- v-if="isEditing"
- :work-item-type="workItemType"
- :aria-label="__('Edit comment')"
- :autosave-key="autosaveKey"
- :initial-value="note.body"
- :comment-button-text="__('Save comment')"
- :class="{ 'gl-pl-8': !isFirstNote }"
- @cancelEditing="isEditing = false"
- @submitForm="updateNote"
- />
- <div v-else class="timeline-content-inner" data-testid="note-wrapper">
- <div class="note-header">
- <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
- <note-actions
- :show-reply="showReply"
- :show-edit="hasAdminPermission"
- @startReplying="showReplyForm"
- @startEditing="startEditing"
- />
- <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link -->
- <gl-dropdown
- v-if="hasAdminPermission"
- 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
- variant="danger"
- data-testid="delete-note-action"
- @click="$emit('deleteNote')"
+ <div class="timeline-content">
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :aria-label="__('Edit comment')"
+ :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"
+ />
+ <div v-else data-testid="note-wrapper">
+ <div :class="noteHeaderClass">
+ <note-header
+ :author="author"
+ :created-at="note.createdAt"
+ :note-id="note.id"
+ :note-url="note.url"
>
- {{ $options.i18n.deleteNoteText }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
- <div class="timeline-discussion-body">
- <note-body ref="noteBody" :note="note" />
+ <span v-if="note.createdAt" class="d-none d-sm-inline">&middot;</span>
+ </note-header>
+ <div class="gl-display-inline-flex">
+ <note-actions
+ :show-award-emoji="hasAwardEmojiPermission"
+ :note-url="noteUrl"
+ :show-reply="showReply"
+ :show-edit="hasAdminPermission"
+ :note-id="note.id"
+ :is-author-an-assignee="isAuthorAnAssignee"
+ :show-assign-unassign="canSetWorkItemMetadata"
+ :can-report-abuse="canReportAbuse"
+ @startReplying="showReplyForm"
+ @startEditing="startEditing"
+ @error="($event) => $emit('error', $event)"
+ @notifyCopyDone="notifyCopyDone"
+ @deleteNote="$emit('deleteNote')"
+ @assignUser="assignUserAction"
+ @reportAbuse="$emit('reportAbuse')"
+ />
+ </div>
+ </div>
+ <div class="timeline-discussion-body">
+ <note-body ref="noteBody" :note="note" :has-replies="hasReplies" />
+ </div>
+ <edited-at
+ v-if="note.lastEditedBy"
+ :updated-at="note.lastEditedAt"
+ :updated-by-name="lastEditedBy.name"
+ :updated-by-path="lastEditedBy.webPath"
+ :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
+ />
</div>
- <edited-at
- v-if="note.lastEditedBy"
- :updated-at="note.lastEditedAt"
- :updated-by-name="lastEditedBy.name"
- :updated-by-path="lastEditedBy.webPath"
- :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
- />
</div>
</timeline-entry-item>
</template>
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 c17e855e527..93f21f4fad8 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,20 +1,34 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+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';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
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'),
+ reportAbuseText: __('Report abuse to administrator'),
},
components: {
GlButton,
+ GlIcon,
ReplyButton,
+ GlDropdown,
+ GlDropdownItem,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
showReply: {
type: Boolean,
@@ -24,12 +38,90 @@ export default {
type: Boolean,
required: true,
},
+ noteId: {
+ type: String,
+ required: true,
+ },
+ showAwardEmoji: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isAuthorAnAssignee: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showAssignUnassign: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canReportAbuse: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assignUserActionText() {
+ return this.isAuthorAnAssignee
+ ? this.$options.i18n.unassignUserText
+ : this.$options.i18n.assignUserText;
+ },
+ },
+ methods: {
+ async setAwardEmoji(name) {
+ try {
+ const {
+ data: {
+ awardEmojiAdd: { errors = [] },
+ },
+ } = await this.$apollo.mutate({
+ mutation: addAwardEmojiMutation,
+ variables: {
+ awardableId: this.noteId,
+ name,
+ },
+ });
+
+ if (errors.length > 0) {
+ throw new Error(errors[0].message);
+ }
+ } catch (error) {
+ this.$emit('error', s__('WorkItem|Failed to award emoji'));
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
<template>
<div class="note-actions">
+ <emoji-picker
+ v-if="showAwardEmoji && glFeatures.workItemsMvc2"
+ toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
+ data-testid="note-emoji-button"
+ @click="setAwardEmoji"
+ >
+ <template #button-content>
+ <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
+ <gl-icon
+ class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
+ name="smiley"
+ />
+ <gl-icon
+ class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
+ name="smile"
+ />
+ </template>
+ </emoji-picker>
<reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" />
<gl-button
v-if="showEdit"
@@ -43,5 +135,46 @@ 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
+ v-if="canReportAbuse"
+ data-testid="abuse-note-action"
+ @click="$emit('reportAbuse')"
+ >
+ {{ $options.i18n.reportAbuseText }}
+ </gl-dropdown-item>
+ <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/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
index 95397b58925..bec902fd325 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
@@ -12,6 +12,11 @@ export default {
type: Object,
required: true,
},
+ hasReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
'note.bodyHtml': {
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
index 46f61ccd204..f053f6e1d7c 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
@@ -41,13 +41,23 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper note-comment gl-p-4 being-posted">
+ <timeline-entry-item class="note note-wrapper note-comment being-posted">
<div class="timeline-avatar gl-float-left">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" />
</div>
- <div class="note-header">
- <note-header :author="author" />
+ <div class="timeline-content" data-testid="note-wrapper">
+ <div class="note-header">
+ <note-header :author="author" />
+ </div>
+ <div ref="note-body" class="timeline-discussion-body">
+ <div class="note-body">
+ <div
+ v-safe-html:[$options.safeHtmlConfig]="body"
+ class="note-text md"
+ data-testid="work-item-note-body"
+ ></div>
+ </div>
+ </div>
</div>
- <div ref="note-body" v-safe-html:[$options.safeHtmlConfig]="body" class="note-body"></div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
index 3ef4a16bc57..bccbec903b4 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
@@ -27,5 +27,5 @@ export default {
</script>
<template>
- <div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div>
+ <div v-safe-html="signedOutText" class="disabled-comment gl-text-center gl-relative"></div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue
new file mode 100644
index 00000000000..0c1419e983f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue
@@ -0,0 +1,91 @@
+<script>
+import WorkItemActivitySortFilter from '~/work_items/components/notes/work_item_activity_sort_filter.vue';
+import { s__ } from '~/locale';
+import { ASC } from '~/notes/constants';
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_ACTIVITY_FILTER_OPTIONS,
+ WORK_ITEM_NOTES_FILTER_KEY,
+ WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+} from '~/work_items/constants';
+
+export default {
+ i18n: {
+ activityLabel: s__('WorkItem|Activity'),
+ },
+ components: {
+ WorkItemActivitySortFilter,
+ },
+ props: {
+ disableActivityFilterSort: {
+ type: Boolean,
+ required: true,
+ },
+ sortOrder: {
+ type: String,
+ default: ASC,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ discussionFilter: {
+ type: String,
+ default: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ required: false,
+ },
+ },
+ methods: {
+ changeNotesSortOrder(direction) {
+ this.$emit('changeSort', direction);
+ },
+ filterDiscussions(filterValue) {
+ this.$emit('changeFilter', filterValue);
+ },
+ },
+ WORK_ITEM_ACTIVITY_FILTER_OPTIONS,
+ WORK_ITEM_NOTES_FILTER_KEY,
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+ ASC,
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-pb-3 gl-align-items-center"
+ >
+ <h3 class="gl-font-base gl-m-0">{{ $options.i18n.activityLabel }}</h3>
+ <div class="gl-display-flex gl-gap-3">
+ <work-item-activity-sort-filter
+ :work-item-type="workItemType"
+ :loading="disableActivityFilterSort"
+ :sort-filter-prop="discussionFilter"
+ :filter-options="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS"
+ :storage-key="$options.WORK_ITEM_NOTES_FILTER_KEY"
+ :default-sort-filter-prop="$options.WORK_ITEM_NOTES_FILTER_ALL_NOTES"
+ tracking-action="work_item_notes_filter_changed"
+ tracking-label="item_track_notes_filtering"
+ filter-event="changeFilter"
+ data-testid="work-item-filter"
+ @changeFilter="filterDiscussions"
+ />
+ <work-item-activity-sort-filter
+ :work-item-type="workItemType"
+ :loading="disableActivityFilterSort"
+ :sort-filter-prop="sortOrder"
+ :filter-options="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS"
+ :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
+ :default-sort-filter-prop="$options.ASC"
+ tracking-action="work_item_notes_sort_order_changed"
+ tracking-label="item_track_notes_sorting"
+ filter-event="changeSort"
+ data-testid="work-item-sort"
+ @changeSort="changeNotesSortOrder"
+ />
+ </div>
+ </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 355f17e970b..6ae30e9b084 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,15 +43,26 @@ 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-5"
+ >
<div
- class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
- :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
+ 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="{
+ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!': isOpen,
+ }"
>
<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>
@@ -71,7 +83,7 @@ export default {
<div
v-if="isOpen"
class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
- :class="{ 'gl-p-5 gl-pb-3': !error }"
+ :class="{ 'gl-p-3': !error }"
data-testid="widget-body"
>
<slot name="body"></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 9f9d94ec3c2..8ea5873f73a 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,62 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
GlModalDirective,
+ GlToggle,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
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,
+ TEST_ID_PROMOTE_ACTION,
+ WIDGET_TYPE_NOTIFICATIONS,
+ I18N_WORK_ITEM_ERROR_CONVERTING,
+ WORK_ITEM_TYPE_VALUE_KEY_RESULT,
+ WORK_ITEM_TYPE_VALUE_OBJECTIVE,
} from '../constants';
+import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
+import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql';
+import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.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,
+ promoteActionTestId: TEST_ID_PROMOTE_ACTION,
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -40,6 +69,11 @@ export default {
required: false,
default: null,
},
+ workItemTypeId: {
+ type: String,
+ required: false,
+ default: null,
+ },
canUpdate: {
type: Boolean,
required: false,
@@ -60,15 +94,52 @@ export default {
required: false,
default: false,
},
+ subscribedToNotifications: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ skip() {
+ return !this.canUpdate;
+ },
+ },
},
- emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
computed: {
i18n() {
return {
deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType),
areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType),
+ convertError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CONVERTING, this.workItemType),
};
},
+ canPromoteToObjective() {
+ return this.canUpdate && this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT;
+ },
+ objectiveWorkItemTypeId() {
+ return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id;
+ },
+ },
+ watch: {
+ subscribedToNotifications() {
+ /**
+ * To toggle the value if mutation fails, assign the
+ * subscribedToNotifications boolean value directly
+ * to data prop.
+ */
+ this.initialSubscribed = this.subscribedToNotifications;
+ },
},
methods: {
handleToggleWorkItemConfidentiality() {
@@ -84,6 +155,85 @@ 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.$emit('error', error.message);
+ Sentry.captureException(error);
+ });
+ },
+ throwConvertError() {
+ this.$emit('error', this.i18n.convertError);
+ },
+ async promoteToObjective() {
+ try {
+ const {
+ data: {
+ workItemConvert: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: convertWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ workItemTypeId: this.objectiveWorkItemTypeId,
+ },
+ },
+ });
+ if (errors.length > 0) {
+ this.throwConvertError();
+ return;
+ }
+ this.$toast.show(s__('WorkItem|Promoted to objective.'));
+ this.track('promote_kr_to_objective');
+ } catch (error) {
+ this.throwConvertError();
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
@@ -99,9 +249,34 @@ 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
+ :value="subscribedToNotifications"
+ :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>
+ <gl-dropdown-item
+ v-if="canPromoteToObjective"
+ :data-testid="$options.promoteActionTestId"
+ @click="promoteToObjective"
+ >
+ {{ __('Promote to objective') }}
+ </gl-dropdown-item>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
- data-testid="confidentiality-toggle-action"
+ :data-testid="$options.confidentialityTestId"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
@@ -114,7 +289,8 @@ 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
>
</gl-dropdown>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index fc4c05d96b2..4e6583b65f8 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -54,6 +54,7 @@ export default {
GlIntersectionObserver,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -81,10 +82,6 @@ export default {
required: false,
default: false,
},
- fullPath: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -298,7 +295,7 @@ export default {
<div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap">
<span
:id="assigneesTitleId"
- class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="assignees-title"
>{{ assigneeText }}</span
>
diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
new file mode 100644
index 00000000000..91f87be1233
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
@@ -0,0 +1,144 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import {
+ EMOJI_ACTION_REMOVE,
+ EMOJI_ACTION_ADD,
+ WIDGET_TYPE_AWARD_EMOJI,
+ EMOJI_THUMBSDOWN,
+ EMOJI_THUMBSUP,
+} from '../constants';
+
+export default {
+ defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
+ isLoggedIn: isLoggedIn(),
+ components: {
+ AwardsList,
+ },
+ props: {
+ workItem: {
+ type: Object,
+ required: true,
+ },
+ awardEmoji: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ /**
+ * Parse and convert award emoji list to a format that AwardsList can understand
+ */
+ awards() {
+ return this.awardEmoji.nodes.map((emoji, index) => ({
+ id: index + 1,
+ name: emoji.name,
+ user: {
+ id: getIdFromGraphQLId(emoji.user.id),
+ },
+ }));
+ },
+ },
+ methods: {
+ handleAward(name) {
+ // Decide action based on emoji is already present
+ const action =
+ this.awards.findIndex((emoji) => emoji.name === name) > -1
+ ? EMOJI_ACTION_REMOVE
+ : EMOJI_ACTION_ADD;
+ const inputVariables = {
+ id: this.workItem.id,
+ awardEmojiWidget: {
+ action,
+ name,
+ },
+ };
+
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: this.getOptimisticResponse({ name, action }),
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ },
+ )
+ .catch((error) => {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ });
+ },
+ /**
+ * Prepare workItemUpdate for optimistic response
+ */
+ getOptimisticResponse({ name, action }) {
+ let awardEmojiNodes = [
+ ...this.awardEmoji.nodes,
+ {
+ name,
+ __typename: 'AwardEmoji',
+ user: {
+ id: convertToGraphQLId(TYPENAME_USER, this.currentUserId),
+ __typename: 'UserCore',
+ },
+ },
+ ];
+ // Exclude the award emoji node in case of remove action
+ if (action === EMOJI_ACTION_REMOVE) {
+ awardEmojiNodes = [...this.awardEmoji.nodes.filter((emoji) => emoji.name !== name)];
+ }
+ return {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...this.workItem,
+ widgets: [
+ {
+ type: WIDGET_TYPE_AWARD_EMOJI,
+ awardEmoji: {
+ nodes: awardEmojiNodes,
+ __typename: 'AwardEmojiConnection',
+ },
+ __typename: 'WorkItemWidgetAwardEmoji',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-3">
+ <awards-list
+ data-testid="work-item-award-list"
+ :awards="awards"
+ :can-award-emoji="$options.isLoggedIn"
+ :current-user-id="currentUserId"
+ :default-awards="$options.defaultAwards"
+ selected-class="selected"
+ @award="handleAward"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
index d1a707f2a8a..78a86aa49a4 100644
--- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue
+++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
@@ -2,7 +2,7 @@
import { GlAvatarLink, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
export default {
components: {
@@ -10,26 +10,13 @@ export default {
GlSprintf,
TimeAgoTooltip,
},
+ inject: ['fullPath'],
props: {
- fetchByIid: {
- type: Boolean,
- required: true,
- },
- workItemId: {
- type: String,
- required: false,
- default: null,
- },
workItemIid: {
type: String,
required: false,
default: null,
},
- fullPath: {
- type: String,
- required: false,
- default: null,
- },
},
computed: {
createdAt() {
@@ -44,31 +31,21 @@ export default {
authorId() {
return getIdFromGraphQLId(this.author.id);
},
- queryVariables() {
- return this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: this.workItemIid,
- }
- : {
- id: this.workItemId,
- };
- },
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
skip() {
- return !this.workItemId && !this.workItemIid;
+ return !this.workItemIid;
},
update(data) {
- const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- return workItem ?? {};
+ return data.workspace.workItems.nodes[0] ?? {};
},
},
},
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 399c220bc96..a4cbc430b84 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -10,9 +10,10 @@ import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
-import { getWorkItemQuery, autocompleteDataSources, markdownPreviewPath } from '../utils';
+import { autocompleteDataSources, markdownPreviewPath } from '../utils';
import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
@@ -27,24 +28,16 @@ export default {
WorkItemDescriptionRendered,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
},
markdownDocsPath: helpPagePath('user/project/quick_actions'),
quickActionsDocsPath: helpPagePath('user/project/quick_actions'),
@@ -55,7 +48,6 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
- descriptionHtml: '',
conflictedDescription: '',
formFieldProps: {
'aria-label': __('Description'),
@@ -67,26 +59,19 @@ export default {
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- },
- skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return data.workspace.workItems.nodes[0];
},
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 +133,11 @@ export default {
},
},
methods: {
+ checkForConflicts() {
+ if (this.descriptionText !== this.workItemDescription?.description) {
+ this.conflictedDescription = this.workItemDescription?.description;
+ }
+ },
async startEditing() {
this.isEditing = true;
@@ -254,8 +244,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
enable-autocomplete
supports-quick-actions
- init-on-autofocus
- use-bottom-toolbar
+ 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 262c093a1d0..0f1af44e8a1 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -14,19 +14,23 @@ import {
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
-import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_NOTIFICATIONS,
+ WIDGET_TYPE_CURRENT_USER_TODOS,
WIDGET_TYPE_DESCRIPTION,
+ WIDGET_TYPE_AWARD_EMOJI,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_PROGRESS,
@@ -39,20 +43,23 @@ 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';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import { findHierarchyWidgetChildren } from '../utils';
import WorkItemTree from './work_item_links/work_item_tree.vue';
import WorkItemActions from './work_item_actions.vue';
+import WorkItemTodos from './work_item_todos.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemCreatedUpdated from './work_item_created_updated.vue';
import WorkItemDescription from './work_item_description.vue';
+import WorkItemAwardEmoji from './work_item_award_emoji.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
@@ -65,6 +72,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ isLoggedIn: isLoggedIn(),
components: {
GlAlert,
GlBadge,
@@ -75,8 +83,10 @@ export default {
GlEmptyState,
WorkItemAssignees,
WorkItemActions,
+ WorkItemTodos,
WorkItemCreatedUpdated,
WorkItemDescription,
+ WorkItemAwardEmoji,
WorkItemDueDate,
WorkItemLabels,
WorkItemTitle,
@@ -91,9 +101,10 @@ export default {
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
mixins: [glFeatureFlagMixin()],
- inject: ['fullPath'],
+ inject: ['fullPath', 'reportAbusePath'],
props: {
isModal: {
type: Boolean,
@@ -128,27 +139,34 @@ export default {
? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: null,
modalWorkItemIid: getParameterByName('work_item_iid'),
+ isReportDrawerOpen: false,
+ reportedUrl: '',
+ reportedUserId: 0,
};
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
skip() {
- return !this.workItemId && !this.workItemIid;
+ return !this.workItemIid;
},
update(data) {
- const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- return workItem ?? {};
+ return data.workspace.workItems.nodes[0] ?? {};
},
error() {
this.setEmptyState();
},
- result() {
+ result(res) {
+ // need to handle this when the res is loading: true, netWorkStatus: 1, partial: true
+ if (!res.data) {
+ return;
+ }
if (isEmpty(this.workItem)) {
this.setEmptyState();
}
@@ -210,26 +228,35 @@ export default {
},
computed: {
workItemLoading() {
- return this.$apollo.queries.workItem.loading;
+ return isEmpty(this.workItem) && this.$apollo.queries.workItem.loading;
},
workItemType() {
return this.workItem.workItemType?.name;
},
+ workItemTypeId() {
+ return this.workItem.workItemType?.id;
+ },
+ workItemBreadcrumbReference() {
+ return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : '';
+ },
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
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;
},
@@ -245,6 +272,9 @@ export default {
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
},
+ parentWorkItemReference() {
+ return this.parentWorkItem ? `${this.parentWorkItem.title} #${this.parentWorkItem.iid}` : '';
+ },
parentUrl() {
// Once more types are moved to have Work Items involved
// we need to handle this properly.
@@ -262,6 +292,18 @@ export default {
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
+ workItemNotificationsSubscribed() {
+ return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed);
+ },
+ workItemCurrentUserTodos() {
+ return this.isWidgetPresent(WIDGET_TYPE_CURRENT_USER_TODOS);
+ },
+ showWorkItemCurrentUserTodos() {
+ return this.$options.isLoggedIn && this.workItemCurrentUserTodos;
+ },
+ currentUserTodos() {
+ return this.workItemCurrentUserTodos?.currentUserTodos?.edges;
+ },
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
@@ -277,6 +319,9 @@ export default {
workItemProgress() {
return this.isWidgetPresent(WIDGET_TYPE_PROGRESS);
},
+ workItemAwardEmoji() {
+ return this.isWidgetPresent(WIDGET_TYPE_AWARD_EMOJI);
+ },
workItemHierarchy() {
return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY);
},
@@ -292,32 +337,21 @@ export default {
workItemNotes() {
return this.isWidgetPresent(WIDGET_TYPE_NOTES);
},
- fetchByIid() {
- return (
- (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) ||
- false
- );
- },
- queryVariables() {
- return this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: this.workItemIid,
- }
- : {
- id: this.workItemId,
- };
- },
children() {
- const widgetHierarchy = this.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
- return widgetHierarchy.children.nodes;
+ return this.workItem ? findHierarchyWidgetChildren(this.workItem) : [];
+ },
+ workItemBodyClass() {
+ return {
+ 'gl-pt-5': !this.updateError && !this.isModal,
+ };
},
},
mounted() {
if (this.modalWorkItemId || this.modalWorkItemIid) {
- this.openInModal(undefined, { id: this.modalWorkItemId, iid: this.modalWorkItemIid });
+ this.openInModal({
+ event: undefined,
+ modalWorkItem: { id: this.modalWorkItemId, iid: this.modalWorkItemIid },
+ });
}
},
methods: {
@@ -381,15 +415,15 @@ export default {
this.toggleChildFromCache(child, child.id, client);
},
toggleChildFromCache(workItem, childId, store) {
- const sourceData = store.readQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.queryVariables,
- });
+ const query = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.fullPath, iid: this.workItemIid },
+ };
+
+ const sourceData = store.readQuery(query);
const newData = produce(sourceData, (draftState) => {
- const widgets = this.fetchByIid
- ? draftState.workspace.workItems.nodes[0].widgets
- : draftState.workItem.widgets;
+ const { widgets } = draftState.workspace.workItems.nodes[0];
const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
@@ -397,15 +431,11 @@ export default {
if (index >= 0) {
widgetHierarchy.children.nodes.splice(index, 1);
} else {
- widgetHierarchy.children.nodes.unshift(workItem);
+ widgetHierarchy.children.nodes.push(workItem);
}
});
- store.writeQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.queryVariables,
- data: newData,
- });
+ store.writeQuery({ ...query, data: newData });
},
async updateWorkItem(workItem, childId, parentId) {
return this.$apollo.mutate({
@@ -428,15 +458,15 @@ export default {
this.activeToast?.hide();
}
},
- async removeChild(childId) {
+ async removeChild({ id }) {
try {
- const { data } = await this.updateWorkItem(null, childId, null);
+ const { data } = await this.updateWorkItem(null, id, null);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
action: {
text: s__('WorkItem|Undo'),
- onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId),
+ onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, id),
},
});
}
@@ -445,17 +475,16 @@ export default {
Sentry.captureException(error);
}
},
+ updateHasNotes() {
+ this.$emit('has-notes');
+ },
updateUrl(modalWorkItem) {
- const params = this.fetchByIid
- ? { work_item_iid: modalWorkItem?.iid }
- : { work_item_id: getIdFromGraphQLId(modalWorkItem?.id) };
-
updateHistory({
- url: setUrlParams(params),
+ url: setUrlParams({ work_item_iid: modalWorkItem?.iid }),
replace: true,
});
},
- openInModal(event, modalWorkItem) {
+ openInModal({ event, modalWorkItem }) {
if (!this.workItemsMvc2Enabled) {
return;
}
@@ -474,252 +503,270 @@ export default {
this.modalWorkItemIid = modalWorkItem.iid;
this.$refs.modal.show();
},
+ openReportAbuseDrawer(reply) {
+ if (this.isModal) {
+ this.$emit('openReportAbuse', reply);
+ } else {
+ this.toggleReportAbuseDrawer(true, reply);
+ }
+ },
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url || {};
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
},
+
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
</script>
<template>
- <section class="gl-pt-5">
- <gl-alert
- v-if="updateError"
- class="gl-mb-3"
- variant="danger"
- @dismiss="updateError = undefined"
- >
- {{ updateError }}
- </gl-alert>
-
- <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5">
- <gl-skeleton-loader :height="65" :width="240">
- <rect width="240" height="20" x="5" y="0" rx="4" />
- <rect width="100" height="20" x="5" y="45" rx="4" />
- </gl-skeleton-loader>
- </div>
- <template v-else>
- <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
- <ul
- v-if="parentWorkItem"
- class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0"
- data-testid="work-item-parent"
- >
- <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
- <gl-button
- v-gl-tooltip.hover
- class="gl-text-truncate gl-max-w-full"
- :icon="parentWorkItemIconName"
- category="tertiary"
- :href="parentUrl"
- :title="parentWorkItem.title"
- @click="openInModal($event, parentWorkItem)"
- >{{ parentWorkItem.title }}</gl-button
+ <section>
+ <section v-if="updateError" class="flash-container flash-container-page sticky">
+ <gl-alert class="gl-mb-3" variant="danger" @dismiss="updateError = undefined">
+ {{ updateError }}
+ </gl-alert>
+ </section>
+ <section :class="workItemBodyClass">
+ <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5">
+ <gl-skeleton-loader :height="65" :width="240">
+ <rect width="240" height="20" x="5" y="0" rx="4" />
+ <rect width="100" height="20" x="5" y="45" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <template v-else>
+ <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
+ <ul
+ v-if="parentWorkItem"
+ class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0"
+ data-testid="work-item-parent"
+ >
+ <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
+ <gl-button
+ v-gl-tooltip.hover
+ class="gl-text-truncate gl-max-w-full"
+ :icon="parentWorkItemIconName"
+ category="tertiary"
+ :href="parentUrl"
+ :title="parentWorkItemReference"
+ @click="openInModal({ event: $event, modalWorkItem: parentWorkItem })"
+ >{{ parentWorkItemReference }}</gl-button
+ >
+ <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
+ </li>
+ <li
+ class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
>
- <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
- </li>
- <li
- class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
+ {{ workItemBreadcrumbReference }}
+ </li>
+ </ul>
+ <div
+ v-else-if="!error && !workItemLoading"
+ class="gl-mr-auto"
+ data-testid="work-item-type"
>
<work-item-type-icon
:work-item-icon-name="workItemIconName"
:work-item-type="workItemType && workItemType.toUpperCase()"
/>
- {{ workItemType }}
- </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"
+ {{ workItemBreadcrumbReference }}
+ </div>
+ <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
+ <gl-badge
+ v-if="workItem.confidential"
+ v-gl-tooltip.bottom
+ :title="confidentialTooltip"
+ variant="warning"
+ icon="eye-slash"
+ class="gl-mr-3 gl-cursor-help"
+ >{{ __('Confidential') }}</gl-badge
+ >
+ <work-item-todos
+ v-if="showWorkItemCurrentUserTodos"
+ :work-item="workItem"
+ :current-user-todos="currentUserTodos"
+ @error="updateError = $event"
+ />
+ <work-item-actions
+ v-if="canUpdate || canDelete"
+ :work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
+ :work-item-type="workItemType"
+ :work-item-type-id="workItemTypeId"
+ :can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
+ @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
+ @toggleWorkItemConfidentiality="toggleConfidentiality"
+ @error="updateError = $event"
+ />
+ <gl-button
+ v-if="isModal"
+ category="tertiary"
+ data-testid="work-item-close"
+ icon="close"
+ :aria-label="__('Close')"
+ @click="$emit('close')"
+ />
+ </div>
+ <work-item-title
+ v-if="workItem.title"
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
/>
- <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
- <gl-badge
- v-if="workItem.confidential"
- v-gl-tooltip.bottom
- :title="confidentialTooltip"
- variant="warning"
- icon="eye-slash"
- class="gl-mr-3 gl-cursor-help"
- >{{ __('Confidential') }}</gl-badge
- >
- <work-item-actions
- v-if="canUpdate || canDelete"
+ <work-item-created-updated :work-item-iid="workItemIid" />
+ <work-item-state
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
:work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="workItemType"
- :can-delete="canDelete"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ @error="updateError = $event"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
:can-update="canUpdate"
- :is-confidential="workItem.confidential"
- :is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
- @toggleWorkItemConfidentiality="toggleConfidentiality"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
@error="updateError = $event"
/>
- <gl-button
- v-if="isModal"
- category="tertiary"
- data-testid="work-item-close"
- icon="close"
- :aria-label="__('Close')"
- @click="$emit('close')"
+ <work-item-due-date
+ v-if="workItemDueDate"
+ :can-update="canUpdate"
+ :due-date="workItemDueDate.dueDate"
+ :start-date="workItemDueDate.startDate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-milestone
+ v-if="workItemMilestone"
+ :work-item-id="workItem.id"
+ :work-item-milestone="workItemMilestone.milestone"
+ :work-item-type="workItemType"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-progress
+ v-if="workItemProgress"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :progress="workItemProgress.progress"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-iteration
+ v-if="workItemIteration"
+ class="gl-mb-5"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-health-status
+ v-if="workItemHealthStatus"
+ class="gl-mb-5"
+ :health-status="workItemHealthStatus.healthStatus"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-description
+ v-if="hasDescriptionWidget"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ class="gl-pt-5"
+ @error="updateError = $event"
+ />
+ <work-item-award-emoji
+ v-if="workItemAwardEmoji"
+ :work-item="workItem"
+ :award-emoji="workItemAwardEmoji.awardEmoji"
+ @error="updateError = $event"
+ />
+ <work-item-tree
+ v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ :work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :children="children"
+ :can-update="canUpdate"
+ :confidential="workItem.confidential"
+ @addWorkItemChild="addChild"
+ @removeChild="removeChild"
+ @show-modal="openInModal"
/>
- </div>
- <work-item-title
- v-if="workItem.title"
- :work-item-id="workItem.id"
- :work-item-title="workItem.title"
- :work-item-type="workItemType"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-created-updated
- :work-item-id="workItem.id"
- :work-item-iid="workItemIid"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- />
- <work-item-state
- :work-item="workItem"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-labels
- v-if="workItemLabels"
- :work-item-id="workItem.id"
- :can-update="canUpdate"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-due-date
- v-if="workItemDueDate"
- :can-update="canUpdate"
- :due-date="workItemDueDate.dueDate"
- :start-date="workItemDueDate.startDate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-milestone
- v-if="workItemMilestone"
- :work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.milestone"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :can-update="canUpdate"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-weight
- v-if="workItemWeight"
- class="gl-mb-5"
- :can-update="canUpdate"
- :weight="workItemWeight.weight"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-progress
- v-if="workItemProgress"
- class="gl-mb-5"
- :can-update="canUpdate"
- :progress="workItemProgress.progress"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-iteration
- v-if="workItemIteration"
- class="gl-mb-5"
- :iteration="workItemIteration.iteration"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-health-status
- v-if="workItemHealthStatus"
- class="gl-mb-5"
- :health-status="workItemHealthStatus.healthStatus"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-description
- v-if="hasDescriptionWidget"
- :work-item-id="workItem.id"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- class="gl-pt-5"
- @error="updateError = $event"
- />
- <work-item-tree
- v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
- :work-item-type="workItemType"
- :parent-work-item-type="workItem.workItemType.name"
- :work-item-id="workItem.id"
- :children="children"
- :can-update="canUpdate"
- :project-path="fullPath"
- :confidential="workItem.confidential"
- @addWorkItemChild="addChild"
- @removeChild="removeChild"
- @show-modal="openInModal"
- />
- <template v-if="workItemsMvcEnabled">
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
- :query-variables="queryVariables"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
+ :work-item-iid="workItem.iid"
:work-item-type="workItemType"
+ :is-modal="isModal"
+ :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
+ :can-set-work-item-metadata="canAssignUnassignUser"
+ :report-abuse-path="reportAbusePath"
class="gl-pt-5"
@error="updateError = $event"
+ @has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <gl-empty-state
+ v-if="error"
+ :title="$options.i18n.fetchErrorTitle"
+ :description="error"
+ :svg-path="noAccessSvgPath"
/>
</template>
- <gl-empty-state
- v-if="error"
- :title="$options.i18n.fetchErrorTitle"
- :description="error"
- :svg-path="noAccessSvgPath"
+ <work-item-detail-modal
+ v-if="!isModal"
+ ref="modal"
+ :work-item-id="modalWorkItemId"
+ :work-item-iid="modalWorkItemIid"
+ :show="true"
+ @close="updateUrl"
+ @openReportAbuse="toggleReportAbuseDrawer(true, $event)"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="true"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
- </template>
- <work-item-detail-modal
- v-if="!isModal"
- ref="modal"
- :work-item-id="modalWorkItemId"
- :work-item-iid="modalWorkItemIid"
- :show="true"
- @close="updateUrl"
- />
+ </section>
</section>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 1b8e97bf717..f8422dda211 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
@@ -1,12 +1,14 @@
<script>
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
-import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
+import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
export default {
+ WORK_ITEM_DETAIL_MODAL_ID: 'work-item-detail-modal',
i18n: {
errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'),
+ modalTitle: s__('WorkItem|Work item'),
},
components: {
GlAlert,
@@ -24,26 +26,6 @@ export default {
required: false,
default: null,
},
- issueGid: {
- type: String,
- required: false,
- default: '',
- },
- lockVersion: {
- type: Number,
- required: false,
- default: null,
- },
- lineNumberStart: {
- type: String,
- required: false,
- default: null,
- },
- lineNumberEnd: {
- type: String,
- required: false,
- default: null,
- },
},
emits: ['workItemDeleted', 'close', 'update-modal'],
data() {
@@ -51,6 +33,8 @@ export default {
error: undefined,
updatedWorkItemId: null,
updatedWorkItemIid: null,
+ isModalShown: false,
+ hasNotes: false,
};
},
computed: {
@@ -61,52 +45,15 @@ export default {
return this.updatedWorkItemIid || this.workItemIid;
},
},
- methods: {
- deleteWorkItem() {
- if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
- this.deleteWorkItemWithTaskData();
- } else {
- this.deleteWorkItemWithoutTaskData();
+ watch: {
+ hasNotes(newVal) {
+ if (newVal && this.isModalShown) {
+ scrollToTargetOnResize({ containerId: this.$options.WORK_ITEM_DETAIL_MODAL_ID });
}
},
- deleteWorkItemWithTaskData() {
- this.$apollo
- .mutate({
- mutation: deleteWorkItemFromTaskMutation,
- variables: {
- input: {
- id: this.issueGid,
- lockVersion: this.lockVersion,
- taskData: {
- id: this.workItemId,
- lineNumberStart: Number(this.lineNumberStart),
- lineNumberEnd: Number(this.lineNumberEnd),
- },
- },
- },
- })
- .then(
- ({
- data: {
- workItemDeleteTask: {
- workItem: { descriptionHtml },
- errors,
- },
- },
- }) => {
- if (errors?.length) {
- throw new Error(errors[0]);
- }
-
- this.$emit('workItemDeleted', descriptionHtml);
- this.hide();
- },
- )
- .catch((error) => {
- this.setErrorMessage(error.message);
- });
- },
- deleteWorkItemWithoutTaskData() {
+ },
+ methods: {
+ deleteWorkItem() {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
@@ -128,6 +75,7 @@ export default {
this.updatedWorkItemId = null;
this.updatedWorkItemIid = null;
this.error = '';
+ this.isModalShown = false;
this.$emit('close');
},
hide() {
@@ -144,6 +92,16 @@ export default {
this.updatedWorkItemIid = workItem.iid;
this.$emit('update-modal', $event, workItem);
},
+ onModalShow() {
+ this.isModalShown = true;
+ },
+ updateHasNotes() {
+ this.hasNotes = true;
+ },
+ openReportAbuseDrawer(reply) {
+ this.hide();
+ this.$emit('openReportAbuse', reply);
+ },
},
};
</script>
@@ -151,13 +109,16 @@ export default {
<template>
<gl-modal
ref="modal"
+ static
hide-footer
size="lg"
- modal-id="work-item-detail-modal"
+ :modal-id="$options.WORK_ITEM_DETAIL_MODAL_ID"
header-class="gl-p-0 gl-pb-2!"
scrollable
- data-testid="work-item-detail-modal"
+ :title="$options.i18n.modalTitle"
+ :data-testid="$options.WORK_ITEM_DETAIL_MODAL_ID"
@hide="closeModal"
+ @shown="onModalShow"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
@@ -165,13 +126,14 @@ export default {
<work-item-detail
is-modal
- :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"
+ @has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
index 03c5b7096b2..3e546598dc2 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -223,7 +223,12 @@ export default {
@clear="clearStartDatePicker"
@close="handleStartDateInput"
/>
- <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate">
+ <gl-button
+ v-if="showStartDateButton"
+ category="tertiary"
+ class="gl-text-gray-500!"
+ @click="clickShowStartDate"
+ >
{{ $options.i18n.addStartDate }}
</gl-button>
</gl-form-group>
@@ -250,7 +255,12 @@ export default {
@clear="clearDueDatePicker"
@close="updateDates"
/>
- <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate">
+ <gl-button
+ v-if="showDueDateButton"
+ category="tertiary"
+ class="gl-text-gray-500!"
+ @click="clickShowDueDate"
+ >
{{ $options.i18n.addDueDate }}
</gl-button>
</gl-form-group>
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 8e9e1def0b9..015c86ba043 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -8,8 +8,8 @@ import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_it
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
-import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import {
i18n,
@@ -43,26 +43,18 @@ export default {
LabelItem,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- canUpdate: {
- type: Boolean,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
+ canUpdate: {
type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
required: true,
},
},
@@ -79,17 +71,18 @@ export default {
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index a7405b6d86c..636c9357170 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -9,21 +9,20 @@ export default function initWorkItemLinks() {
const workItemLinksRoot = document.querySelector('.js-work-item-links-root');
if (!workItemLinksRoot) {
- return;
+ return null;
}
const {
- projectPath,
+ fullPath,
wiHasIssueWeightsFeature,
- iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ wiReportAbusePath,
} = workItemLinksRoot.dataset;
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
apolloProvider,
@@ -31,14 +30,13 @@ export default function initWorkItemLinks() {
WorkItemLinks,
},
provide: {
- projectPath,
- iid,
- fullPath: projectPath,
+ fullPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ reportAbusePath: wiReportAbusePath,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
new file mode 100644
index 00000000000..4b6f581d76d
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
@@ -0,0 +1,242 @@
+<script>
+import produce from 'immer';
+import Draggable from 'vuedraggable';
+
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { defaultSortableOptions } from '~/sortable/constants';
+
+import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants';
+import { findHierarchyWidgets, getWorkItemQuery } from '../../utils';
+import workItemQuery from '../../graphql/work_item.query.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql';
+import WorkItemLinkChild from './work_item_link_child.vue';
+
+export default {
+ components: {
+ WorkItemLinkChild,
+ },
+ inject: ['fullPath'],
+ props: {
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ children: {
+ type: Array,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ prefetchedWorkItem: null,
+ };
+ },
+ computed: {
+ canReorder() {
+ return isLoggedIn() && this.canUpdate;
+ },
+ treeRootWrapper() {
+ return this.canReorder ? Draggable : 'div';
+ },
+ treeRootOptions() {
+ const options = {
+ ...defaultSortableOptions,
+ fallbackOnBody: false,
+ group: 'sortable-container',
+ tag: 'div',
+ 'ghost-class': 'tree-item-drag-active',
+ 'data-parent-id': this.workItemId,
+ value: this.children,
+ };
+
+ return this.canReorder ? options : {};
+ },
+ hasIndirectChildren() {
+ return this.children
+ .map((child) => findHierarchyWidgets(child.widgets) || {})
+ .some((hierarchy) => hierarchy.hasChildren);
+ },
+ queryVariables() {
+ return this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ }
+ : {
+ id: this.workItemId,
+ };
+ },
+ },
+ methods: {
+ addWorkItemQuery({ id, iid }) {
+ const variables = this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid,
+ }
+ : {
+ id,
+ };
+ this.$apollo.addSmartQuery('prefetchedWorkItem', {
+ query() {
+ return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ },
+ variables,
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ });
+ },
+ prefetchWorkItem({ id, iid }) {
+ if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) {
+ this.prefetch = setTimeout(
+ () => this.addWorkItemQuery({ id, iid }),
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+ }
+ },
+ clearPrefetching() {
+ if (this.prefetch) {
+ clearTimeout(this.prefetch);
+ }
+ },
+ getReorderParams({ oldIndex, newIndex }) {
+ let relativePosition;
+
+ // adjacentWorkItemId is always the item that's at the position
+ // where target was moved.
+ const adjacentWorkItemId = this.children[newIndex].id;
+
+ if (newIndex === 0) {
+ // If newIndex is `0`, item was moved to the top.
+ // Adjacent reference will be the one which is currently at the top,
+ // and it's relative position with respect to target's new position is `BEFORE`.
+ relativePosition = 'BEFORE';
+ } else if (newIndex === this.children.length - 1) {
+ // If newIndex is last position in list, item was moved to the bottom.
+ // Adjacent reference will be the one which is currently at the bottom,
+ // and it's relative position with respect to target's new position is `AFTER`.
+ relativePosition = 'AFTER';
+ } else if (oldIndex < newIndex) {
+ // If newIndex is neither top nor bottom, it was moved somewhere in the middle.
+ // Adjacent reference will be the one which is currently at that position,
+
+ // when the item is moved down, the newIndex is after the adjacent reference.
+ relativePosition = 'AFTER';
+ } else {
+ // when the item is moved up, the newIndex is before the adjacent reference.
+ relativePosition = 'BEFORE';
+ }
+
+ return {
+ relativePosition,
+ adjacentWorkItemId,
+ };
+ },
+ handleDragOnEnd(params) {
+ const { oldIndex, newIndex } = params;
+
+ if (oldIndex === newIndex) return;
+
+ const targetItem = this.children[oldIndex];
+
+ const updatedChildren = this.children.slice();
+ updatedChildren.splice(oldIndex, 1);
+ updatedChildren.splice(newIndex, 0, targetItem);
+
+ this.$apollo
+ .mutate({
+ mutation: reorderWorkItem,
+ variables: {
+ input: {
+ id: targetItem.id,
+ hierarchyWidget: this.getReorderParams({ oldIndex, newIndex }),
+ },
+ },
+ update: (store) => {
+ store.updateQuery(
+ { query: getWorkItemQuery(this.fetchByIid), variables: this.queryVariables },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const widgets = this.fetchByIid
+ ? draftData.workspace.workItems.nodes[0].widgets
+ : draftData.workItem.widgets;
+ const hierarchyWidget = findHierarchyWidgets(widgets);
+ hierarchyWidget.children.nodes = updatedChildren;
+ }),
+ );
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ __typename: 'WorkItemUpdatePayload',
+ errors: [],
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ },
+ )
+ .catch((error) => {
+ this.updateError = error.message;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="treeRootWrapper"
+ v-bind="treeRootOptions"
+ :class="{ 'gl-cursor-grab sortable-container': canReorder }"
+ @end="handleDragOnEnd"
+ >
+ <work-item-link-child
+ v-for="child in children"
+ :key="child.id"
+ :can-update="canUpdate"
+ :issuable-gid="workItemId"
+ :child-item="child"
+ :confidential="child.confidential"
+ :work-item-type="workItemType"
+ :has-indirect-children="hasIndirectChildren"
+ @mouseover="prefetchWorkItem(child)"
+ @mouseout="clearPrefetching"
+ @removeChild="$emit('removeChild', $event)"
+ @click="$emit('show-modal', { event: $event, child: $event.childItem || child })"
+ />
+ </component>
+</template>
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 3a3a846bce5..401c8a53eb0 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,12 +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 { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+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,
@@ -25,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
+ GlLabel,
GlLink,
GlButton,
GlIcon,
@@ -36,11 +38,8 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['fullPath'],
props: {
- projectPath: {
- type: String,
- required: true,
- },
canUpdate: {
type: Boolean,
required: true,
@@ -69,9 +68,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;
},
@@ -110,10 +118,7 @@ export default {
return this.isItemOpen ? __('Created') : __('Closed');
},
childPath() {
- return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`;
- },
- hasChildren() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren;
+ return `${gon?.relative_url_root || ''}/${this.fullPath}/-/work_items/${this.childItem.iid}`;
},
chevronType() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
@@ -134,6 +139,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;
@@ -165,105 +181,178 @@ export default {
this.isLoadingChildren = false;
}
},
+ showScopedLabel(label) {
+ return isScopedLabel(label) && this.allowsScopedLabels;
+ },
+ async removeChild({ id }) {
+ this.cloneChildren();
+ this.isLoadingChildren = true;
+
+ try {
+ const { data } = await this.updateWorkItem(id, null);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.filterRemovedChild(id);
+
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, id),
+ },
+ });
+ }
+ } 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>
<template>
- <div>
+ <div class="tree-item">
<div
- class="gl-display-flex gl-align-items-flex-start gl-mb-3"
+ class="gl-display-flex gl-align-items-flex-start"
:class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }"
>
<gl-button
v-if="hasChildren"
- v-gl-tooltip.viewport
+ v-gl-tooltip.hover
:title="chevronTooltip"
:aria-label="chevronTooltip"
:icon="chevronType"
category="tertiary"
+ size="small"
:loading="isLoadingChildren"
class="gl-px-0! gl-py-3! gl-mr-3"
data-testid="expand-child"
@click="toggleItem"
/>
<div
- class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
+ 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-mr-3"
- :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-3"
- />
</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"
data-testid="links-menu"
- @removeChild="$emit('removeChild', childItem.id)"
+ @removeChild="$emit('removeChild', childItem)"
/>
</div>
</div>
</div>
<work-item-tree-children
v-if="isExpanded"
- :project-path="projectPath"
:can-update="canUpdate"
: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 6974804523a..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-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-mt-3 gl-sm-mt-0 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.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index e8578a6d49a..5728e33880e 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -4,28 +4,22 @@ import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
-import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
-import {
- FORM_TYPES,
- WIDGET_ICONS,
- WIDGET_TYPE_HIERARCHY,
- WORK_ITEM_STATUS_TEXT,
-} from '../../constants';
-import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants';
+import { findHierarchyWidgetChildren, getWorkItemQuery } from '../../utils';
import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
-import WorkItemLinkChild from './work_item_link_child.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
+import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
export default {
components: {
@@ -34,21 +28,16 @@ export default {
GlIcon,
GlLoadingIcon,
WidgetWrapper,
- WorkItemLinkChild,
WorkItemLinksForm,
WorkItemDetailModal,
+ AbuseCategorySelector,
+ WorkItemChildrenWrapper,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
- inject: ['projectPath', 'iid'],
+ inject: ['fullPath', 'reportAbusePath'],
props: {
- workItemId: {
- type: String,
- required: false,
- default: null,
- },
issuableId: {
type: Number,
required: false,
@@ -57,12 +46,17 @@ export default {
},
apollo: {
workItem: {
- query: getWorkItemLinksQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
return {
id: this.issuableGid,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return !this.issuableId;
},
@@ -70,10 +64,8 @@ export default {
this.error = e.message || this.$options.i18n.fetchError;
},
async result() {
- const { id, iid } = this.childUrlParams;
- this.activeChild = this.fetchByIid
- ? this.children.find((child) => child.iid === iid) ?? {}
- : this.children.find((child) => child.id === id) ?? {};
+ const iid = getParameterByName('work_item_iid');
+ this.activeChild = this.children.find((child) => child.iid === iid) ?? {};
await this.$nextTick();
if (!isEmpty(this.activeChild)) {
this.$refs.modal.show();
@@ -86,13 +78,10 @@ export default {
query: getIssueDetailsQuery,
variables() {
return {
- fullPath: this.projectPath,
- iid: String(this.iid),
+ id: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId),
};
},
- update(data) {
- return data.workspace?.issuable;
- },
+ update: (data) => data.issue,
},
},
data() {
@@ -105,9 +94,15 @@ export default {
parentIssue: null,
formType: null,
workItem: null,
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
};
},
computed: {
+ fetchByIid() {
+ return false;
+ },
confidential() {
return this.parentIssue?.confidential || this.workItem?.confidential || false;
},
@@ -118,14 +113,14 @@ export default {
return this.parentIssue?.milestone;
},
children() {
- return (
- this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
- .nodes ?? []
- );
+ return this.workItem ? findHierarchyWidgetChildren(this.workItem) : [];
},
canUpdate() {
return this.workItem?.userPermissions.updateWorkItem || false;
},
+ canAddTask() {
+ return this.workItem?.userPermissions.adminParentLink || false;
+ },
// Only used for children for now but should be extended later to support parents and siblings
isChildrenEmpty() {
return this.children?.length === 0;
@@ -142,29 +137,9 @@ export default {
childrenCountLabel() {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
- },
- childUrlParams() {
- const params = {};
- if (this.fetchByIid) {
- const iid = getParameterByName('work_item_iid');
- if (iid) {
- params.iid = iid;
- }
- } else {
- const workItemId = getParameterByName('work_item_id');
- if (workItemId) {
- params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
- }
- }
- return params;
- },
},
mounted() {
- if (!isEmpty(this.childUrlParams)) {
- this.addWorkItemQuery(this.childUrlParams);
- }
+ this.addWorkItemQuery(getParameterByName('work_item_iid'));
},
methods: {
showAddForm(formType) {
@@ -178,11 +153,11 @@ export default {
hideAddForm() {
this.isShownAddForm = false;
},
- openChild(child, e) {
- if (isMetaKey(e)) {
+ openChild({ event, child }) {
+ if (isMetaKey(event)) {
return;
}
- e.preventDefault();
+ event.preventDefault();
this.activeChild = child;
this.$refs.modal.show();
this.updateWorkItemIdUrlQuery(child);
@@ -195,11 +170,8 @@ export default {
this.removeHierarchyChild(child);
this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
},
- updateWorkItemIdUrlQuery({ id, iid } = {}) {
- const params = this.fetchByIid
- ? { work_item_iid: iid }
- : { work_item_id: getIdFromGraphQLId(id) };
- updateHistory({ url: setUrlParams(params), replace: true });
+ updateWorkItemIdUrlQuery({ iid } = {}) {
+ updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true });
},
async addHierarchyChild(workItem) {
return this.$apollo.mutate({
@@ -213,29 +185,26 @@ export default {
variables: { id: this.issuableGid, workItem },
});
},
- async updateWorkItem(workItem, childId, parentId) {
- const response = await this.$apollo.mutate({
+ async undoChildRemoval(workItem, childId) {
+ const { data } = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ variables: { input: { id: childId, hierarchyWidget: { parentId: this.issuableGid } } },
});
- if (parentId === null) {
- await this.removeHierarchyChild(workItem);
- } else {
- await this.addHierarchyChild(workItem);
- }
-
- return response;
- },
- async undoChildRemoval(workItem, childId) {
- const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid);
+ await this.addHierarchyChild(workItem);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast?.hide();
}
},
- async removeChild(childId) {
- const { data } = await this.updateWorkItem({ id: childId }, childId, null);
+ async removeChild(workItem) {
+ const childId = workItem.id;
+ const { data } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
+ });
+
+ await this.removeHierarchyChild(workItem);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
@@ -246,37 +215,42 @@ export default {
});
}
},
- addWorkItemQuery({ id, iid }) {
- const variables = this.fetchByIid
- ? {
- fullPath: this.projectPath,
- iid,
- }
- : {
- id,
- };
+ addWorkItemQuery(iid) {
+ if (!iid) {
+ return;
+ }
+
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query() {
- return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid,
},
- variables,
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
context: {
isSingleRequest: true,
},
});
},
- prefetchWorkItem({ id, iid }) {
+ prefetchWorkItem({ iid }) {
this.prefetch = setTimeout(
- () => this.addWorkItemQuery({ id, iid }),
+ () => this.addWorkItemQuery(iid),
DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
);
},
clearPrefetching() {
clearTimeout(this.prefetch);
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
i18n: {
title: s__('WorkItem|Tasks'),
@@ -304,16 +278,16 @@ export default {
<template #header>{{ $options.i18n.title }}</template>
<template #header-suffix>
<span
- class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3 gl-font-weight-bold gl-text-gray-500"
data-testid="children-count"
>
- <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" />
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2" />
{{ childrenCountLabel }}
</span>
</template>
<template #header-right>
<gl-dropdown
- v-if="canUpdate"
+ v-if="canUpdate && canAddTask"
right
size="small"
:text="$options.i18n.addChildButtonLabel"
@@ -334,11 +308,11 @@ export default {
</gl-dropdown>
</template>
<template #body>
- <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
+ <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
<template v-else>
<div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
- <p class="gl-mb-3">
+ <p class="gl-px-3 gl-py-2 gl-mb-0 gl-text-gray-500">
{{ $options.i18n.emptyStateMessage }}
</p>
</div>
@@ -356,17 +330,12 @@ export default {
@cancel="hideAddForm"
@addWorkItemChild="addHierarchyChild"
/>
- <work-item-link-child
- v-for="child in children"
- :key="child.id"
- :project-path="projectPath"
+ <work-item-children-wrapper
+ :children="children"
:can-update="canUpdate"
- :issuable-gid="issuableGid"
- :child-item="child"
- @click="openChild(child, $event)"
- @mouseover="prefetchWorkItem(child)"
- @mouseout="clearPrefetching"
+ :work-item-id="issuableGid"
@removeChild="removeChild"
+ @show-modal="openChild"
/>
<work-item-detail-modal
ref="modal"
@@ -374,6 +343,14 @@ export default {
:work-item-iid="activeChild.iid"
@close="closeModal"
@workItemDeleted="handleWorkItemDeleted(activeChild)"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</template>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 5169a77dd33..51c83784d06 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -41,7 +41,7 @@ export default {
GlFormCheckbox,
GlTooltip,
},
- inject: ['projectPath', 'hasIterationsFeature'],
+ inject: ['fullPath', 'hasIterationsFeature'],
props: {
issuableGid: {
type: String,
@@ -88,7 +88,7 @@ export default {
query: projectWorkItemTypesQuery,
variables() {
return {
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
};
},
update(data) {
@@ -99,7 +99,7 @@ export default {
query: projectWorkItemsQuery,
variables() {
return {
- projectPath: this.projectPath,
+ fullPath: this.fullPath,
searchTerm: this.search?.title || this.search,
types: [this.childrenType],
in: this.search ? 'TITLE' : undefined,
@@ -131,7 +131,7 @@ export default {
workItemInput() {
let workItemInput = {
title: this.search?.title || this.search,
- projectPath: this.projectPath,
+ projectPath: this.fullPath,
workItemTypeId: this.childWorkItemType,
hierarchyWidget: {
parentId: this.issuableGid,
@@ -340,7 +340,7 @@ export default {
<template>
<gl-form
- class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-bg-white gl-mt-1 gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
@submit.prevent="addOrCreateMethod"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
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 1aa4a433a58..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-2">
- <gl-dropdown category="tertiary" toggle-class="btn-icon" :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 aa12df424f1..cbca78e4b14 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
@@ -1,26 +1,16 @@
<script>
-import { isEmpty } from 'lodash';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
import {
FORM_TYPES,
WIDGET_TYPE_HIERARCHY,
WORK_ITEMS_TREE_TEXT_MAP,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
- WORK_ITEM_TYPE_VALUE_OBJECTIVE,
} from '../../constants';
-import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import OkrActionsSplitButton from './okr_actions_split_button.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
-import WorkItemLinkChild from './work_item_link_child.vue';
+import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
export default {
FORM_TYPES,
@@ -31,9 +21,9 @@ export default {
OkrActionsSplitButton,
WidgetWrapper,
WorkItemLinksForm,
- WorkItemLinkChild,
+ WorkItemChildrenWrapper,
},
- mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
props: {
workItemType: {
type: String,
@@ -48,6 +38,11 @@ export default {
type: String,
required: true,
},
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
confidential: {
type: Boolean,
required: false,
@@ -63,10 +58,6 @@ export default {
required: false,
default: false,
},
- projectPath: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -77,9 +68,6 @@ export default {
};
},
computed: {
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
- },
childrenIds() {
return this.children.map((c) => c.id);
},
@@ -90,26 +78,6 @@ export default {
)
.some((hierarchy) => hierarchy.hasChildren);
},
- childUrlParams() {
- const params = {};
- if (this.fetchByIid) {
- const iid = getParameterByName('work_item_iid');
- if (iid) {
- params.iid = iid;
- }
- } else {
- const workItemId = getParameterByName('work_item_id');
- if (workItemId) {
- params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
- }
- }
- return params;
- },
- },
- mounted() {
- if (!isEmpty(this.childUrlParams)) {
- this.addWorkItemQuery(this.childUrlParams);
- }
},
methods: {
showAddForm(formType, childType) {
@@ -124,41 +92,28 @@ export default {
hideAddForm() {
this.isShownAddForm = false;
},
- addWorkItemQuery({ id, iid }) {
- const variables = this.fetchByIid
- ? {
- fullPath: this.projectPath,
- iid,
- }
- : {
- id,
- };
+ showModal({ event, child }) {
+ this.$emit('show-modal', { event, modalWorkItem: child });
+ },
+ addWorkItemQuery(iid) {
+ if (!iid) {
+ return;
+ }
+
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query() {
- return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid,
},
- variables,
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
context: {
isSingleRequest: true,
},
});
},
- prefetchWorkItem({ id, iid }) {
- if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) {
- this.prefetch = setTimeout(
- () => this.addWorkItemQuery({ id, iid }),
- DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
- );
- }
- },
- clearPrefetching() {
- if (this.prefetch) {
- clearTimeout(this.prefetch);
- }
- },
},
};
</script>
@@ -170,6 +125,7 @@ export default {
</template>
<template #header-right>
<okr-actions-split-button
+ v-if="canUpdate"
@showCreateObjectiveForm="
showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE)
"
@@ -186,7 +142,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>
@@ -203,20 +159,15 @@ export default {
@addWorkItemChild="$emit('addWorkItemChild', $event)"
@cancel="hideAddForm"
/>
- <work-item-link-child
- v-for="child in children"
- :key="child.id"
- :project-path="projectPath"
+ <work-item-children-wrapper
+ :children="children"
:can-update="canUpdate"
- :issuable-gid="workItemId"
- :child-item="child"
- :confidential="child.confidential"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
:work-item-type="workItemType"
- :has-indirect-children="hasIndirectChildren"
- @mouseover="prefetchWorkItem(child)"
- @mouseout="clearPrefetching"
+ fetch-by-iid
@removeChild="$emit('removeChild', $event)"
- @click="$emit('show-modal', $event, $event.childItem || child)"
+ @show-modal="showModal"
/>
</template>
</widget-wrapper>
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 71de6867680..2cabf489bc6 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,13 +1,9 @@
<script>
-import { createAlert } from '~/flash';
-import { s__ } from '~/locale';
-
-import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-
export default {
components: {
WorkItemLinkChild: () => import('./work_item_link_child.vue'),
},
+ inject: ['fullPath'],
props: {
workItemType: {
type: String,
@@ -27,42 +23,20 @@ export default {
required: false,
default: false,
},
- projectPath: {
- type: String,
- 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"
- :project-path="projectPath"
:can-update="canUpdate"
:issuable-gid="workItemId"
:child-item="child"
:work-item-type="workItemType"
- @removeChild="updateWorkItem"
+ @removeChild="$emit('removeChild', $event)"
@click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
index 6ed230b8ad4..693397686d0 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -46,6 +46,7 @@ export default {
GlDropdownText,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -66,19 +67,6 @@ export default {
required: false,
default: false,
},
- fullPath: {
- type: String,
- required: true,
- },
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
},
data() {
return {
@@ -234,6 +222,7 @@ export default {
<gl-dropdown
v-else
id="milestone-value"
+ data-testid="work-item-milestone-dropdown"
class="gl-pl-0 gl-max-w-full"
:toggle-class="dropdownClasses"
:text="dropdownText"
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 02b94c5331c..092b90a5731 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -1,21 +1,36 @@
<script>
import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { s__, __ } from '~/locale';
+import { uniqueId } from 'lodash';
+import { __ } from '~/locale';
+import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants';
import SystemNote from '~/work_items/components/notes/system_note.vue';
-import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
-import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
+import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue';
+import {
+ i18n,
+ DEFAULT_PAGE_SIZE_NOTES,
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
+} from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
-import { getWorkItemNotesQuery } from '~/work_items/utils';
+import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils';
+import {
+ updateCacheAfterCreatingNote,
+ updateCacheAfterDeletingNote,
+} from '~/work_items/graphql/cache_utils';
+import { getLocationHash } from '~/lib/utils/url_utility';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
+import WorkItemHistoryOnlyFilterNote from '~/work_items/components/notes/work_item_history_only_filter_note.vue';
+import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql';
+import workItemNoteUpdatedSubscription from '~/work_items/graphql/notes/work_item_note_updated.subscription.graphql';
+import workItemNoteDeletedSubscription from '~/work_items/graphql/notes/work_item_note_deleted.subscription.graphql';
import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql';
+import workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql';
import WorkItemAddNote from './notes/work_item_add_note.vue';
export default {
- i18n: {
- ACTIVITY_LABEL: s__('WorkItem|Activity'),
- },
loader: {
repeat: 10,
width: 1000,
@@ -24,21 +39,19 @@ export default {
components: {
GlSkeletonLoader,
GlModal,
- ActivityFilter,
SystemNote,
WorkItemAddNote,
WorkItemDiscussion,
+ WorkItemNotesActivityHeader,
+ WorkItemHistoryOnlyFilterNote,
},
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- queryVariables: {
- type: Object,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
@@ -46,18 +59,33 @@ export default {
type: String,
required: true,
},
- fetchByIid: {
+ isModal: {
type: Boolean,
required: false,
default: false,
},
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
isLoadingMore: false,
- perPage: DEFAULT_PAGE_SIZE_NOTES,
sortOrder: ASC,
noteToDelete: null,
+ discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`),
};
},
computed: {
@@ -73,70 +101,135 @@ export default {
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
- showLoadingMoreSkeleton() {
- return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
- },
- disableActivityFilter() {
+ disableActivityFilterSort() {
return this.initialLoading || this.isLoadingMore;
},
formAtTop() {
return this.sortOrder === DESC;
},
+ markdownPreviewPath() {
+ return markdownPreviewPath(this.fullPath, this.workItemIid);
+ },
+ autocompleteDataSources() {
+ return autocompleteDataSources(this.fullPath, this.workItemIid);
+ },
workItemCommentFormProps() {
return {
- queryVariables: this.queryVariables,
fullPath: this.fullPath,
workItemId: this.workItemId,
- fetchByIid: this.fetchByIid,
+ workItemIid: this.workItemIid,
workItemType: this.workItemType,
sortOrder: this.sortOrder,
+ isNewDiscussion: true,
+ markdownPreviewPath: this.markdownPreviewPath,
+ autocompleteDataSources: this.autocompleteDataSources,
};
},
notesArray() {
const notes = this.workItemNotes?.nodes || [];
+ const visibleNotes = notes.filter((note) => {
+ const isSystemNote = this.isSystemNote(note);
+
+ if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS && isSystemNote) {
+ return false;
+ }
+
+ if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_HISTORY && !isSystemNote) {
+ return false;
+ }
+
+ return true;
+ });
+
if (this.sortOrder === DESC) {
- return [...notes].reverse();
+ return [...visibleNotes].reverse();
}
- return notes;
+ return visibleNotes;
+ },
+ commentsDisabled() {
+ return this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_HISTORY;
+ },
+ targetNoteHash() {
+ return getLocationHash();
},
},
apollo: {
workItemNotes: {
- query() {
- return getWorkItemNotesQuery(this.fetchByIid);
- },
+ query: workItemNotesByIidQuery,
context: {
isSingleRequest: true,
},
variables() {
return {
- ...this.queryVariables,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
after: this.after,
pageSize: DEFAULT_PAGE_SIZE_NOTES,
};
},
update(data) {
- const workItemWidgets = this.fetchByIid
- ? data.workspace?.workItems?.nodes[0]?.widgets
- : data.workItem?.widgets;
- const discussionNodes =
- workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || [];
- return discussionNodes;
+ const widgets = data.workspace?.workItems?.nodes[0]?.widgets;
+ return widgets?.find((widget) => widget.type === 'NOTES')?.discussions || [];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
},
result() {
- this.updateSortingOrderIfApplicable();
-
if (this.hasNextPage) {
this.fetchMoreNotes();
+ } else if (this.targetNoteHash) {
+ if (this.isModal) {
+ this.$emit('has-notes');
+ } else {
+ scrollToTargetOnResize();
+ }
}
},
+ subscribeToMore: [
+ {
+ document: workItemNoteCreatedSubscription,
+ updateQuery(previousResult, { subscriptionData }) {
+ return updateCacheAfterCreatingNote(previousResult, subscriptionData);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteDeletedSubscription,
+ updateQuery(previousResult, { subscriptionData }) {
+ return updateCacheAfterDeletingNote(previousResult, subscriptionData);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteUpdatedSubscription,
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ },
+ ],
},
},
methods: {
@@ -148,30 +241,25 @@ export default {
isSystemNote(note) {
return note.notes.nodes[0].system;
},
- updateSortingOrderIfApplicable() {
- // when the sort order is DESC in local storage and there is only a single page, call
- // changeSortOrder manually
- if (
- this.changeNotesSortOrderAfterLoading &&
- this.perPage === DEFAULT_PAGE_SIZE_NOTES &&
- !this.hasNextPage
- ) {
- this.changeNotesSortOrder(DESC);
- }
- },
changeNotesSortOrder(direction) {
this.sortOrder = direction;
},
+ filterDiscussions(filterValue) {
+ this.discussionFilter = filterValue;
+ },
+ updateKey() {
+ this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`);
+ },
+ reportAbuse(isOpen, reply = {}) {
+ this.$emit('openReportAbuse', reply);
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
- // copied from discussions batch logic - every fetchMore call has a higher
- // amount of page size than the previous one with the limit being 100
- this.perPage = Math.min(Math.round(this.perPage * 1.5), 100);
await this.$apollo.queries.workItemNotes
.fetchMore({
variables: {
- ...this.queryVariables,
- pageSize: this.perPage,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
after: this.pageInfo?.endCursor,
},
})
@@ -223,17 +311,14 @@ export default {
<template>
<div class="gl-border-t gl-mt-5 work-item-notes">
- <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
- <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
- <activity-filter
- class="gl-min-h-5 gl-pb-3"
- :loading="disableActivityFilter"
- :sort-order="sortOrder"
- :work-item-type="workItemType"
- @changeSortOrder="changeNotesSortOrder"
- @updateSavedSortOrder="changeNotesSortOrder"
- />
- </div>
+ <work-item-notes-activity-header
+ :sort-order="sortOrder"
+ :disable-activity-filter-sort="disableActivityFilterSort"
+ :work-item-type="workItemType"
+ :discussion-filter="discussionFilter"
+ @changeSort="changeNotesSortOrder"
+ @changeFilter="filterDiscussions"
+ />
<div v-if="initialLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
@@ -248,13 +333,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"
- 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)"
@@ -265,26 +354,39 @@ export default {
<work-item-discussion
:key="getDiscussionKey(discussion)"
:discussion="discussion.notes.nodes"
- :query-variables="queryVariables"
- :full-path="fullPath"
:work-item-id="workItemId"
- :fetch-by-iid="fetchByIid"
+ :work-item-iid="workItemIid"
: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)"
+ @reportAbuse="reportAbuse(true, $event)"
@error="$emit('error', $event)"
/>
</template>
</template>
- <work-item-add-note
- v-if="!formAtTop"
- 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">
+ <template v-if="isLoadingMore">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue
new file mode 100644
index 00000000000..4e787720a42
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_todos.vue
@@ -0,0 +1,116 @@
+<script>
+import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { updateGlobalTodoCount } from '~/sidebar/utils';
+import { getWorkItemTodoOptimisticResponse } from '../utils';
+import { ADD, MARK_AS_DONE, TODO_ADD_ICON, TODO_DONE_ICON } from '../constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+
+export default {
+ i18n: {
+ addATodo: s__('WorkItem|Add a to do'),
+ markAsDone: s__('WorkItem|Mark as done'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ GlButton,
+ },
+ props: {
+ workItem: {
+ type: Object,
+ required: true,
+ },
+ currentUserTodos: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ buttonLabel:
+ this.currentUserTodos.length > 0
+ ? this.$options.i18n.markAsDone
+ : this.$options.i18n.addATodo,
+ };
+ },
+ computed: {
+ pendingTodo() {
+ return this.currentUserTodos.length > 0;
+ },
+ buttonIcon() {
+ return this.pendingTodo ? TODO_DONE_ICON : TODO_ADD_ICON;
+ },
+ },
+ methods: {
+ onToggle() {
+ this.isLoading = true;
+ this.buttonLabel = '';
+ const action = this.pendingTodo ? MARK_AS_DONE : ADD;
+ const inputVariables = {
+ id: this.workItem.id,
+ currentUserTodosWidget: {
+ action,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: getWorkItemTodoOptimisticResponse({
+ workItem: this.workItem,
+ pendingTodo: this.pendingTodo,
+ }),
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ if (this.pendingTodo) {
+ updateGlobalTodoCount(1);
+ this.buttonLabel = this.$options.i18n.markAsDone;
+ } else {
+ updateGlobalTodoCount(-1);
+ this.buttonLabel = this.$options.i18n.addATodo;
+ }
+ },
+ )
+ .catch((error) => {
+ this.$emit('error', error.message);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover
+ data-testid="work-item-todos-action"
+ :loading="isLoading"
+ :title="buttonLabel"
+ category="tertiary"
+ :aria-label="buttonLabel"
+ @click="onToggle"
+ >
+ <gl-icon
+ data-testid="work-item-todos-icon"
+ :class="{ 'gl-fill-blue-500': pendingTodo }"
+ :name="buttonIcon"
+ />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 81f9bf04bc8..6710c762c2e 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -1,5 +1,6 @@
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { ASC, DESC } from '~/notes/constants';
export const STATE_OPEN = 'OPEN';
export const STATE_CLOSED = 'CLOSED';
@@ -13,6 +14,9 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_AWARD_EMOJI = 'AWARD_EMOJI';
+export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS';
+export const WIDGET_TYPE_CURRENT_USER_TODOS = 'CURRENT_USER_TODOS';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
@@ -59,6 +63,9 @@ export const I18N_WORK_ITEM_ERROR_CREATING = s__(
export const I18N_WORK_ITEM_ERROR_UPDATING = s__(
'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.',
);
+export const I18N_WORK_ITEM_ERROR_CONVERTING = s__(
+ 'WorkItem|Something went wrong while promoting the %{workItemType}. Please try again.',
+);
export const I18N_WORK_ITEM_ERROR_DELETING = s__(
'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.',
);
@@ -176,3 +183,53 @@ export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
export const DEFAULT_PAGE_SIZE_NOTES = 30;
export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item';
+
+export const WORK_ITEM_NOTES_FILTER_ALL_NOTES = 'ALL_NOTES';
+export const WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS = 'ONLY_COMMENTS';
+export const WORK_ITEM_NOTES_FILTER_ONLY_HISTORY = 'ONLY_HISTORY';
+
+export const WORK_ITEM_NOTES_FILTER_KEY = 'filter_key_work_item';
+
+export const WORK_ITEM_ACTIVITY_FILTER_OPTIONS = [
+ {
+ key: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ text: s__('WorkItem|All activity'),
+ },
+ {
+ key: WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ text: s__('WorkItem|Comments only'),
+ testid: 'comments-activity',
+ },
+ {
+ key: WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
+ text: s__('WorkItem|History only'),
+ testid: 'history-activity',
+ },
+];
+
+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';
+export const TEST_ID_PROMOTE_ACTION = 'promote-action';
+
+export const ADD = 'ADD';
+export const MARK_AS_DONE = 'MARK_AS_DONE';
+export const TODO_ADD_ICON = 'todo-add';
+export const TODO_DONE_ICON = 'todo-done';
+export const TODO_TYPENAME = 'Todo';
+export const TODO_EDGE_TYPENAME = 'TodoEdge';
+export const TODO_CONNECTION_TYPENAME = 'TodoConnection';
+export const CURRENT_USER_TODOS_TYPENAME = 'WorkItemWidgetCurrentUserTodos';
+export const WORK_ITEM_TYPENAME = 'WorkItem';
+export const WORK_ITEM_UPDATE_PAYLOAD_TYPENAME = 'WorkItemUpdatePayload';
+
+export const EMOJI_ACTION_ADD = 'ADD';
+export const EMOJI_ACTION_REMOVE = 'REMOVE';
+export const EMOJI_THUMBSUP = 'thumbsup';
+export const EMOJI_THUMBSDOWN = 'thumbsdown';
diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
new file mode 100644
index 00000000000..85b88990cd6
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
@@ -0,0 +1,6 @@
+fragment AwardEmojiFragment on AwardEmoji {
+ name
+ user {
+ id
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 16b892b3476..455d8b8ae7b 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -1,62 +1,88 @@
import { produce } from 'immer';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
-import { getWorkItemNotesQuery } from '~/work_items/utils';
+
+const isNotesWidget = (widget) => widget.type === WIDGET_TYPE_NOTES;
+
+const getNotesWidgetFromSourceData = (draftData) =>
+ draftData?.workspace?.workItems?.nodes[0]?.widgets.find(isNotesWidget);
+
+const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => {
+ const noteWidgetIndex = draftData.workspace.workItems.nodes[0].widgets.findIndex(isNotesWidget);
+ draftData.workspace.workItems.nodes[0].widgets[noteWidgetIndex] = notesWidget;
+};
/**
- * Updates the cache manually when adding a main comment
+ * Work Item note create subscription update query callback
*
- * @param store
- * @param createNoteData
- * @param fetchByIid
- * @param queryVariables
- * @param sortOrder
+ * @param currentNotes
+ * @param subscriptionData
*/
-export const updateCommentState = (store, { data: { createNote } }, fetchByIid, queryVariables) => {
- const notesQuery = getWorkItemNotesQuery(fetchByIid);
- const variables = {
- ...queryVariables,
- pageSize: 100,
- };
- const sourceData = store.readQuery({
- query: notesQuery,
- variables,
+
+export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => {
+ if (!subscriptionData.data?.workItemNoteCreated) {
+ return currentNotes;
+ }
+ const newNote = subscriptionData.data.workItemNoteCreated;
+
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData);
+
+ if (!notesWidget.discussions) {
+ return;
+ }
+
+ const discussion = notesWidget.discussions.nodes.find((d) => d.id === newNote.discussion.id);
+
+ // handle the case where discussion already exists - we don't need to do anything, update will happen automatically
+ if (discussion) {
+ return;
+ }
+
+ notesWidget.discussions.nodes.push(newNote.discussion);
+ updateNotesWidgetDataInDraftData(draftData, notesWidget);
});
+};
+
+/**
+ * Work Item note delete subscription update query callback
+ *
+ * @param currentNotes
+ * @param subscriptionData
+ */
+
+export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => {
+ if (!subscriptionData.data?.workItemNoteDeleted) {
+ return currentNotes;
+ }
+ const deletedNote = subscriptionData.data.workItemNoteDeleted;
+ const { id, discussionId, lastDiscussionNote } = deletedNote;
- const finalData = produce(sourceData, (draftData) => {
- const notesWidget = fetchByIid
- ? draftData.workspace.workItems.nodes[0].widgets.find(
- (widget) => widget.type === WIDGET_TYPE_NOTES,
- )
- : draftData.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_NOTES);
-
- // as notes are currently sorted/reversed on the frontend rather than in the query
- // we only ever push.
- // const arrayPushMethod = sortOrder === ASC ? 'push' : 'unshift';
- const arrayPushMethod = 'push';
-
- // manual update of cache with a completely new discussion
- if (createNote.note.discussion.notes.nodes.length === 1) {
- notesWidget.discussions.nodes[arrayPushMethod]({
- id: createNote.note.discussion.id,
- notes: {
- nodes: createNote.note.discussion.notes.nodes,
- __typename: 'NoteConnection',
- },
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Discussion',
- });
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData);
+
+ if (!notesWidget.discussions) {
+ return;
}
- if (fetchByIid) {
- draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget;
+ const discussionIndex = notesWidget.discussions.nodes.findIndex(
+ (discussion) => discussion.id === discussionId,
+ );
+
+ if (discussionIndex === -1) {
+ return;
+ }
+
+ if (lastDiscussionNote) {
+ notesWidget.discussions.nodes.splice(discussionIndex, 1);
} else {
- draftData.workItem.widgets[6] = notesWidget;
+ const deletedThreadDiscussion = notesWidget.discussions.nodes[discussionIndex];
+ const deletedThreadIndex = deletedThreadDiscussion.notes.nodes.findIndex(
+ (note) => note.id === id,
+ );
+ deletedThreadDiscussion.notes.nodes.splice(deletedThreadIndex, 1);
+ notesWidget.discussions.nodes[discussionIndex] = deletedThreadDiscussion;
}
- });
- store.writeQuery({
- query: notesQuery,
- variables,
- data: finalData,
+ updateNotesWidgetDataInDraftData(draftData, notesWidget);
});
};
diff --git a/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql
deleted file mode 100644
index 32c07ed48c7..00000000000
--- a/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation workItemDeleteTask($input: WorkItemDeleteTaskInput!) {
- workItemDeleteTask(input: $input) {
- workItem {
- id
- descriptionHtml
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
index daeb58c0947..43dbe8fc2dd 100644
--- a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
@@ -1,12 +1,9 @@
-query issuableDetails($fullPath: ID!, $iid: String) {
- workspace: project(fullPath: $fullPath) {
+query issuableDetails($id: IssueID!) {
+ issue(id: $id) {
id
- issuable: issue(iid: $iid) {
+ confidential
+ milestone {
id
- confidential
- milestone {
- id
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
index 52a7a1f8e23..93616c39e55 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
@@ -9,6 +9,7 @@ fragment WorkItemNote on Note {
systemNoteIconName
createdAt
lastEditedAt
+ url
lastEditedBy {
...User
webPath
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
new file mode 100644
index 00000000000..dc51c53428b
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+mutation workItemNoteAddAwardEmoji($awardableId: AwardableID!, $name: String!) {
+ awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
+ awardEmoji {
+ name
+ description
+ unicode
+ emoji
+ unicodeVersion
+ user {
+ ...User
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
deleted file mode 100644
index 56dc175109f..00000000000
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
+++ /dev/null
@@ -1,27 +0,0 @@
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "./work_item_note.fragment.graphql"
-
-query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
- workItem(id: $id) {
- id
- iid
- widgets {
- ... on WorkItemWidgetNotes {
- type
- discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
- pageInfo {
- ...PageInfo
- }
- nodes {
- id
- notes {
- nodes {
- ...WorkItemNote
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
index fce10f6f2a6..7d63af448d4 100644
--- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -1,10 +1,10 @@
query projectWorkItems(
$searchTerm: String
- $projectPath: ID!
+ $fullPath: ID!
$types: [IssueType!]
$in: [IssuableSearchableField!]
) {
- workspace: project(fullPath: $projectPath) {
+ workspace: project(fullPath: $fullPath) {
id
workItems(search: $searchTerm, types: $types, in: $in) {
nodes {
diff --git a/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql
new file mode 100644
index 00000000000..13d10d5eef7
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql
@@ -0,0 +1,5 @@
+mutation workItemReorder($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ errors
+ }
+}
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..b045796579b 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,8 @@ fragment WorkItem on WorkItem {
userPermissions {
deleteWorkItem
updateWorkItem
+ setWorkItemMetadata @client
+ adminParentLink
}
widgets {
...WorkItemWidgets
diff --git a/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql b/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql
new file mode 100644
index 00000000000..0e4b87bdd67
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./work_item.fragment.graphql"
+
+mutation workItemConvert($input: WorkItemConvertInput!) {
+ workItemConvert(input: $input) {
+ workItem {
+ ...WorkItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
deleted file mode 100644
index 7d7bb9c7fc5..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ /dev/null
@@ -1,40 +0,0 @@
-query workItemLinksQuery($id: WorkItemID!) {
- workItem(id: $id) {
- id
- workItemType {
- id
- name
- }
- title
- userPermissions {
- deleteWorkItem
- updateWorkItem
- }
- confidential
- widgets {
- type
- ... on WorkItemWidgetHierarchy {
- type
- parent {
- id
- }
- children {
- nodes {
- id
- iid
- confidential
- workItemType {
- id
- name
- iconName
- }
- title
- state
- createdAt
- closedAt
- }
- }
- }
- }
- }
-}
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..42c057fb8fe 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
@@ -1,6 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
@@ -36,4 +37,28 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+
+ ... on WorkItemWidgetCurrentUserTodos {
+ type
+ currentUserTodos(state: pending) {
+ edges {
+ node {
+ id
+ state
+ }
+ }
+ }
+ }
+ ... on WorkItemWidgetAwardEmoji {
+ type
+ awardEmoji {
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
+ }
}
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..bf8dc9ce9b0 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
@@ -1,6 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
@@ -85,4 +86,27 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ ... on WorkItemWidgetCurrentUserTodos {
+ type
+ currentUserTodos(state: pending) {
+ edges {
+ node {
+ id
+ state
+ }
+ }
+ }
+ }
+ ... on WorkItemWidgetAwardEmoji {
+ type
+ awardEmoji {
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 6aa63aae172..70bda7d3783 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -18,6 +18,8 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
+ newCommentTemplatePath,
+ reportAbusePath,
} = el.dataset;
return new Vue({
@@ -27,7 +29,6 @@ export const initWorkItemsRoot = () => {
apolloProvider,
provide: {
fullPath,
- projectPath: fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
@@ -35,6 +36,8 @@ export const initWorkItemsRoot = () => {
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ newCommentTemplatePath,
+ reportAbusePath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 2245f984174..49ec12db4e1 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,13 +1,12 @@
<script>
import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { getPreferredLocales, s__ } from '~/locale';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import ItemTitle from '../components/item_title.vue';
@@ -22,7 +21,6 @@ export default {
ItemTitle,
GlFormSelect,
},
- mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
props: {
initialTitle: {
@@ -73,9 +71,6 @@ export default {
return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
},
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath;
- },
},
methods: {
async createWorkItem() {
@@ -96,45 +91,31 @@ export default {
},
update: (store, { data: { workItemCreate } }) => {
const { workItem } = workItemCreate;
- const data = this.fetchByIid
- ? {
- workspace: {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Project',
- id: workItem.project.id,
- workItems: {
- __typename: 'WorkItemConnection',
- nodes: [workItem],
- },
- },
- }
- : { workItem };
store.writeQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: workItem.iid,
- }
- : {
- id: workItem.id,
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid: workItem.iid,
+ },
+ data: {
+ workspace: {
+ __typename: TYPENAME_PROJECT,
+ id: workItem.project.id,
+ workItems: {
+ __typename: 'WorkItemConnection',
+ nodes: [workItem],
},
- data,
+ },
+ },
});
},
});
- const {
- data: {
- workItemCreate: {
- workItem: { id, iid },
- },
- },
- } = response;
- const routerParams = this.fetchByIid
- ? { name: 'workItem', params: { id: iid }, query: { iid_path: 'true' } }
- : { name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } };
- this.$router.push(routerParams);
+
+ this.$router.push({
+ name: 'workItem',
+ params: { id: response.data.workItemCreate.workItem.iid },
+ });
} catch {
this.error = this.createErrorText;
}
diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js
index 777badeb5be..8d67bcaf84f 100644
--- a/app/assets/javascripts/work_items/router/index.js
+++ b/app/assets/javascripts/work_items/router/index.js
@@ -11,6 +11,6 @@ export function createRouter(fullPath) {
return new VueRouter({
routes: routes(),
mode: 'history',
- base: joinPaths(fullPath, '-', 'work_items'),
+ base: joinPaths(gon?.relative_url_root, fullPath, '-', 'work_items'),
});
}
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index f2af87d476c..653819904af 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,19 +1,26 @@
-import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { uniqueId } from 'lodash';
+import {
+ WIDGET_TYPE_HIERARCHY,
+ WIDGET_TYPE_CURRENT_USER_TODOS,
+ CURRENT_USER_TODOS_TYPENAME,
+ TODO_CONNECTION_TYPENAME,
+ TODO_EDGE_TYPENAME,
+ TODO_TYPENAME,
+ WORK_ITEM_TYPENAME,
+ WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
+} from '~/work_items/constants';
import workItemQuery from './graphql/work_item.query.graphql';
import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
-import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql';
-import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql';
export function getWorkItemQuery(isFetchedByIid) {
return isFetchedByIid ? workItemByIidQuery : workItemQuery;
}
-export function getWorkItemNotesQuery(isFetchedByIid) {
- return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery;
-}
+export const findHierarchyWidgets = (widgets) =>
+ widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
export const findHierarchyWidgetChildren = (workItem) =>
- workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY).children.nodes;
+ findHierarchyWidgets(workItem.widgets).children.nodes;
const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => {
return `${
@@ -31,3 +38,38 @@ export const markdownPreviewPath = (fullPath, iid) =>
`${
gon.relative_url_root || ''
}/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;
+
+export const getWorkItemTodoOptimisticResponse = ({ workItem, pendingTodo }) => {
+ const todo = pendingTodo
+ ? [
+ {
+ node: {
+ id: -uniqueId(),
+ state: 'pending',
+ __typename: TODO_TYPENAME,
+ },
+ __typename: TODO_EDGE_TYPENAME,
+ },
+ ]
+ : [];
+ return {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...workItem,
+ widgets: [
+ {
+ type: WIDGET_TYPE_CURRENT_USER_TODOS,
+ currentUserTodos: {
+ edges: todo,
+ __typename: TODO_CONNECTION_TYPENAME,
+ },
+ __typename: CURRENT_USER_TODOS_TYPENAME,
+ },
+ ],
+ __typename: WORK_ITEM_TYPENAME,
+ },
+ __typename: WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
+ },
+ };
+};
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 134c2858849..6634d0cc617 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,13 +1,12 @@
-/* eslint-disable consistent-return */
-
// Zen Mode (full screen) textarea
//
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
+import autosize from 'autosize';
import Dropzone from 'dropzone';
import $ from 'jquery';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -39,6 +38,7 @@ export default class ZenMode {
constructor() {
this.active_backdrop = null;
this.active_textarea = null;
+ this.storedStyle = null;
$(document).on('click', '.js-zen-enter', (e) => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:enter');
@@ -53,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) {
@@ -68,6 +69,7 @@ export default class ZenMode {
this.active_backdrop.addClass('fullscreen');
this.active_textarea = this.active_backdrop.find('textarea');
// Prevent a user-resized textarea from persisting to fullscreen
+ this.storedStyle = this.active_textarea.attr('style');
this.active_textarea.removeAttr('style');
this.active_textarea.focus();
}
@@ -77,6 +79,11 @@ export default class ZenMode {
Mousetrap.unpause();
this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
scrollToElement(this.active_textarea, { duration: 0, offset: -100 });
+ this.active_textarea.attr('style', this.storedStyle);
+
+ autosize(this.active_textarea);
+ autosize.update(this.active_textarea);
+
this.active_textarea = null;
this.active_backdrop = null;
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index fa5d2bf7972..483c4dc226b 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,13 +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/ml_experiment_tracking';
@import './pages/merge_requests';
@import './pages/note_form';
@import './pages/notes';
@@ -16,4 +13,3 @@
@import './pages/projects';
@import './pages/registry';
@import './pages/settings';
-@import './pages/storage_quota';
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 44b06c0ff12..4e3fb819f4c 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -1,7 +1,18 @@
.ProseMirror {
+ padding-top: $gl-spacing-scale-4;
+ min-height: 140px;
max-height: 55vh;
overflow-y: auto;
+ ::selection {
+ background-color: transparent;
+ }
+
+ &:not(.ProseMirror-hideselection) .content-editor-selection {
+ background-color: $blue-100;
+ box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100;
+ }
+
td,
th,
li,
@@ -13,11 +24,21 @@
}
}
- img.ProseMirror-selectednode {
- outline: 3px solid rgba($blue-400, 0.48);
+ img.ProseMirror-selectednode,
+ .ProseMirror-selectednode audio,
+ .ProseMirror-selectednode video {
+ outline: 3px solid $blue-200;
outline-offset: -3px;
}
+ video {
+ max-width: 400px;
+ }
+
+ img {
+ max-width: 100%;
+ }
+
ul[data-type='taskList'] {
list-style: none;
padding: 0;
@@ -106,14 +127,8 @@
}
}
-.content-editor-dropdown .dropdown-menu {
- width: auto !important;
-
- @include gl-min-w-0;
-
- button {
- @include gl-white-space-nowrap;
- }
+.content-editor-switcher {
+ min-height: 32px;
}
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss
index 909de9d57f2..74f61faa9ae 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/components/detail_page.scss
@@ -1,6 +1,5 @@
.detail-page-header {
- padding: $gl-padding-top 0;
- border-bottom: 1px solid $border-color;
+ padding-top: $gl-spacing-scale-4;
color: $gl-text-color;
line-height: 34px;
display: flex;
@@ -55,9 +54,13 @@
}
}
+.detail-page-header-meta {
+ @include gl-flex-basis-full;
+}
+
.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;
@@ -71,3 +74,7 @@
color: $gl-text-color;
}
}
+
+.new-header-popover {
+ z-index: 999;
+}
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 293caf6fc87..04a7590d531 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -6,6 +6,8 @@ $item-remove-button-space: 42px;
.related-items-list {
padding: $gl-padding-4;
padding-right: $gl-padding-6;
+ border-bottom-left-radius: $gl-border-size-3;
+ border-bottom-right-radius: $gl-border-size-3;
&,
.list-item:last-child {
@@ -33,6 +35,14 @@ $item-remove-button-space: 42px;
.item-body {
position: relative;
line-height: $gl-line-height;
+ border: 1px solid transparent;
+ border-radius: $gl-border-radius-base;
+
+ &:hover,
+ &:focus-within {
+ background-color: $white;
+ border-color: $gray-50;
+ }
.merge-request-status.closed {
color: $red-500;
@@ -45,22 +55,23 @@ $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;
.sortable-link {
color: $gray-900;
+ font-weight: 500;
}
}
@@ -87,16 +98,6 @@ $item-remove-button-space: 42px;
}
}
- .item-attributes-area {
- > * {
- margin-left: 8px;
- }
-
- @include media-breakpoint-down(sm) {
- margin-left: -8px;
- }
- }
-
.item-milestone,
.item-weight {
cursor: help;
@@ -147,7 +148,7 @@ $item-remove-button-space: 42px;
}
.item-path-id {
- font-size: $gl-font-size-xs;
+ font-size: $gl-font-size-small;
white-space: nowrap;
.path-id-text {
@@ -156,34 +157,15 @@ $item-remove-button-space: 42px;
}
}
-.btn-item-remove {
- position: absolute;
- top: $gl-padding-4 / 2;
- right: 0;
- padding: $gl-padding-4;
- margin-right: $gl-padding-4 / 2;
+.mr-ci-status {
line-height: 0;
- border-color: transparent;
- background-color: transparent;
- color: $gl-text-color-secondary;
- .related-items-tree & {
- position: relative;
- top: initial;
- padding: $btn-sm-side-margin;
- margin-right: initial;
- }
-
- &:hover {
- color: $gl-text-color;
- border-color: $border-color;
+ a:focus {
+ @include gl-rounded-full;
+ @include gl-focus;
}
}
-.mr-ci-status {
- line-height: 0;
-}
-
@include media-breakpoint-down(xs) {
.btn-sm.dropdown-toggle-split {
max-width: 40px;
@@ -191,10 +173,6 @@ $item-remove-button-space: 42px;
}
@include media-breakpoint-up(sm) {
- .item-info-area {
- flex-basis: 100%;
- }
-
.sortable-link {
max-width: 90%;
}
@@ -265,18 +243,7 @@ $item-remove-button-space: 42px;
}
}
- .btn-item-remove {
- position: relative;
- top: initial;
- padding: $btn-sm-side-margin;
- margin-right: $gl-padding-4 / 2;
- }
-
.sortable-link {
line-height: 1.3;
}
-
- .item-info-area {
- flex-basis: auto;
- }
}
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/animations.scss b/app/assets/stylesheets/framework/animations.scss
index fa235f72e35..c93ef2287a8 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -102,7 +102,6 @@
@include transition(color);
}
-a,
.notification-dot {
@include transition(background-color, color, border);
}
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/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 5fa1923af7c..8059164782f 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -261,7 +261,6 @@
.btn-block {
width: 100%;
margin: 0;
- @include gl-mb-5;
&.btn {
padding: 6px 0;
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..f828129cdf1 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -1,3 +1,71 @@
+// 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;
+
+ --application-bar-left: 0px;
+ --application-bar-right: 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};
+}
+
+@include media-breakpoint-up(md) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: #{$contextual-sidebar-collapsed-width};
+ }
+
+ .right-sidebar-collapsed {
+ --application-bar-right: #{$right-sidebar-collapsed-width};
+
+ &.is-merge-request {
+ --application-bar-right: 0px;
+ }
+ }
+
+ .right-sidebar-expanded {
+ --application-bar-right: #{$right-sidebar-width};
+ }
+}
+
+@include media-breakpoint-up(xl) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: #{$contextual-sidebar-width};
+ }
+
+ .page-with-icon-sidebar {
+ --application-bar-left: #{$contextual-sidebar-collapsed-width};
+ }
+
+ .page-with-super-sidebar {
+ --application-bar-left: #{$super-sidebar-width};
+ }
+
+ .page-with-super-sidebar-collapsed {
+ --application-bar-left: 0px;
+ }
+}
+
/** COLORS **/
.cgray { color: $gl-text-color; }
.clgray { color: $gray-200; }
@@ -253,12 +321,6 @@ li.note {
}
}
-img.emoji {
- height: 16px;
- vertical-align: top;
- width: 20px;
-}
-
.chart {
overflow: hidden;
height: 220px;
@@ -451,7 +513,8 @@ img.emoji {
width: 100%;
> .dropdown-menu,
- > .btn {
+ > .btn,
+ > .gl-new-dropdown-toggle > .gl-button-text {
width: 100%;
}
}
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 4eb26d533c2..6c40781670a 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -9,7 +9,7 @@
}
table.code tr:last-of-type td:last-of-type {
- @include gl-rounded-bottom-right-base();
+ border-bottom-right-radius: $border-radius-default - 1px;
}
.file-title,
@@ -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-sticky-header-height} - #{$gl-border-size-1});
}
&::before {
@@ -69,20 +51,11 @@
pointer-events: none;
}
- &.is-commit {
- top: calc(#{$header-height} + #{$commit-stat-summary-height});
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$commit-stat-summary-height} + #{$performance-bar-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});
- }
+ &.is-commit,
+ &.is-compare,
+ &.is-wiki {
+ top: calc(#{$calc-application-header-height});
+ border-top: 0;
}
}
@@ -99,22 +72,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;
}
}
@@ -587,7 +545,7 @@ table.code {
.line_holder:last-of-type {
.diff-td:first-child,
td:first-child {
- border-bottom-left-radius: $border-radius-default;
+ border-bottom-left-radius: $border-radius-default - 1px;
}
}
@@ -723,44 +681,6 @@ table.code {
}
}
-.diff-files-changed {
- background-color: $body-bg;
-
- .inline-parallel-buttons {
- @include gl-relative;
- z-index: 1;
- }
-
- @include media-breakpoint-up(sm) {
- @include gl-sticky;
- top: $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;
- border-bottom: 1px solid $white-dark;
-
- .diff-stats-additions-deletions-expanded,
- .inline-parallel-buttons {
- @include gl-display-none;
- }
- }
- }
-
- @include media-breakpoint-up(lg) {
- &.is-stuck {
- .diff-stats-additions-deletions-collapsed {
- @include gl-display-block;
- }
- }
- }
-}
-
.note-container {
background-color: $gray-light;
border-top: 1px solid $white-normal;
@@ -944,6 +864,19 @@ table.code {
}
}
+// Remove border from collapsed replies widget only on diffs
+.diff-grid-comments {
+ .replies-widget-collapsed {
+ border-bottom: 0;
+ }
+ // Rounded border radius only on diff comments with no replies
+ .discussion-collapsible {
+ .discussion-reply-holder:first-of-type {
+ border-radius: $gl-border-radius-base;
+ }
+ }
+}
+
.discussion-body .image .frame {
position: relative;
}
@@ -978,7 +911,7 @@ table.code {
&.popover {
width: 250px;
min-width: 250px;
- z-index: 210;
+ z-index: 610;
}
.popover-header {
@@ -999,7 +932,7 @@ table.code {
}
// Note: Prevents tall files from appearing above sticky tabs
-.diffs .vue-recycle-scroller__item-view > div:not(.active) {
+.diff-files-holder .vue-recycle-scroller__item-view > div:not(.active) {
position: absolute;
bottom: 100vh;
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ebb9466eb15..884cb70cb9f 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -96,7 +96,7 @@
color: $gl-text-color;
font-size: 14px;
text-align: left;
- border: 1px solid $border-color;
+ border: 1px solid $gray-200;
border-radius: $border-radius-base;
white-space: nowrap;
@@ -143,11 +143,24 @@
.dropdown-menu-toggle.dropdown-menu-toggle {
justify-content: flex-start;
overflow: hidden;
+ padding-top: #{$gl-padding-8 - 1};
+ padding-bottom: #{$gl-padding-8 - 1};
padding-right: 25px;
position: relative;
text-overflow: ellipsis;
+ line-height: $gl-line-height;
width: 160px;
+ &:hover {
+ @include gl-inset-border-1-gray-400;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $gray-50;
+ border-color: $gray-400;
+ }
+
.gl-spinner {
position: absolute;
top: 9px;
@@ -157,7 +170,7 @@
.dropdown-menu-toggle-icon {
position: absolute;
right: $gl-padding-8;
- color: $gray-darkest;
+ color: $gray-500;
}
}
@@ -705,13 +718,13 @@
}
.dropdown-label-box {
- position: relative;
- top: 0;
- margin-right: 5px;
+ margin-right: $gl-spacing-scale-3;
+ margin-top: $gl-spacing-scale-2;
display: inline-block;
- width: 15px;
- height: 15px;
+ width: $gl-spacing-scale-5;
+ height: $gl-spacing-scale-3;
border-radius: $border-radius-base;
+ border: 1px solid var(--white, $white);
}
.git-revision-dropdown {
@@ -984,17 +997,6 @@
.label-item {
padding: 8px 20px;
}
-
- .color-input-container {
- .dropdown-label-color-preview {
- border: 1px solid $gray-100;
- border-right: 0;
-
- &[style] {
- border-color: transparent;
- }
- }
- }
}
.bulk-update {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 9ea5a66b3bc..503e22742ba 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -2,17 +2,8 @@
* 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;
border-radius: $border-radius-default;
&.file-holder-top-border {
@@ -29,10 +20,6 @@
border: 0;
}
- &.file-holder-bottom-radius {
- border-radius: 0 0 $border-radius-small $border-radius-small;
- }
-
&.readme-holder {
margin: $gl-padding 0;
}
@@ -484,18 +471,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;
}
}
@@ -580,3 +573,31 @@ span.idiff {
padding: 0;
border-radius: 0 0 $border-radius-default $border-radius-default;
}
+
+.blame-stream-container {
+ border-top: 1px solid $border-color;
+}
+
+.blame-stream-loading {
+ $gradient-size: 16px;
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: -$gradient-size;
+ height: $gl-spacing-scale-10;
+ border-top: $gradient-size solid transparent;
+ background-color: $white;
+ box-sizing: content-box;
+ background-clip: content-box;
+
+ .gradient {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -$gradient-size;
+ height: $gradient-size;
+ background: linear-gradient(to top, $white, transparentize($white, 1));
+ }
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index b35175f4ef6..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,9 +195,9 @@
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 0 0 $border-radius-default;
+ border-radius: $border-radius-default;
@include media-breakpoint-down(sm) {
flex: 1 1 auto;
@@ -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 - 2;
+ height: #{$input-height - 2px};
line-height: inherit;
&,
@@ -261,8 +260,9 @@
flex: 1;
position: relative;
min-width: 0;
- height: 2rem;
+ height: #{$input-height - 2px};
background-color: $input-bg;
+ border-radius: $border-radius-default;
}
.filtered-search-input-dropdown-menu {
@@ -291,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;
@@ -302,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/forms.scss b/app/assets/stylesheets/framework/forms.scss
index c0fe8ca6f76..3e1dff18f2a 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -148,7 +148,10 @@ label {
width: $input-short-md-width;
}
}
+}
+.form-control,
+[contenteditable=true] {
&:focus {
border-color: $gray-400;
@include gl-focus;
@@ -156,6 +159,7 @@ label {
}
.select-control {
+ line-height: 1.3;
padding-left: 10px;
padding-right: 10px;
appearance: none;
@@ -208,7 +212,7 @@ label {
.gl-show-field-errors {
.form-control:not(textarea) {
- height: 34px;
+ height: $input-height;
}
.gl-field-success-outline {
@@ -246,7 +250,7 @@ label {
.show-password-complexity-errors {
.form-control:not(textarea) {
- height: 34px;
+ height: $input-height;
}
.password-complexity-error-outline {
@@ -286,3 +290,22 @@ label {
.input-group-text {
max-height: $input-height;
}
+
+.add-issuable-form-input-wrapper {
+ &.focus {
+ border-color: var(--gray-700, $gray-700);
+
+ input {
+ @include gl-shadow-none;
+ }
+ }
+
+ .gl-show-field-errors &.form-control:not(textarea) {
+ height: auto;
+ }
+}
+
+.add-issuable-form-input-wrapper.focus,
+.issue-token-remove-button:focus {
+ @include gl-focus;
+}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 7baf84198e4..0c53b3fd866 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,23 +312,32 @@ $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%;
- position: relative;
- padding-top: $gl-padding / 2;
- padding-bottom: $gl-padding / 2;
- align-items: center;
- border-bottom: 1px solid $border-color;
+.top-bar-fixed {
+ @include gl-inset-border-b-1-gray-100;
+ background-color: $body-bg;
+ left: var(--application-bar-left);
+ position: fixed;
+ right: var(--application-bar-right);
+ top: $calc-application-bars-height;
+ width: auto;
+ z-index: $top-bar-z-index;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium;
+ }
+
+ .breadcrumbs-list {
+ @include media-breakpoint-down(xs) {
+ flex-wrap: nowrap;
+ }
+ }
}
-.breadcrumbs-links {
+.breadcrumbs {
flex: 1;
min-width: 0;
align-self: center;
@@ -350,16 +359,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 {
@@ -375,14 +374,11 @@ $search-input-field-x-min-width: 200px;
display: flex;
align-items: center;
position: relative;
+ min-width: 0;
padding: 2px 0;
&:not(:last-child) {
padding-right: 20px;
-
- &:not(.dropdown) {
- overflow: hidden;
- }
}
&:last-child {
@@ -503,11 +499,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,25 +549,16 @@ $search-input-field-x-min-width: 200px;
}
.toggle-mobile-nav {
- display: none;
- background-color: transparent;
- border: 0;
- padding: 6px 16px;
- margin: 0 0 0 -15px;
- height: 46px;
- color: $gl-text-color;
+ @include gl-display-none;
@include media-breakpoint-down(sm) {
- display: flex;
- align-items: center;
-
- i {
- font-size: 18px;
- }
+ @include gl-display-block;
- + .breadcrumbs-links {
- padding-left: $gl-padding;
- border-left: 1px solid $gl-text-color-quaternary;
+ + .breadcrumbs {
+ @include gl-pl-4;
+ @include gl-border-l-1;
+ @include gl-border-l-solid;
+ @include gl-border-gray-100;
}
}
}
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..e57dad9e4cb 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -44,46 +44,6 @@
}
}
-.md-header {
- .nav-links {
- a {
- width: 100%;
- padding-top: 0;
- line-height: 19px;
-
- &.btn.btn-sm {
- padding: 2px 5px;
- }
-
- &:focus {
- margin-top: -10px;
- padding-top: 10px;
- }
- }
- }
-
- .gl-tabs-nav {
- @include media-breakpoint-down(xs) {
- .nav-item {
- flex: 1;
- }
-
- .gl-tab-nav-item {
- padding-top: $gl-padding-4;
- padding-bottom: $gl-padding-8;
- }
-
- .md-header-toolbar {
- width: 100%;
- display: flex;
- flex-wrap: wrap;
- padding-top: $gl-padding-8;
- border-top: 1px solid $border-color;
- }
- }
- }
-}
-
.md-header-tab {
@include media-breakpoint-down(xs) {
flex: 1;
@@ -120,9 +80,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;
@@ -130,7 +91,7 @@
}
.md-preview-holder {
- min-height: 172px;
+ min-height: 176px;
padding: 10px 0;
overflow-x: auto;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index c5e50299e6d..15a31fbb3d9 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -256,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;
}
/*
@@ -457,3 +451,21 @@
color: $gl-text-color;
}
}
+
+@mixin omniauth-divider {
+ &::before,
+ &::after {
+ content: '';
+ flex: 1;
+ border-bottom: 1px solid var(--gray-100, $gray-100);
+ margin: $gl-padding-24 0;
+ }
+
+ &::before {
+ margin-right: $gl-padding;
+ }
+
+ &::after {
+ margin-left: $gl-padding;
+ }
+}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index c9b17f5d5c4..f76a9cf0373 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -45,7 +45,7 @@
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
}
- .text-danger {
+ .text-danger:not(.dropdown-item) {
font-weight: $gl-font-weight-bold;
}
}
diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss
index 5ed5b1e1445..84a34f12649 100644
--- a/app/assets/stylesheets/framework/page_title.scss
+++ b/app/assets/stylesheets/framework/page_title.scss
@@ -1,8 +1,6 @@
.page-title-holder {
- border-bottom: 1px solid $border-color;
-
.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 a07a57f40f7..9fdf889f4e9 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -22,7 +22,7 @@
&:not(.is-merge-request) {
@include media-breakpoint-up(sm) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
- padding-right: $gutter-collapsed-width;
+ padding-right: $right-sidebar-collapsed-width;
}
}
}
@@ -30,7 +30,7 @@
&.is-merge-request {
@include media-breakpoint-up(lg) {
.content-wrapper {
- padding-right: $gutter-collapsed-width;
+ padding-right: $right-sidebar-collapsed-width;
}
}
}
@@ -56,7 +56,7 @@
z-index: $zindex-dropdown-menu;
&.right-sidebar-merge-requests {
- width: 300px;
+ width: $right-sidebar-width;
@include media-breakpoint-up(md) {
z-index: auto;
@@ -69,21 +69,21 @@
@include media-breakpoint-only(sm) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
- padding-right: $gutter-collapsed-width;
+ padding-right: $right-sidebar-collapsed-width;
}
}
&:not(.is-merge-request) {
@include media-breakpoint-up(md) {
.content-wrapper {
- padding-right: $gutter-width;
+ padding-right: $right-sidebar-width;
}
}
}
}
.right-sidebar {
- border-left: 1px solid $gray-100;
+ border-left: 1px solid $gray-50;
&.right-sidebar-merge-requests {
@include media-breakpoint-up(lg) {
@@ -102,7 +102,7 @@
@mixin maintain-sidebar-dimensions {
display: block;
- width: $gutter-width;
+ width: $right-sidebar-width;
}
.issues-bulk-update.right-sidebar {
@@ -113,7 +113,7 @@
&.right-sidebar-expanded {
@include maintain-sidebar-dimensions;
- width: $gutter-width;
+ width: $right-sidebar-width;
.issuable-sidebar-header {
// matches `.top-area .nav-controls` for issuable index pages
@@ -140,6 +140,11 @@
.issuable-sidebar {
padding: 0 3px;
}
+
+ .block {
+ border-bottom: 0;
+ padding-top: 0;
+ }
}
.issuable-sidebar .labels {
@@ -266,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'] {
@@ -283,32 +306,34 @@
@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);
+ 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;
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+
+ @include media-breakpoint-down(md) {
+ z-index: 251;
+ }
}
&.right-sidebar-merge-requests {
@include media-breakpoint-down(md) {
@include right-sidebar;
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
z-index: 251;
}
- }
- @include media-breakpoint-down(sm) {
- z-index: 251;
+ @include media-breakpoint-down(sm) {
+ top: $calc-application-header-height;
+ }
}
a:not(.btn) {
@@ -376,7 +401,7 @@
border-bottom: 1px solid $border-gray-normal;
// This prevents the mess when resizing the sidebar
// of elements repositioning themselves..
- width: $gutter-inner-width;
+ width: $right-sidebar-inner-width;
// --
&:last-child {
@@ -449,7 +474,7 @@
&.right-sidebar-expanded {
&:not(.right-sidebar-merge-requests) {
- width: $gutter-width;
+ width: $right-sidebar-width;
}
.value {
@@ -464,28 +489,14 @@
padding: 0;
.issuable-context-form {
- --initial-top: calc(#{$header-height} + 76px);
- --top: var(--initial-top);
+ $issue-sticky-header-height: 76px;
- @include gl-sticky;
- @include gl-overflow-auto;
-
- 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);
}
}
}
@@ -543,13 +554,13 @@
}
}
- width: $gutter-collapsed-width;
+ width: $right-sidebar-collapsed-width;
padding: 0;
.block,
.sidebar-contained-width,
.issuable-sidebar-header {
- width: $gutter-collapsed-width - 2px;
+ width: $right-sidebar-collapsed-width - 2px;
padding: 0;
border-bottom: 0;
overflow: hidden;
@@ -737,10 +748,6 @@
}
}
-.with-performance-bar .right-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
.issuable-show-labels {
.gl-label {
margin-bottom: 5px;
@@ -786,7 +793,7 @@
.participants-author {
&:nth-of-type(8n) {
- padding-right: 0;
+ margin-right: 0;
}
.avatar.avatar-inline {
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..a09ab7ed64c 100644
--- a/app/assets/stylesheets/framework/source_editor.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -37,10 +37,34 @@
.gl-source-editor {
@include gl-order-n1;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
}
}
.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 4b55b39d6f3..ca67b472322 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -1,7 +1,58 @@
+@mixin active-toggle {
+ background-color: $gray-50 !important;
+ mix-blend-mode: multiply;
+
+ .gl-dark & {
+ mix-blend-mode: screen;
+ }
+}
+
+$super-sidebar-transition-duration: $gl-transition-duration-medium;
+$super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
+
+@mixin notification-dot($color, $size, $top, $left) {
+ background-color: $color;
+ border: 2px solid $gray-10; // Same as the sidebar's background color.
+ position: absolute;
+ height: $size;
+ width: $size;
+ top: $top;
+ left: $left;
+ border-radius: 50%;
+ transition: background-color 100ms linear, border-color 100ms linear;
+}
+
+.super-sidebar-skip-to {
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+ width: calc(#{$super-sidebar-width} - #{$gl-spacing-scale-5});
+ z-index: $super-sidebar-skip-to-z-index;
+}
+
.super-sidebar {
- top: 0;
- width: $contextual-sidebar-width;
- z-index: 600;
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ 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;
+ transform: translate3d(0, 0, 0);
+ width: $super-sidebar-width;
+ z-index: $super-sidebar-z-index;
+
+ &.super-sidebar-loading {
+ transform: translate3d(-100%, 0, 0);
+ transition: none;
+
+ @include media-breakpoint-up(xl) {
+ transform: translate3d(0, 0, 0);
+ }
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform $super-sidebar-transition-duration;
+ }
.user-bar {
background-color: $t-gray-a-04;
@@ -9,46 +60,100 @@
.tanuki-logo {
@include gl-vertical-align-middle;
}
+
+ .user-bar-item,
+ .tanuki-logo-container {
+ @include gl-rounded-base;
+ @include gl-p-2;
+ @include gl-bg-transparent;
+ @include gl-border-none;
+
+ &:focus,
+ &:active {
+ @include gl-focus;
+ }
+ }
+
+ .user-bar-item {
+ &:hover,
+ &:focus,
+ &:active {
+ @include active-toggle;
+ }
+ }
+
+ $light-mode-btn-bg: #e0dfe5;
+ $dark-mode-btn-bg: #53515b;
+
+ .tanuki-logo-container {
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: $light-mode-btn-bg;
+
+ .gl-dark & {
+ background-color: $dark-mode-btn-bg;
+ }
+ }
+ }
}
- .counter .gl-icon {
- color: var(--gray-500, $gray-500);
+ .counter .gl-icon,
+ .item-icon {
+ color: var(--gray-600, $gray-500);
}
.counter:hover,
.counter:focus,
- .gl-dropdown-custom-toggle:hover .counter,
- .gl-dropdown-custom-toggle:focus .counter,
- .gl-dropdown-custom-toggle[aria-expanded='true'] .counter {
+ .counter[aria-expanded='true'] {
background-color: $gray-50;
border-color: transparent;
mix-blend-mode: multiply;
+ .gl-dark & {
+ mix-blend-mode: screen;
+ }
+
.gl-icon {
color: var(--gray-700, $gray-700);
}
}
- .context-switcher-toggle {
+ .counter:hover,
+ .counter[aria-expanded='true'] {
+ box-shadow: none;
+ }
+
+ .context-switcher .gl-new-dropdown-custom-toggle {
+ width: 100%;
+ }
+
+ .context-switcher .gl-new-dropdown-panel {
+ overflow-y: auto;
+ }
+
+ .context-switcher-search-box input {
+ @include gl-font-sm;
+ }
+
+ .gl-new-dropdown-custom-toggle .context-switcher-toggle {
&[aria-expanded='true'] {
background-color: $t-gray-a-08;
}
+
+ &:focus {
+ @include gl-focus($inset: true); }
}
.btn-with-notification {
- mix-blend-mode: unset !important; // Our tertiary buttons otherwise use another mix-blend mode, making border-color semi-transparent.
position: relative;
- .notification {
- background-color: $blue-500;
- border: 2px solid $gray-10; // Same as the sidebar's background color.
- position: absolute;
- height: 9px;
- width: 9px;
- top: 5px;
- left: 22px;
- border-radius: 50%;
- transition: background-color 100ms linear, border-color 100ms linear;
+ .notification-dot-info {
+ @include notification-dot($blue-500, 9px, 5px, 22px);
+ }
+
+ .notification-dot-warning {
+ @include notification-dot($orange-300, 12px, 1px, 19px);
}
&:hover,
@@ -58,8 +163,169 @@
}
}
}
+
+ .gl-new-dropdown-toggle[aria-expanded='true'] {
+ @include active-toggle;
+ }
+
+ .gl-new-dropdown-custom-toggle {
+ .btn-with-notification {
+ mix-blend-mode: unset; // Our tertiary buttons otherwise use another mix-blend mode, making border-color semi-transparent.
+ }
+
+ [aria-expanded='true'] {
+ @include active-toggle;
+ }
+ }
+
+ .nav-item-link {
+ button,
+ .draggable-icon {
+ opacity: 0;
+ }
+
+ .draggable-icon {
+ cursor: grab;
+ }
+
+ &:hover {
+ button,
+ .draggable-icon {
+ opacity: 1;
+ }
+ }
+
+ &:hover,
+ &:focus-within {
+ .nav-item-badge {
+ opacity: 0;
+ }
+ }
+
+ &:focus button,
+ button:focus {
+ opacity: 1;
+ }
+ }
+
+ #trial-status-sidebar-widget:hover {
+ text-decoration: none;
+ @include gl-text-contrast-light;
+ }
+}
+
+.super-sidebar-overlay {
+ display: none;
+}
+
+.super-sidebar-peek,
+.super-sidebar-peek-hint {
+ @include gl-shadow;
+ border-right: 0;
+}
+
+.super-sidebar-peek-hint {
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform $super-sidebar-transition-hint-duration ease-out;
+ }
+}
+
+.page-with-super-sidebar {
+ padding-left: 0;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: padding-left $super-sidebar-transition-duration;
+ }
+
+ &:not(.page-with-super-sidebar-collapsed) {
+ .super-sidebar-overlay {
+ display: block;
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: $black-transparent;
+ z-index: $super-sidebar-z-index - 1;
+
+ @include media-breakpoint-up(md) {
+ display: none;
+ }
+ }
+ }
+
+ @include media-breakpoint-up(xl) {
+ padding-left: $super-sidebar-width;
+
+ .super-sidebar-toggle {
+ display: none;
+ }
+ }
+}
+
+.page-with-super-sidebar-collapsed {
+ .super-sidebar {
+ transform: translate3d(-100%, 0, 0);
+
+ &.super-sidebar-peek {
+ transform: translate3d(0, 0, 0);
+ }
+
+ &.super-sidebar-peek-hint {
+ transform: translate3d(calc(#{$gl-spacing-scale-3} - 100%), 0, 0);
+ }
+ }
+
+ @include media-breakpoint-up(xl) {
+ padding-left: 0;
+
+ .super-sidebar-toggle {
+ display: block;
+ }
+ }
+}
+
+.gl-dark {
+ .super-sidebar {
+ .gl-new-dropdown-custom-toggle {
+ .btn-with-notification.btn-with-notification {
+ mix-blend-mode: unset;
+ }
+ }
+ }
}
-.with-performance-bar .super-sidebar {
- top: $performance-bar-height;
+.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 89585fd96ae..703c2ca0dad 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -36,69 +36,14 @@
}
}
-// 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,
- .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,
- .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,
- .right-sidebar,
// navless pages' footer eg: login page
// navless pages' footer border eg: login page
&.devise-layout-html body .footer-container,
&.devise-layout-html body hr.footer-fixed {
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}));
- }
}
.fullscreen-layout {
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 8b2a494527b..b28a93749d1 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -41,7 +41,7 @@ table {
}
th {
- @include gl-bg-gray-50;
+ @include gl-bg-gray-10;
border-bottom: 0;
&.wide {
@@ -160,3 +160,7 @@ table {
border-top: 0;
}
}
+
+.gl-table-no-top-border th {
+ border-top: 0;
+}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 32e9bba8712..699693bd354 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -31,6 +31,7 @@
&:not(.note-form).internal-note .timeline-content,
&:not(.note-form).draft-note .timeline-content {
background-color: $orange-50 !important;
+ border-radius: 3px;
}
.timeline-entry-inner {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 9b5897b7df9..88f990d2320 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -494,6 +494,7 @@
outline: none;
&::after {
+ @include gl-dark-invert-keep-hue;
content: image-url('icon_anchor.svg');
visibility: hidden;
}
@@ -631,7 +632,7 @@ body {
}
.page-title {
- margin: #{2 * $grid-size} 0;
+ 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 c616915073e..1ba3de68662 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -2,14 +2,19 @@
* Layout
*/
$grid-size: 8px;
-$gutter-collapsed-width: 62px;
-$gutter-width: 290px;
-$gutter-inner-width: 250px;
+$right-sidebar-collapsed-width: 62px;
+$right-sidebar-width: 290px;
+$right-sidebar-inner-width: 250px;
$sidebar-breakpoint: 1024px;
$default-transition-duration: 0.15s;
$contextual-sidebar-width: 256px;
$contextual-sidebar-collapsed-width: 56px;
$toggle-sidebar-height: 48px;
+$super-sidebar-width: 256px;
+$super-sidebar-z-index: 600;
+$super-sidebar-skip-to-z-index: 601;
+$super-sidebar-overlay-z-index: 599;
+$top-bar-z-index: 210;
/**
🚨 Do not use this spacing scale — it is deprecated and being removed. 🚨
@@ -386,7 +391,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;
@@ -477,10 +481,11 @@ $highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
$system-header-height: 16px;
$system-footer-height: $system-header-height;
+$mr-sticky-header-height: 72px;
+$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;
@@ -494,6 +499,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);
@@ -665,8 +678,6 @@ $note-targe3-inside: #ffffd3;
/*
* Calendar
*/
-$calendar-hover-bg: #ecf3fe;
-$calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
@@ -679,12 +690,11 @@ $ci-skipped-color: #888;
*/
$issue-boards-font-size: 14px;
$issue-boards-card-shadow: rgba(0, 0, 0, 0.1);
-$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});
@@ -730,7 +740,6 @@ $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;
/*
* Files
@@ -741,7 +750,7 @@ $logs-p-color: #333;
/*
* Forms
*/
-$input-height: 34px;
+$input-height: 32px;
$input-danger-bg: #f2dede;
$input-group-addon-bg: $gray-10;
$gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
@@ -877,6 +886,11 @@ Multi file editor
$border-color-settings: #e1e1e1;
/*
+Drawers
+*/
+$wide-drawer: 500px;
+
+/*
Modals
*/
$modal-body-height: 80px;
@@ -902,9 +916,15 @@ Merge requests
$mr-tabs-height: 48px;
/*
-Compare Branches
+Board Swimlanes
+*/
+$board-swimlanes-headers-height: 64px;
+
+/*
+Source Editor theme overrides
*/
-$compare-branches-sticky-header-height: 68px;
+$source-editor-hover-light-text-color: #ececef;
+$source-editor-hover-dark-text-color: #333238;
/**
Bootstrap 4.2.0 introduced new icons for validating forms.
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index cb9c623c8fc..1434c16b68f 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -1,7 +1,7 @@
.info-well {
- background: $gray-light;
+ background: $gray-10;
color: $gl-text-color;
- border: 1px solid $border-color;
+ border: 1px solid $gray-100;
border-radius: $border-radius-default;
.card.card-body-segment {
diff --git a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
index 30895a55711..5f195bc47bf 100644
--- a/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
+++ b/app/assets/stylesheets/highlight/diff_custom_colors_addition.scss
@@ -25,7 +25,7 @@
}
}
- .gd {
+ .gi {
background-color: var(--diff-addition-color);
}
}
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/admin/geo_nodes.scss b/app/assets/stylesheets/page_bundles/admin/geo_sites.scss
index b0aaa48569a..37bc2394d58 100644
--- a/app/assets/stylesheets/page_bundles/admin/geo_nodes.scss
+++ b/app/assets/stylesheets/page_bundles/admin/geo_sites.scss
@@ -1,6 +1,6 @@
@import '../mixins_and_variables_and_functions';
-.geo-node-header-grid-columns {
+.geo-site-header-grid-columns {
grid-template-columns: 1fr auto;
grid-gap: $gl-spacing-scale-5;
@@ -9,7 +9,7 @@
}
}
-.geo-node-details-grid-columns {
+.geo-site-details-grid-columns {
grid-gap: $gl-spacing-scale-5;
@include media-breakpoint-up(lg) {
@@ -17,12 +17,12 @@
}
}
-.geo-node-core-details-grid-columns {
+.geo-site-core-details-grid-columns {
grid-template-columns: 1fr 1fr;
grid-gap: $gl-spacing-scale-5;
}
-.geo-node-replication-details-grid-columns {
+.geo-site-replication-details-grid-columns {
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
@@ -31,7 +31,7 @@
}
}
-.geo-node-filter-grid-columns {
+.geo-site-filter-grid-columns {
grid-template-columns: 1fr;
@include media-breakpoint-up(md) {
@@ -39,7 +39,7 @@
}
}
-.geo-node-replication-counts-grid {
+.geo-site-replication-counts-grid {
grid-template-columns: 2fr 1fr 1fr;
grid-gap: 1rem;
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 99e7f7ae0a4..5aca697ae26 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -6,21 +6,30 @@
}
.boards-app {
+ height: calc(#{$calc-application-viewport-height} - var(--header-height, 48px));
+ display: flex;
+ flex-direction: column;
+
@include media-breakpoint-up(sm) {
transition: width $gl-transition-duration-medium;
width: 100%;
&.is-compact {
- width: calc(100% - #{$gutter-width});
+ width: calc(100% - #{$right-sidebar-width});
}
}
}
.boards-list,
.board-swimlanes {
- overflow-x: scroll;
- min-height: 200px;
+ flex-grow: 1;
border-left: 8px solid var(--gray-10, $white);
+
+ min-height: calc(#{$calc-application-viewport-height} - var(--header-height));
+
+ @include media-breakpoint-up(sm) {
+ min-height: 0;
+ }
}
.board {
@@ -40,6 +49,7 @@
// We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a
// rotated element has square dimensions so it won't overlap with its siblings.
margin: calc(50% - 8px) 0;
+ max-width: 50vh;
transform-origin: center;
}
@@ -195,8 +205,8 @@
padding-top: 10px;
}
- .boards-list {
- height: calc(100vh - #{$issue-boards-filter-height});
+ .boards-app {
+ height: 100vh;
}
// Use !important for these as top and z-index are set on style attribute
@@ -208,15 +218,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/branches.scss b/app/assets/stylesheets/page_bundles/branches.scss
index 2aa90529e22..a5b201c7dac 100644
--- a/app/assets/stylesheets/page_bundles/branches.scss
+++ b/app/assets/stylesheets/page_bundles/branches.scss
@@ -39,3 +39,7 @@
flex: 0 0 auto;
white-space: nowrap;
}
+
+.branches-list .branch-item:not(:last-of-type) {
+ border-bottom: 1px solid $border-color;
+}
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..b42e6fd85fa 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -8,22 +8,13 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
background: transparent;
}
-.design-checkbox {
- position: absolute;
- top: $gl-padding;
- left: 30px;
-}
-
.layout-page.design-detail-layout {
max-height: 100vh;
}
.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/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index 36da979ba1f..9e9723d2e5a 100644
--- a/app/assets/stylesheets/page_bundles/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -88,6 +88,10 @@
}
}
}
+
+ .overflow-guard {
+ border-radius: 0 0 $border-radius-default $border-radius-default;
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
index a6c08e344f9..0a856e217ef 100644
--- a/app/assets/stylesheets/page_bundles/import.scss
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -4,19 +4,11 @@ $import-bar-height: $gl-spacing-scale-11;
.import-table-bar {
height: $import-bar-height;
- top: $header-height;
-
- html.with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
+ top: $calc-application-header-height;
}
.import-table {
thead {
- top: calc(#{$header-height} + #{$import-bar-height});
-
- html.with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$import-bar-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$import-bar-height});
}
}
diff --git a/app/assets/stylesheets/page_bundles/incidents.scss b/app/assets/stylesheets/page_bundles/incidents.scss
index 493add1ea0f..fde35ab3d39 100644
--- a/app/assets/stylesheets/page_bundles/incidents.scss
+++ b/app/assets/stylesheets/page_bundles/incidents.scss
@@ -57,8 +57,6 @@
}
.timeline-entry:not(:last-child) {
- @include gl-pb-0;
-
.timeline-event-border {
@include gl-pb-3;
@include gl-border-gray-50;
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index f364170c99f..1b98fd4df07 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;
@@ -81,8 +81,6 @@
}
.detail-page-description {
- padding: 16px 0;
-
small {
color: var(--gray-500, $gray-500);
}
@@ -92,10 +90,11 @@
color: var(--gray-500, $gray-500);
display: block;
margin: 16px 0 0;
- font-size: 85%;
+ font-size: $gl-font-size-small;
.author-link {
- color: var(--gray-500, $gray-500);
+ color: var(--gray-700, $gray-700);
+ font-size: $gl-font-size-small;
}
}
@@ -138,21 +137,6 @@
}
}
-.add-issuable-form-input-wrapper {
- &.focus {
- border-color: var(--gray-700, $gray-700);
- @include gl-focus;
-
- input {
- @include gl-shadow-none;
- }
- }
-
- .gl-show-field-errors &.form-control:not(textarea) {
- height: auto;
- }
-}
-
/*
* Following overrides are done to prevent
* legacy dropdown styles from influencing
@@ -181,3 +165,13 @@
border: 0;
}
}
+
+.merge-request-notification-toggle {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
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/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss
index 41515a98e0a..f39dee12126 100644
--- a/app/assets/stylesheets/page_bundles/issues_list.scss
+++ b/app/assets/stylesheets/page_bundles/issues_list.scss
@@ -23,11 +23,6 @@
margin-bottom: 2px;
}
- .issue-labels,
- .author-link {
- display: inline-block;
- }
-
.icon-merge-request-unmerged {
height: 13px;
margin-bottom: 3px;
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 0a2b3175aa9..2c54c819543 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -16,12 +16,12 @@
@import '@gitlab/ui/src/components/base/pagination/pagination';
@import '@gitlab/ui/src/components/base/table/table';
@import '@gitlab/ui/src/components/base/tooltip/tooltip';
-@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
@import '@gitlab/ui/src/components/base/form/form_input/form_input';
@import '@gitlab/ui/src/components/base/form/form_radio/form_radio';
@import '@gitlab/ui/src/components/base/form/form_radio_group/form_radio_group';
@import '@gitlab/ui/src/components/base/form/form_checkbox/form_checkbox';
@import '@gitlab/ui/src/components/base/form/form_group/form_group';
+@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
$header-height: 40px;
diff --git a/app/assets/stylesheets/page_bundles/jira_connect_users.scss b/app/assets/stylesheets/page_bundles/jira_connect_users.scss
deleted file mode 100644
index 602910adad9..00000000000
--- a/app/assets/stylesheets/page_bundles/jira_connect_users.scss
+++ /dev/null
@@ -1 +0,0 @@
-@import '../themes/theme_indigo';
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index 360ea20733d..98fa45e0e3d 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 {
@@ -95,11 +96,6 @@
.login-body {
font-size: 13px;
- input + p,
- input ~ p.field-validation {
- margin-top: 5px;
- }
-
.username .validation-success {
color: $green-600;
}
@@ -220,6 +216,10 @@
color: $red-700;
}
}
+
+ .omniauth-divider {
+ @include omniauth-divider;
+ }
}
@include media-breakpoint-down(xs) {
@@ -287,3 +287,9 @@
}
}
}
+
+@include media-breakpoint-down(sm) {
+ .sm-bg-gray-10 {
+ @include gl-bg-gray-10;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index fe64e4f2fe8..61f8f0de557 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,24 +255,24 @@ $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));
- max-height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
+ min-height: 300px;
+ 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-sticky-header-height} + #{$diff-file-header-top}));
+ top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height} + #{$diff-file-header-top});
}
}
.tree-list-holder {
+ --file-row-height: 32px;
height: 100%;
.file-row {
@@ -297,6 +288,10 @@ $tabs-holder-z-index: 250;
overflow-x: auto;
}
+.tree-list-gutter {
+ height: $grid-size;
+}
+
.tree-list-search {
flex: 0 0 34px;
@@ -322,6 +317,12 @@ $tabs-holder-z-index: 250;
line-height: 0;
}
+.file-row-header {
+ display: flex;
+ align-items: center;
+ height: var(--file-row-height);
+}
+
@media (max-width: map-get($grid-breakpoints, lg)-1) {
.diffs .files {
.diff-tree-list {
@@ -549,7 +550,7 @@ $tabs-holder-z-index: 250;
border-radius: $border-radius-default;
}
- .mr-widget-section:not(:first-child) {
+ .mr-widget-section:not(:first-child) > div {
border-top: solid 1px var(--border-color, $border-color);
}
@@ -596,10 +597,6 @@ $tabs-holder-z-index: 250;
}
}
- label {
- margin-bottom: 0;
- }
-
.btn {
font-size: $gl-font-size;
}
@@ -694,10 +691,6 @@ $tabs-holder-z-index: 250;
margin-right: 7px;
}
- label {
- font-weight: $gl-font-weight-normal;
- }
-
.spacing {
margin: 0 0 0 10px;
}
@@ -807,7 +800,7 @@ $tabs-holder-z-index: 250;
.mr-widget-body,
.mr-widget-content {
- padding: $gl-padding;
+ padding: $gl-padding-12 $gl-padding;
}
.mr-widget-body-ready-merge {
@@ -828,6 +821,11 @@ $tabs-holder-z-index: 250;
}
}
+.mr-widget-grouped-section .report-block-container {
+ border-bottom-left-radius: $border-radius-default;
+ border-bottom-right-radius: $border-radius-default;
+}
+
.mr-widget-extension {
border-top: 1px solid var(--border-color, $border-color);
background-color: var(--gray-10, $gray-10);
@@ -898,15 +896,6 @@ $tabs-holder-z-index: 250;
&:not(:first-child) {
margin-top: $gl-padding;
}
-
- &:not(:last-child)::before {
- content: '';
- border-left: 2px solid var(--gray-10, $gray-10);
- position: absolute;
- bottom: -17px;
- left: calc(1rem - 1px);
- height: 16px;
- }
}
.mr-version-controls {
@@ -975,8 +964,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% - #{$right-sidebar-width}) auto;
+ grid-gap: 3%;
}
}
@@ -994,7 +983,7 @@ $tabs-holder-z-index: 250;
.submit-review-dropdown {
&.show .dropdown-menu {
width: calc(100vw - 20px);
- max-width: 650px;
+ max-width: 680px;
max-height: calc(100vh - 50px);
.gl-dropdown-inner {
@@ -1004,7 +993,8 @@ $tabs-holder-z-index: 250;
.md-header {
.gl-tab-nav-item {
color: var(--gl-text-color, $gl-text-color);
- @include gl-pb-5;
+ @include gl-py-4;
+ @include gl-px-3;
&:hover {
@include gl-bg-none;
@@ -1037,10 +1027,29 @@ $tabs-holder-z-index: 250;
}
.md-preview-holder {
- max-height: 172px;
+ max-height: 182px;
}
}
+.submit-review-dropdown-animated {
+ animation: review-btn-animate 300ms ease-in;
+}
+
+@include keyframes(review-btn-animate) {
+ 0% {
+ transform: scale(1);
+ }
+
+ 75% {
+ transform: scale(1.2);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+
.mr-widget-merge-details {
*,
& {
@@ -1069,16 +1078,7 @@ $tabs-holder-z-index: 250;
.merge-request-sticky-header {
z-index: 204;
- box-shadow: 0 1px 2px $issue-boards-card-shadow;
- --width: calc(100% - #{$contextual-sidebar-width});
-
- @include media-breakpoint-down(lg) {
- --width: calc(100% - #{$contextual-sidebar-collapsed-width});
- }
-}
-
-.page-with-icon-sidebar .issue-sticky-header {
- --width: calc(100% - #{$contextual-sidebar-collapsed-width});
+ height: $mr-sticky-header-height;
}
.merge-request-notification-toggle {
@@ -1123,7 +1123,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;
@@ -1131,7 +1131,7 @@ $tabs-holder-z-index: 250;
width: 100%;
height: $toggle-sidebar-height;
padding-left: $contextual-sidebar-width;
- padding-right: $gutter_collapsed_width;
+ padding-right: $right-sidebar-collapsed-width;
background: var(--white, $white);
border-top: 1px solid var(--border-color, $border-color);
transition: padding $gl-transition-duration-medium;
@@ -1140,10 +1140,6 @@ $tabs-holder-z-index: 250;
padding-left: $contextual-sidebar-collapsed-width;
}
- .right-sidebar-expanded & {
- padding-right: $gutter_width;
- }
-
@media (max-width: map-get($grid-breakpoints, sm)-1) {
padding-left: 0;
padding-right: 0;
@@ -1208,3 +1204,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 9ee6d17cb50..8dc07715989 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -120,10 +120,6 @@
}
}
-.milestone-detail {
- border-bottom: 1px solid var(--border-color, $border-color);
-}
-
@include media-breakpoint-down(md) {
.milestone-actions {
@include clearfix();
@@ -135,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/pages/ml_experiment_tracking.scss b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss
index 3c025b5d23f..d6f71b12cd9 100644
--- a/app/assets/stylesheets/pages/ml_experiment_tracking.scss
+++ b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss
@@ -1,26 +1,10 @@
@import '../page_bundles/mixins_and_variables_and_functions';
-.ml-experiment-row {
- .title {
- margin-bottom: $gl-spacing-scale-1;
- font-weight: $gl-font-weight-bold;
- }
-
- .ml-experiment-info {
- color: $gl-text-color-secondary;
- }
-
- a {
- color: $gl-text-color;
- }
-}
-
table.ml-candidate-table {
- table-layout: fixed;
-
tr td,
tr th {
padding: $gl-padding-8;
+ min-width: 100px;
> * {
@include gl-display-block;
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/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
index fc745433f1b..dfc86a73635 100644
--- a/app/assets/stylesheets/page_bundles/profile.scss
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -135,10 +135,9 @@
}
}
+// Limits the width of the user bio for readability.
.profile-user-bio {
- // Limits the width of the user bio for readability.
max-width: 600px;
- margin: 10px auto;
}
.user-calendar {
@@ -172,7 +171,6 @@
}
.avatar-holder {
- width: 90px;
margin: 0 auto 10px;
}
}
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/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss
index 8978b8d798b..9a0d7880734 100644
--- a/app/assets/stylesheets/page_bundles/settings.scss
+++ b/app/assets/stylesheets/page_bundles/settings.scss
@@ -138,42 +138,32 @@
border-radius: $gl-border-radius-base;
}
-.prometheus-metrics-monitoring {
- .card {
- .card-toggle {
- width: 14px;
- }
+.prometheus-metrics-monitoring {
+ .gl-card {
.badge.badge-pill {
font-size: 12px;
line-height: 12px;
}
- .card-header .label-count {
+ .gl-card-header .label-count {
color: var(--white, $white);
background: var(--gray-800, $gray-800);
}
- .card-body {
- padding: 0;
- }
-
.flash-container {
margin-bottom: 0;
cursor: default;
- .flash-notice {
+ .flash-notice,
+ .flash-warning {
+ margin-top: 0;
border-radius: 0;
}
}
}
.custom-monitored-metrics {
- .card-header {
- display: flex;
- align-items: center;
- }
-
.custom-metric {
display: flex;
align-items: center;
diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss
index 4fc671dace8..2bb3ff77132 100644
--- a/app/assets/stylesheets/page_bundles/signup.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -9,21 +9,7 @@
}
.omniauth-divider {
- &::before,
- &::after {
- content: '';
- flex: 1;
- border-bottom: 1px solid var(--gray-100, $gray-100);
- margin: $gl-padding-24 0;
- }
-
- &::before {
- margin-right: $gl-padding;
- }
-
- &::after {
- margin-left: $gl-padding;
- }
+ @include omniauth-divider;
}
.decline-page {
diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss
index 50d9684c7d2..a13b8704095 100644
--- a/app/assets/stylesheets/page_bundles/tree.scss
+++ b/app/assets/stylesheets/page_bundles/tree.scss
@@ -6,7 +6,7 @@
.tree-holder {
.nav-block {
- margin: 16px 0;
+ margin: $gl-spacing-scale-2 0 $gl-spacing-scale-5;
.tree-ref-holder {
margin-right: 15px;
@@ -103,7 +103,6 @@
tr {
border-bottom: 1px solid var(--gray-50, $gray-50);
- border-top: 1px solid var(--gray-50, $gray-50);
&:last-of-type {
border-bottom-color: transparent;
diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index 9bbea48d2c0..69a3ec94fda 100644
--- a/app/assets/stylesheets/page_bundles/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -102,8 +102,43 @@
}
}
- .active > a {
- color: var(--black, $black);
+ .active > .wiki-list {
+ a,
+ .wiki-list-expand-button,
+ .wiki-list-collapse-button {
+ 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: $gray-400;
+
+ &:hover {
+ color: $black;
+ }
}
ul.wiki-pages,
@@ -113,12 +148,8 @@
margin: 0;
}
- ul.wiki-pages li {
- margin: 5px 0 10px;
- }
-
ul.wiki-pages ul {
- padding-left: 15px;
+ padding-left: 20px;
}
.wiki-sidebar-header {
@@ -153,3 +184,44 @@ ul.wiki-pages-list.content-list {
.wiki-form .markdown-area {
max-height: 55vh;
}
+
+.wiki-list {
+ .wiki-list-create-child-button {
+ display: none;
+ }
+
+ .wiki-list-expand-button,
+ .wiki-list-collapse-button {
+ left: -$gl-spacing-scale-5;
+ }
+
+ .wiki-list-expand-button {
+ display: none;
+ }
+
+ &.collapsed {
+ .wiki-list-collapse-button {
+ display: none;
+ }
+
+ .wiki-list-expand-button {
+ display: block;
+ }
+ }
+
+ &.collapsed + ul {
+ display: none;
+ }
+}
+
+.drawio-editor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 100vw;
+ height: 100vh;
+ border: 0;
+ z-index: 1100;
+ visibility: hidden;
+}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 07a0cf3f367..ecbb872e1df 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -87,9 +87,20 @@
}
}
-.work-item-notes {
- .discussion-notes ul.notes li.toggle-replies-widget {
- // offset for .timeline-content padding + an extra 1px for border width
- margin: -5px -9px;
+// 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/colors.scss b/app/assets/stylesheets/pages/colors.scss
index d1917948c88..85c1f7da07f 100644
--- a/app/assets/stylesheets/pages/colors.scss
+++ b/app/assets/stylesheets/pages/colors.scss
@@ -1,5 +1,4 @@
.color-item {
- @include gl-align-items-center;
@include gl-display-flex;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 7d465dbcc04..b25a5b1c493 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -71,18 +71,6 @@
}
}
-.commit-header {
- padding: 5px 10px;
- background-color: $gray-light;
- border-bottom: 1px solid $gray-darker;
- border-top: 1px solid $gray-darker;
- font-size: 14px;
-
- &:first-child {
- border-top-width: 0;
- }
-}
-
.commit-row-title {
.str-truncated {
max-width: 70%;
@@ -270,6 +258,10 @@
&:hover {
@include gl-text-decoration-none;
}
+
+ .header-main-content & {
+ @include gl-mr-2;
+ }
}
.gpg-popover-certificate-details {
@@ -328,3 +320,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 75c81b74ba7..36efe42aed1 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;
}
}
@@ -236,28 +235,25 @@ ul.related-merge-requests > li gl-emoji {
outline: none;
&::after {
+ @include gl-dark-invert-keep-hue;
content: image-url('icon_anchor.svg');
visibility: hidden;
}
}
&:hover > a.anchor::after {
+ position: relative;
+ top: -3px;
visibility: visible;
}
}
}
.issue-sticky-header {
- --width: 100%;
-
- @include gl-left-0;
- width: var(--width);
- top: $header-height;
-
- // collapsed right sidebar
- @include media-breakpoint-up(sm) {
- --width: calc(100% - #{$gutter-collapsed-width});
- }
+ left: var(--application-bar-left);
+ right: var(--application-bar-right);
+ width: auto;
+ top: $calc-application-header-height;
}
.limit-container-width {
@@ -266,47 +262,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
- .issue-sticky-header {
- left: $contextual-sidebar-collapsed-width;
- --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
- }
-
- // collapsed left sidebar + expanded right sidebar
- .right-sidebar-expanded .issue-sticky-header {
- --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
- }
-}
-
-@include media-breakpoint-up(xl) {
- // expanded left sidebar + collapsed right sidebar
- .issue-sticky-header {
- left: $contextual-sidebar-width;
- --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
- }
-
- // collapsed left sidebar + collapsed right sidebar
- .page-with-icon-sidebar .issue-sticky-header {
- left: $contextual-sidebar-collapsed-width;
- --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
- }
-
- // expanded left sidebar + expanded right sidebar
- .right-sidebar-expanded .issue-sticky-header {
- --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
- }
-
- // collapsed left sidebar + expanded right sidebar
- .right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header {
- --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
- }
-}
-
.issuable-header-slide-enter-active,
.issuable-header-slide-leave-active {
@include gl-transition-medium;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index bd66319d78f..15d4a0fec9a 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -1,5 +1,5 @@
.suggest-colors {
- margin-top: 5px;
+ padding-top: 3px;
a {
border-radius: 4px;
@@ -9,23 +9,50 @@
margin-right: 10px;
margin-bottom: 10px;
text-decoration: none;
+
+ &:focus,
+ &:focus:active {
+ position: relative;
+ z-index: 1;
+ @include gl-focus;
+ }
}
&.suggest-colors-dropdown {
margin-top: 10px;
margin-bottom: 10px;
- border-radius: $border-radius-base;
- overflow: hidden;
a {
border-radius: 0;
width: (100% / 7);
margin-right: 0;
margin-bottom: -5px;
+
+ &:first-of-type {
+ border-top-left-radius: $border-radius-base;
+ }
+
+ &:nth-of-type(7) {
+ border-top-right-radius: $border-radius-base;
+ }
+
+ &:nth-last-child(7) {
+ border-bottom-left-radius: $border-radius-base;
+ }
+
+ &:last-of-type {
+ border-bottom-right-radius: $border-radius-base;
+ }
}
}
}
+.labels-select-contents-create {
+ .dropdown-input {
+ margin-bottom: 4px;
+ }
+}
+
.dropdown-menu-labels {
.dropdown-content {
max-height: 135px;
@@ -44,21 +71,7 @@
.dropdown-label-color-input {
position: relative;
- margin-bottom: 10px;
-
- &.is-active {
- padding-left: 32px;
- }
-}
-
-.dropdown-label-color-preview {
- position: absolute;
- left: 0;
- top: 0;
- width: 32px;
- height: 32px;
- border-top-left-radius: $border-radius-base;
- border-bottom-left-radius: $border-radius-base;
+ margin-bottom: 8px;
}
.color-label {
@@ -72,13 +85,19 @@
padding: 0;
margin-bottom: 0;
- > li:not(.empty-message):not(.no-border) {
- background-color: $white;
+ > li:not(.empty-message):not(.no-border) .label-content {
display: flex;
justify-content: space-between;
.prioritized-labels:not(.is-not-draggable) & {
cursor: grab;
+ border: 1px solid transparent;
+
+ &:hover,
+ &:focus-within {
+ background-color: $white;
+ border-color: $gray-50;
+ }
&:active {
cursor: grabbing;
@@ -92,6 +111,10 @@
}
}
+.label-list-item:not(:last-of-type) {
+ border-bottom: 1px solid $border-color;
+}
+
.prioritized-labels .add-priority,
.other-labels .remove-priority {
display: none;
@@ -102,6 +125,7 @@
}
.label-actions-list {
+ font-size: 0;
list-style: none;
flex-shrink: 0;
text-align: right;
@@ -119,7 +143,7 @@
font-size: $label-font-size;
}
-.label-list-item {
+.label-content {
.label-name {
width: 200px;
@@ -144,7 +168,7 @@
@media (max-width: map-get($grid-breakpoints, md)-1) {
.manage-labels-list {
- > li:not(.empty-message):not(.no-border) {
+ > li:not(.empty-message):not(.no-border) .label-content {
flex-wrap: wrap;
}
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/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index adeab227670..d029aa01e37 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -31,7 +31,7 @@
.note-textarea {
display: block;
- padding: 10px 1px;
+ padding: 10px 16px;
color: $gl-text-color;
font-family: $regular-font;
border: 0;
@@ -48,9 +48,8 @@
.common-note-form {
.md-area {
- padding: 0 $gl-padding;
border: 1px solid $border-color;
- border-radius: $border-radius-base;
+ border-radius: $border-radius-large;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s;
background-color: $white;
@@ -81,6 +80,10 @@
@include gl-focus;
}
+.md-header {
+ min-height: 32px;
+}
+
.md-header .nav-links {
display: flex;
flex-flow: row wrap;
@@ -92,6 +95,11 @@
}
}
+
+.md-header .gl-tabs-nav {
+ border-bottom: 0;
+}
+
.issuable-note-warning {
color: $orange-600;
background-color: $orange-50;
@@ -305,7 +313,6 @@ table {
.comment-toolbar {
color: $gl-text-color-secondary;
- border-top: 1px solid $border-color;
}
.toolbar-button {
@@ -394,6 +401,10 @@ table {
float: left;
margin-top: 5px;
}
+
+ button {
+ font-size: $gl-font-size-sm !important;
+ }
}
.uploading-error-icon,
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 5d03281a30a..b31ee069236 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -1,15 +1,24 @@
-$system-note-icon-size: 2rem;
+$avatar-icon-size: 2rem;
+$avatar-m-top: 0.5rem;
+$avatar-m-ratio: 2;
+$avatar-m-left: $avatar-m-top * $avatar-m-ratio;
+
+$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 - 1.3rem;
+$system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
@mixin vertical-line($left) {
&::before {
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);
}
}
@@ -21,8 +30,30 @@ $system-note-svg-size: 1rem;
.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;
+ }
}
}
@@ -36,6 +67,15 @@ $system-note-svg-size: 1rem;
&.timeline > .timeline-entry {
margin: $gl-padding 0;
+ &.system-note {
+ margin-top: $gl-spacing-scale-1;
+ margin-bottom: 0;
+
+ .note-header-info {
+ padding-left: $gl-spacing-scale-4;
+ }
+ }
+
&.system-note,
&.note-form {
border: 0;
@@ -45,6 +85,10 @@ $system-note-svg-size: 1rem;
height: 2rem;
}
+ .gl-avatar {
+ border-color: var(--gray-50, $gray-50);
+ }
+
&.note-comment,
&.note-skeleton,
.draft-note {
@@ -113,7 +157,7 @@ $system-note-svg-size: 1rem;
background-color: $white;
.timeline-content:not(.flash-container) {
- padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding;
+ padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 18px;
}
.timeline-discussion-body-footer {
@@ -247,7 +291,10 @@ $system-note-svg-size: 1rem;
&.being-posted {
pointer-events: none;
- opacity: 0.5;
+
+ .timeline-entry-inner {
+ opacity: 0.5;
+ }
.dummy-avatar {
background-color: $gray-100;
@@ -270,7 +317,7 @@ $system-note-svg-size: 1rem;
}
&.is-editing {
- .note-header,
+ .note-actions,
.note-text,
.edited-text {
display: none;
@@ -325,6 +372,7 @@ $system-note-svg-size: 1rem;
.note-header-info {
padding-bottom: 0;
+ padding-top: 0;
}
&.timeline-entry::after {
@@ -351,9 +399,7 @@ $system-note-svg-size: 1rem;
}
.timeline-content {
- @include notes-media('min', map-get($grid-breakpoints, sm)) {
- margin-left: 30px;
- }
+ margin-left: 30px;
}
.note-header {
@@ -432,40 +478,6 @@ $system-note-svg-size: 1rem;
}
}
}
-
- .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: -6px 0 0;
-
- 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 {
@@ -475,7 +487,7 @@ $system-note-svg-size: 1rem;
}
.timeline-icon {
- margin: 8px 0 0 14px;
+ margin: 20px 0 0 28px;
}
}
@@ -488,18 +500,6 @@ $system-note-svg-size: 1rem;
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;
}
@@ -585,15 +585,6 @@ $system-note-svg-size: 1rem;
.system-note {
background-color: transparent;
padding: 0;
-
- .timeline-icon {
- margin-top: -2px;
- }
-
- .timeline-entry-inner .timeline-icon {
- margin-top: $grid-size;
- margin-left: 14px;
- }
}
}
@@ -625,10 +616,6 @@ $system-note-svg-size: 1rem;
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 {
@@ -655,6 +642,14 @@ $system-note-svg-size: 1rem;
}
}
}
+
+ .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 {
@@ -672,7 +667,7 @@ $system-note-svg-size: 1rem;
.discussion-reply-holder {
border-top: 0;
- border-radius: 0 0 $border-radius-default $border-radius-default;
+ border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base;
position: relative;
.discussion-form {
@@ -1134,7 +1129,7 @@ $system-note-svg-size: 1rem;
}
.timeline-avatar {
- margin: $gl-padding-8 0 0 $gl-padding;
+ margin: $avatar-m-top 0 0 $avatar-m-left;
}
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 8e4dd39e498..41b022437bb 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -64,13 +64,6 @@
}
}
-table.u2f-registrations {
- th:not(:last-child),
- td:not(:last-child) {
- border-right: solid 1px transparent;
- }
-}
-
.codes {
padding-top: 14px;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ee91d955019..ff1987f35b3 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 {
@@ -654,3 +654,17 @@
}
}
}
+
+.projects-list-item {
+ .description {
+ max-height: $gl-spacing-scale-8;
+
+ p {
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ text-overflow: ellipsis;
+ /* stylelint-disable-next-line value-no-vendor-prefix */
+ display: -webkit-box;
+ }
+ }
+}
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..3b5e234c6b8 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 {
@@ -108,9 +75,3 @@
}
}
}
-
-.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap {
- @include gl-media-breakpoint-up(md) {
- @include gl-flex-wrap-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 ab86a2f69dd..84181a00f34 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -25,6 +25,7 @@ nav,
nav.navbar-collapse,
nav.navbar-collapse.collapse,
.nav-sidebar,
+.super-sidebar,
.profiler-results,
.tree-ref-holder,
.tree-holder .breadcrumb,
@@ -62,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 3b28025053b..74ffebd44ec 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -119,7 +119,7 @@ kbd kbd {
.form-control {
display: block;
width: 100%;
- height: 34px;
+ height: 32px;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 400;
@@ -132,10 +132,6 @@ kbd kbd {
}
@media (prefers-reduced-motion: reduce) {
}
-.form-control:-moz-focusring {
- color: transparent;
- text-shadow: 0 0 0 #ececef;
-}
.form-control::placeholder {
color: #a4a3a8;
opacity: 1;
@@ -144,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;
@@ -352,6 +336,7 @@ kbd kbd {
border: 0;
}
.gl-avatar {
+ display: inline-flex;
border-width: 1px;
border-style: solid;
border-color: rgba(251, 250, 253, 0.08);
@@ -566,17 +551,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;
@@ -597,6 +578,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;
@@ -636,39 +625,40 @@ html {
color: #bfbfc3;
vertical-align: baseline;
}
-.gl-font-sm {
- font-size: 12px;
-}
-.dropdown {
- position: relative;
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb;
- outline: none;
+.with-top-bar {
+ --top-bar-height: 48px;
}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
+@media (min-width: 768px) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: 56px;
+ }
}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #333238;
- color: #ececef;
- font-size: 14px;
- text-align: left;
- border: 1px solid #434248;
- border-radius: 0.25rem;
- white-space: nowrap;
+@media (min-width: 1200px) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: 256px;
+ }
+ .page-with-icon-sidebar {
+ --application-bar-left: 56px;
+ }
+ .page-with-super-sidebar {
+ --application-bar-left: 256px;
+ }
+ .page-with-super-sidebar-collapsed {
+ --application-bar-left: 0px;
+ }
}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
+.gl-font-sm {
+ font-size: 12px;
}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-right: 25px;
+.dropdown {
position: relative;
- text-overflow: ellipsis;
- width: 160px;
}
.dropdown-menu {
display: none;
@@ -751,11 +741,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;
@@ -794,7 +779,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;
@@ -1076,11 +1061,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);
@@ -1493,6 +1482,50 @@ kbd {
display: none;
}
}
+.super-sidebar {
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ 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);
+ transform: translate3d(0, 0, 0);
+ width: 256px;
+ z-index: 600;
+}
+.super-sidebar.super-sidebar-loading {
+ transform: translate3d(-100%, 0, 0);
+}
+@media (min-width: 1200px) {
+ .super-sidebar.super-sidebar-loading {
+ 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;
+ }
+}
+.page-with-super-sidebar-collapsed .super-sidebar {
+ transform: translate3d(-100%, 0, 0);
+}
+@media (min-width: 1200px) {
+ .page-with-super-sidebar-collapsed {
+ padding-left: 0;
+ }
+}
input::-moz-placeholder {
color: #737278;
opacity: 1;
@@ -1631,7 +1664,6 @@ svg.s16 {
--gray-200: #535158;
--gray-700: #bfbfc3;
--gray-900: #ececef;
- --gl-text-color: #ececef;
--border-color: #434248;
--white: #333238;
--black: #fff;
@@ -1743,16 +1775,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;
}
@@ -1781,17 +1803,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 adafe719892..c5a5d1aa289 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -119,7 +119,7 @@ kbd kbd {
.form-control {
display: block;
width: 100%;
- height: 34px;
+ height: 32px;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 400;
@@ -132,10 +132,6 @@ kbd kbd {
}
@media (prefers-reduced-motion: reduce) {
}
-.form-control:-moz-focusring {
- color: transparent;
- text-shadow: 0 0 0 #333238;
-}
.form-control::placeholder {
color: #626168;
opacity: 1;
@@ -144,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;
@@ -352,6 +336,7 @@ kbd kbd {
border: 0;
}
.gl-avatar {
+ display: inline-flex;
border-width: 1px;
border-style: solid;
border-color: rgba(31, 30, 36, 0.08);
@@ -566,17 +551,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;
@@ -597,6 +578,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;
@@ -636,39 +625,40 @@ html {
color: #535158;
vertical-align: baseline;
}
-.gl-font-sm {
- font-size: 12px;
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
}
-.dropdown {
- position: relative;
-}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc;
- outline: none;
+.with-top-bar {
+ --top-bar-height: 48px;
}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
+@media (min-width: 768px) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: 56px;
+ }
}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #fff;
- color: #333238;
- font-size: 14px;
- text-align: left;
- border: 1px solid #dcdcde;
- border-radius: 0.25rem;
- white-space: nowrap;
+@media (min-width: 1200px) {
+ .page-with-contextual-sidebar {
+ --application-bar-left: 256px;
+ }
+ .page-with-icon-sidebar {
+ --application-bar-left: 56px;
+ }
+ .page-with-super-sidebar {
+ --application-bar-left: 256px;
+ }
+ .page-with-super-sidebar-collapsed {
+ --application-bar-left: 0px;
+ }
}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
+.gl-font-sm {
+ font-size: 12px;
}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-right: 25px;
+.dropdown {
position: relative;
- text-overflow: ellipsis;
- width: 160px;
}
.dropdown-menu {
display: none;
@@ -751,11 +741,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;
@@ -794,7 +779,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;
@@ -1076,11 +1061,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);
@@ -1493,6 +1482,50 @@ kbd {
display: none;
}
}
+.super-sidebar {
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ 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);
+ transform: translate3d(0, 0, 0);
+ width: 256px;
+ z-index: 600;
+}
+.super-sidebar.super-sidebar-loading {
+ transform: translate3d(-100%, 0, 0);
+}
+@media (min-width: 1200px) {
+ .super-sidebar.super-sidebar-loading {
+ 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;
+ }
+}
+.page-with-super-sidebar-collapsed .super-sidebar {
+ transform: translate3d(-100%, 0, 0);
+}
+@media (min-width: 1200px) {
+ .page-with-super-sidebar-collapsed {
+ padding-left: 0;
+ }
+}
input::-moz-placeholder {
color: #89888d;
opacity: 1;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 3aace601c45..f676782de2a 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -192,7 +192,7 @@ hr {
.form-control {
display: block;
width: 100%;
- height: 34px;
+ height: 32px;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 400;
@@ -205,10 +205,6 @@ hr {
}
@media (prefers-reduced-motion: reduce) {
}
-.form-control:-moz-focusring {
- color: transparent;
- text-shadow: 0 0 0 #333238;
-}
.form-control::placeholder {
color: #626168;
opacity: 1;
@@ -262,7 +258,7 @@ input.btn-block[type="button"] {
display: block;
min-height: 1.5rem;
padding-left: 1.5rem;
- color-adjust: exact;
+ print-color-adjust: exact;
}
.custom-control-input {
position: absolute;
@@ -303,7 +299,7 @@ input.btn-block[type="button"] {
pointer-events: none;
content: "";
background-color: #fff;
- border: #737278 solid 1px;
+ border: 1px solid #737278;
}
.custom-control-label::after {
position: absolute;
@@ -313,7 +309,7 @@ input.btn-block[type="button"] {
width: 1rem;
height: 1rem;
content: "";
- background: no-repeat 50% / 50% 50%;
+ background: 50% / 50% 50% no-repeat;
}
.custom-checkbox .custom-control-label::before {
border-radius: 0.25rem;
@@ -364,11 +360,6 @@ input.btn-block[type="button"] {
align-items: center;
justify-content: space-between;
}
-.clearfix::after {
- display: block;
- clear: both;
- content: "";
-}
.fixed-top {
position: fixed;
top: 0;
@@ -434,8 +425,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
@@ -444,6 +440,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,11 +660,17 @@ body.navless {
.btn-block {
width: 100%;
margin: 0;
- margin-bottom: 1rem;
}
.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;
}
@@ -695,7 +698,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 {
@@ -726,7 +733,7 @@ label.label-bold {
color: #89888d;
}
.gl-show-field-errors .form-control:not(textarea) {
- height: 34px;
+ height: 32px;
}
.navbar-empty {
justify-content: center;
@@ -761,234 +768,18 @@ 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;
}
.gl-display-inline-block {
display: inline-block;
}
-.gl-flex-wrap {
- flex-wrap: wrap;
-}
-.gl-justify-content-center {
- justify-content: center;
+.gl-align-items-center {
+ align-items: center;
}
.gl-justify-content-space-between {
justify-content: space-between;
@@ -1002,9 +793,6 @@ svg {
.gl-w-half {
width: 50%;
}
-.gl-w-90p {
- width: 90%;
-}
.gl-w-full {
width: 100%;
}
@@ -1013,9 +801,6 @@ svg {
width: 100%;
}
}
-.gl-p-5 {
- padding: 1rem;
-}
.gl-px-5 {
padding-left: 1rem;
padding-right: 1rem;
@@ -1023,6 +808,13 @@ svg {
.gl-pt-5 {
padding-top: 1rem;
}
+.gl-pb-5 {
+ padding-bottom: 1rem;
+}
+.gl-py-5 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
.gl-mt-3 {
margin-top: 0.5rem;
}
@@ -1032,9 +824,6 @@ svg {
.gl-mr-auto {
margin-right: auto;
}
-.gl-mr-2 {
- margin-right: 0.25rem;
-}
.gl-mb-1 {
margin-bottom: 0.125rem;
}
@@ -1047,9 +836,6 @@ svg {
.gl-ml-auto {
margin-left: auto;
}
-.gl-ml-2 {
- margin-left: 0.25rem;
-}
@media (min-width: 576px) {
.gl-sm-mt-0 {
margin-top: 0;
@@ -1061,9 +847,6 @@ svg {
.gl-font-size-h2 {
font-size: 1.1875rem;
}
-.gl-font-weight-normal {
- font-weight: 400;
-}
.gl-font-weight-bold {
font-weight: 600;
}
diff --git a/app/assets/stylesheets/test_environment.scss b/app/assets/stylesheets/test_environment.scss
index e9ba41f9bb7..38fe2795ff4 100644
--- a/app/assets/stylesheets/test_environment.scss
+++ b/app/assets/stylesheets/test_environment.scss
@@ -1,9 +1,3 @@
-// Disable sticky changes bar for tests
-.diff-files-changed {
- position: relative !important;
- top: 0 !important;
-}
-
// Un-hide inputs for @gitlab/ui custom checkboxes and radios so Capybara can target them
.custom-control-input {
z-index: 500;
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index bb97261a1ca..3a18f735217 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -143,6 +143,17 @@ body.gl-dark {
background-color: $gray-200;
}
}
+
+ .gl-new-dropdown-item {
+ &:active,
+ &:hover,
+ &:focus,
+ &:focus:active {
+ .gl-new-dropdown-item-content {
+ @include gl-bg-gray-10;
+ }
+ }
+ }
}
// Some hacks and overrides for things that don't properly support dark mode
@@ -289,3 +300,13 @@ body.gl-dark {
// soften on darkmode
background-color: mix($gray-50, $orange-50, 75%) !important;
}
+
+.tanuki-bot-chat-drawer .tanuki-bot-message {
+ // lightens chat bubble in darkmode as $gray-50 matches drawer background. See tanuki_bot_chat.scss
+ background-color: $gray-100;
+}
+
+.ai-genie-chat,
+.ai-genie-chat .gl-form-input {
+ background-color: $gray-10;
+}
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 af98d59251f..fd378dc7008 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;
@@ -59,6 +65,10 @@
min-width: 0;
}
+.gl-min-h-100vh {
+ min-height: 100vh;
+}
+
// .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466
.gl-font-size-inherit,
.font-size-inherit { font-size: inherit; }
@@ -71,58 +81,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 +93,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 +116,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,37 +128,46 @@ 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-gap-2 {
- gap: $gl-spacing-scale-2;
+.gl-md-w-20 {
+ @include gl-media-breakpoint-up(md) {
+ width: $gl-spacing-scale-20;
+ }
}
-.gl-hover-bg-t-gray-a-08:hover {
- background-color: $t-gray-a-08;
+.gl-md-w-30 {
+ @include gl-media-breakpoint-up(md) {
+ width: $gl-spacing-scale-30;
+ }
}
-/* End gitlab-ui#1709 */
-
-/*
- * The below style will be moved to @gitlab/ui by
- * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1751
- */
-.gl-filter-blur-1 {
- backdrop-filter: blur(2px);
- /* stylelint-disable property-no-vendor-prefix */
- -webkit-backdrop-filter: blur(2px); // still required by Safari
+.gl-fill-orange-500 {
+ fill: $orange-500;
}
-.gl-flex-flow-row-wrap {
- flex-flow: row wrap;
+.gl-fill-red-500 {
+ fill: $red-500;
}
-.gl-isolate {
- isolation: isolate;
+/**
+ Note: used by app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
+ Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab/-/issues/408643
+
+ Although this solution uses vendor-prefixes, it is supported by all browsers and it is
+ currently the only way to truncate text by lines. See https://caniuse.com/css-line-clamp
+**/
+.gl-truncate-text-by-line {
+ // stylelint-disable-next-line value-no-vendor-prefix
+ display: -webkit-box;
+ -webkit-line-clamp: var(--lines);
+ -webkit-box-orient: vertical;
+
+ @include gl-media-breakpoint-down(sm) {
+ -webkit-line-clamp: var(--mobile-lines);
+ }
}
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/base_component.rb b/app/components/diffs/base_component.rb
index 9e1347d1e84..f5bc59cb314 100644
--- a/app/components/diffs/base_component.rb
+++ b/app/components/diffs/base_component.rb
@@ -2,6 +2,8 @@
module Diffs
class BaseComponent < ViewComponent::Base
+ warn_on_deprecated_slot_setter
+
# To make converting the partials to components easier,
# we delegate all missing methods to the helpers,
# where they probably are.
diff --git a/app/components/diffs/overflow_warning_component.html.haml b/app/components/diffs/overflow_warning_component.html.haml
index 551d995cb22..19048c5058e 100644
--- a/app/components/diffs/overflow_warning_component.html.haml
+++ b/app/components/diffs/overflow_warning_component.html.haml
@@ -1,9 +1,9 @@
-= 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
+ = c.with_body do
= message
- = c.actions do
+ = c.with_actions do
= diff_link
= patch_link
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/components/layouts/horizontal_section_component.rb b/app/components/layouts/horizontal_section_component.rb
index 48c960f17d9..caeaa1782c0 100644
--- a/app/components/layouts/horizontal_section_component.rb
+++ b/app/components/layouts/horizontal_section_component.rb
@@ -2,6 +2,8 @@
module Layouts
class HorizontalSectionComponent < ViewComponent::Base
+ warn_on_deprecated_slot_setter
+
# @param [Boolean] border
# @param [Hash] options
def initialize(border: true, options: {})
diff --git a/app/components/pajamas/component.rb b/app/components/pajamas/component.rb
index 3b1826a646c..a7b45ffd7fd 100644
--- a/app/components/pajamas/component.rb
+++ b/app/components/pajamas/component.rb
@@ -2,6 +2,8 @@
module Pajamas
class Component < ViewComponent::Base
+ warn_on_deprecated_slot_setter
+
private
# Filter a given a value against a list of allowed values
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/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb
index 5357558434e..84e5cc430ef 100644
--- a/app/controllers/admin/abuse_reports_controller.rb
+++ b/app/controllers/admin/abuse_reports_controller.rb
@@ -3,16 +3,37 @@
class Admin::AbuseReportsController < Admin::ApplicationController
feature_category :insider_threat
+ before_action :set_status_param, only: :index, if: -> { Feature.enabled?(:abuse_reports_list) }
+ before_action :find_abuse_report, only: [:show, :update, :destroy]
+
def index
@abuse_reports = AbuseReportsFinder.new(params).execute
end
- def destroy
- abuse_report = AbuseReport.find(params[:id])
+ def show; end
+
+ def update
+ Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute
+ end
- abuse_report.remove_user(deleted_by: current_user) if params[:remove_user]
- abuse_report.destroy
+ def destroy
+ @abuse_report.remove_user(deleted_by: current_user) if params[:remove_user]
+ @abuse_report.destroy
head :ok
end
+
+ private
+
+ def find_abuse_report
+ @abuse_report = AbuseReport.find(params[:id])
+ end
+
+ def set_status_param
+ params[:status] ||= 'open'
+ end
+
+ def permitted_params
+ params.permit(:user_action, :close, :reason, :comment)
+ end
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index ade58ca0970..dff1c04311d 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -13,22 +13,16 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :disable_query_limiting, only: [:usage_data]
+ before_action do
+ push_frontend_feature_flag(:ci_variables_pages, current_user)
+ end
+
feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
:general, :reporting, :metrics_and_profiling, :network,
:preferences, :update, :reset_health_check_token
]
- feature_category :metrics, [
- :create_self_monitoring_project,
- :status_create_self_monitoring_project,
- :delete_self_monitoring_project,
- :status_delete_self_monitoring_project
- ]
urgency :low, [
- :create_self_monitoring_project,
- :status_create_self_monitoring_project,
- :delete_self_monitoring_project,
- :status_delete_self_monitoring_project,
:reset_error_tracking_access_token
]
@@ -101,8 +95,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def reset_error_tracking_access_token
@application_setting.reset_error_tracking_access_token!
- redirect_to general_admin_application_settings_path,
- notice: _('New error tracking access token has been generated!')
+ redirect_to general_admin_application_settings_path, notice: _('New error tracking access token has been generated!')
end
def clear_repository_check_states
@@ -121,91 +114,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
- # Specs are in spec/requests/self_monitoring_project_spec.rb
- def create_self_monitoring_project
- job_id = SelfMonitoringProjectCreateWorker.with_status.perform_async # rubocop:disable CodeReuse/Worker
-
- render status: :accepted, json: {
- job_id: job_id,
- monitor_status: status_create_self_monitoring_project_admin_application_settings_path
- }
- end
-
- # Specs are in spec/requests/self_monitoring_project_spec.rb
- def status_create_self_monitoring_project
- job_id = params[:job_id].to_s
-
- unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
- return render status: :bad_request, json: {
- message: format(_('Parameter "job_id" cannot exceed length of %{job_id_max_size}'), job_id_max_size: PARAM_JOB_ID_MAX_SIZE)
- }
- end
-
- if SelfMonitoringProjectCreateWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker
- ::Gitlab::PollingInterval.set_header(response, interval: 3_000)
-
- return render status: :accepted, json: {
- message: _('Job to create self-monitoring project is in progress')
- }
- end
-
- return render status: :ok, json: self_monitoring_data if @application_setting.self_monitoring_project_id.present?
-
- render status: :bad_request, json: {
- message: _('Self-monitoring project does not exist. Please check logs ' \
- 'for any error messages')
- }
- end
-
- # Specs are in spec/requests/self_monitoring_project_spec.rb
- def delete_self_monitoring_project
- job_id = SelfMonitoringProjectDeleteWorker.with_status.perform_async # rubocop:disable CodeReuse/Worker
-
- render status: :accepted, json: {
- job_id: job_id,
- monitor_status: status_delete_self_monitoring_project_admin_application_settings_path
- }
- end
-
- # Specs are in spec/requests/self_monitoring_project_spec.rb
- def status_delete_self_monitoring_project
- job_id = params[:job_id].to_s
-
- unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
- return render status: :bad_request, json: {
- message: format(_('Parameter "job_id" cannot exceed length of %{job_id_max_size}'), job_id_max_size: PARAM_JOB_ID_MAX_SIZE)
- }
- end
-
- if SelfMonitoringProjectDeleteWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker
- ::Gitlab::PollingInterval.set_header(response, interval: 3_000)
-
- return render status: :accepted, json: {
- message: _('Job to delete self-monitoring project is in progress')
- }
- end
-
- if @application_setting.self_monitoring_project_id.nil?
- return render status: :ok, json: {
- message: _('Self-monitoring project has been successfully deleted')
- }
- end
-
- render status: :bad_request, json: {
- message: _('Self-monitoring project was not deleted. Please check logs ' \
- 'for any error messages')
- }
- end
-
private
- def self_monitoring_data
- {
- project_id: @application_setting.self_monitoring_project_id,
- project_full_path: @application_setting.self_monitoring_project&.full_path
- }
- end
-
def set_application_setting
@application_setting = ApplicationSetting.current_without_cache
@plans = Plan.all
@@ -231,6 +141,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
@@ -273,6 +186,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 d66b3cb4366..d97fcc5df74 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -3,19 +3,17 @@
class Admin::ApplicationsController < Admin::ApplicationController
include OauthApplications
- before_action :set_application, only: [:show, :edit, :update, :destroy]
+ before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:new, :create, :edit, :update]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
applications = ApplicationsFinder.new.execute
@applications = Kaminari.paginate_array(applications).page(params[:page])
end
- def show
- @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
- end
+ def show; end
def new
@application = Doorkeeper::Application.new
@@ -30,14 +28,8 @@ class Admin::ApplicationsController < Admin::ApplicationController
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- if Feature.enabled?('hash_oauth_secrets')
- @created = true
- render :show
- else
- set_created_session
-
- redirect_to admin_application_url(@application)
- end
+ @created = true
+ render :show
else
render :new
end
@@ -51,6 +43,16 @@ class Admin::ApplicationsController < Admin::ApplicationController
end
end
+ def renew
+ @application.renew_secret
+
+ if @application.save
+ render json: { secret: @application.plaintext_secret }
+ else
+ render json: { errors: @application.errors }, status: :unprocessable_entity
+ end
+ end
+
def destroy
@application.destroy
redirect_to admin_applications_url, status: :found, notice: _('Application was successfully destroyed.')
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/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index d641a26c9fb..821c3cc1635 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -6,7 +6,6 @@ module Admin
before_action :find_broadcast_message, only: [:edit, :update, :destroy]
before_action :find_broadcast_messages, only: [:index, :create]
- before_action :push_features, only: [:index, :edit]
feature_category :onboarding
urgency :low
@@ -15,8 +14,7 @@ module Admin
@broadcast_message = BroadcastMessage.new
end
- def edit
- end
+ def edit; end
def create
@broadcast_message = BroadcastMessage.new(broadcast_message_params)
@@ -72,7 +70,7 @@ module Admin
def preview
@broadcast_message = BroadcastMessage.new(broadcast_message_params)
- render partial: 'admin/broadcast_messages/preview'
+ render plain: render_broadcast_message(@broadcast_message), status: :ok
end
protected
@@ -88,18 +86,14 @@ module Admin
def broadcast_message_params
params.require(:broadcast_message)
.permit(%i[
- theme
- ends_at
- message
- starts_at
- target_path
- broadcast_type
- dismissable
- ], target_access_levels: []).reverse_merge!(target_access_levels: [])
- end
-
- def push_features
- push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user)
+ theme
+ ends_at
+ message
+ starts_at
+ target_path
+ broadcast_type
+ dismissable
+ ], target_access_levels: []).reverse_merge!(target_access_levels: [])
end
end
end
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index ef50d7362c4..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_authoring
+ feature_category :secrets_management
def show
respond_to do |format|
@@ -32,10 +32,7 @@ module Admin
end
def render_instance_variables
- render status: :ok,
- json: {
- variables: ::Ci::InstanceVariableSerializer.new.represent(variables)
- }
+ render status: :ok, json: { variables: ::Ci::InstanceVariableSerializer.new.represent(variables) }
end
def render_error(errors)
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index ce3d769f35e..3948d3635fe 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -7,7 +7,7 @@ class Admin::CohortsController < Admin::ApplicationController
urgency :low
- track_custom_event :index,
+ track_event :index,
name: 'i_analytics_cohorts',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb
index 71ee19ddf39..2e47dfcb0db 100644
--- a/app/controllers/admin/dev_ops_report_controller.rb
+++ b/app/controllers/admin/dev_ops_report_controller.rb
@@ -5,7 +5,7 @@ class Admin::DevOpsReportController < Admin::ApplicationController
helper_method :show_adoption?
- track_custom_event :show,
+ track_event :show,
name: 'i_analytics_dev_ops_score',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index e3a33bafb62..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])
@@ -65,8 +65,8 @@ class Admin::GroupsController < Admin::ApplicationController
Groups::DestroyService.new(@group, current_user).async_execute
redirect_to admin_groups_path,
- status: :found,
- alert: format(_('Group %{group_name} was scheduled for deletion.'), group_name: @group.name)
+ status: :found,
+ alert: format(_('Group %{group_name} was scheduled for deletion.'), group_name: @group.name)
end
private
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 1dc6c68d8ca..57ef75f12e9 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -3,8 +3,6 @@
class Admin::HooksController < Admin::ApplicationController
include ::WebHooks::HookActions
- before_action :hook_logs, only: :edit
-
feature_category :integrations
urgency :low, [:test]
@@ -26,10 +24,6 @@ class Admin::HooksController < Admin::ApplicationController
@hook ||= SystemHook.find(params[:id])
end
- def hook_logs
- @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count
- end
-
def hook_param_names
%i[enable_ssl_verification token url]
end
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index dcec50e882d..0745ba328c6 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -4,7 +4,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
before_action :user
before_action :identity, except: [:index, :new, :create]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
@identity = Identity.new
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index ddc555add5c..dae3337d19b 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -4,7 +4,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
before_action :user
before_action :verify_impersonation_enabled!
- feature_category :authentication_and_authorization
+ feature_category :user_management
def index
set_index_vars
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 6c45b03455e..c1a6cb350ec 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -4,7 +4,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
skip_before_action :authenticate_admin!
before_action :authenticate_impersonator!
- feature_category :authentication_and_authorization
+ feature_category :user_management
def destroy
original_user = stop_impersonation
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
index cc801bce5b7..87fb1fa1a66 100644
--- a/app/controllers/admin/instance_review_controller.rb
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -5,7 +5,7 @@ class Admin::InstanceReviewController < Admin::ApplicationController
urgency :low
def index
- redirect_to("#{Gitlab::SubscriptionPortal.subscriptions_instance_review_url}?#{instance_review_params}")
+ redirect_to("#{subscription_portal_instance_review_url}?#{instance_review_params}")
end
def instance_review_params
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 03383604e30..e4a756ec12d 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -3,7 +3,7 @@
class Admin::KeysController < Admin::ApplicationController
before_action :user, only: [:show, :destroy]
- feature_category :authentication_and_authorization
+ feature_category :user_management
def show
@key = user.keys.find(params[:id])
diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb
index ea52198432c..b8608390d4d 100644
--- a/app/controllers/admin/plan_limits_controller.rb
+++ b/app/controllers/admin/plan_limits_controller.rb
@@ -41,7 +41,6 @@ class Admin::PlanLimitsController < Admin::ApplicationController
generic_packages_max_file_size
ci_pipeline_size
ci_active_jobs
- ci_active_pipelines
ci_project_subscriptions
ci_pipeline_schedules
ci_needs_size_limit
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 5d37bd27302..84eb90ce334 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -3,10 +3,10 @@
class Admin::ProjectsController < Admin::ApplicationController
include MembersPresentation
- before_action :project, only: [:show, :transfer, :repository_check, :destroy]
+ before_action :project, only: [:show, :transfer, :repository_check, :destroy, :edit, :update]
before_action :group, only: [:show, :transfer]
- feature_category :projects, [:index, :show, :transfer, :destroy]
+ feature_category :projects, [:index, :show, :transfer, :destroy, :edit, :update]
feature_category :source_code_management, [:repository_check]
def index
@@ -62,6 +62,22 @@ class Admin::ProjectsController < Admin::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def edit; end
+
+ def update
+ 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"
+ end
+ end
+
def repository_check
RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id) # rubocop:disable CodeReuse/Worker
@@ -83,6 +99,18 @@ class Admin::ProjectsController < Admin::ApplicationController
def group
@group ||= @project.group
end
+
+ def project_params
+ params.require(:project).permit(allowed_project_params)
+ end
+
+ def allowed_project_params
+ [
+ :description,
+ :name,
+ :runner_registration_enabled
+ ]
+ end
end
Admin::ProjectsController.prepend_mod_with('Admin::ProjectsController')
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 7dbae565d07..79a31331374 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -10,9 +10,10 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success?
- redirect_to edit_admin_runner_url(@runner), notice: s_('Runners|Runner assigned to project.')
+ flash[:success] = s_('Runners|Runner assigned to project.')
+ redirect_to edit_admin_runner_url(@runner)
else
- redirect_to edit_admin_runner_url(@runner), alert: 'Failed adding runner to project'
+ redirect_to edit_admin_runner_url(@runner), alert: s_('Runners|Failed adding runner to project')
end
end
@@ -22,7 +23,8 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
::Ci::Runners::UnassignRunnerService.new(rp, current_user).execute
- redirect_to edit_admin_runner_url(runner), status: :found, notice: s_('Runners|Runner unassigned from project.')
+ flash[:success] = s_('Runners|Runner unassigned from project.')
+ redirect_to edit_admin_runner_url(runner), status: :found
end
private
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 21a3a0aea0b..f63616a2bea 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -6,7 +6,7 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :new, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
- push_frontend_feature_flag(:create_runner_workflow, current_user)
+ push_frontend_feature_flag(:create_runner_workflow_for_admin, current_user)
end
feature_category :runner
@@ -23,7 +23,12 @@ class Admin::RunnersController < Admin::ApplicationController
end
def new
- render_404 unless Feature.enabled?(:create_runner_workflow, current_user)
+ render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user)
+ end
+
+ def register
+ render_404 unless Feature.enabled?(:create_runner_workflow_for_admin, current_user) &&
+ runner.registration_available?
end
def update
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 63579421573..bb275532170 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -7,7 +7,7 @@ class Admin::SessionsController < ApplicationController
before_action :user_is_admin!
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
if current_user_mode.admin_mode?
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 984ae736697..b27185a6add 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -5,7 +5,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def index
- @spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page])
+ @spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]).without_count
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -15,8 +15,8 @@ class Admin::SpamLogsController < Admin::ApplicationController
if params[:remove_user]
spam_log.remove_user(deleted_by: current_user)
redirect_to admin_spam_logs_path,
- status: :found,
- notice: format(_('User %{username} was successfully removed.'), username: spam_log.user.username)
+ status: :found,
+ notice: format(_('User %{username} was successfully removed.'), username: spam_log.user.username)
else
spam_log.destroy
head :ok
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index 345a778772d..94d084932ad 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -41,8 +41,8 @@ class Admin::TopicsController < Admin::ApplicationController
@topic.destroy!
redirect_to admin_topics_path,
- status: :found,
- notice: format(_('Topic %{topic_name} was successfully removed.'), topic_name: @topic.title_or_name)
+ status: :found,
+ notice: format(_('Topic %{topic_name} was successfully removed.'), topic_name: @topic.title_or_name)
end
def merge
diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb
index 082b38ac3a8..f88028535c1 100644
--- a/app/controllers/admin/usage_trends_controller.rb
+++ b/app/controllers/admin/usage_trends_controller.rb
@@ -3,7 +3,7 @@
class Admin::UsageTrendsController < Admin::ApplicationController
include ProductAnalyticsTracking
- track_custom_event :index,
+ track_event :index,
name: 'i_analytics_instance_statistics',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 00b17bf381f..45a7901b2c4 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -96,28 +96,29 @@ class Admin::UsersController < Admin::ApplicationController
end
def deactivate
- if user.blocked?
- return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated"))
- end
-
- return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated?
- return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal?
+ deactivate_service = Users::DeactivateService.new(current_user, skip_authorization: true)
+ result = deactivate_service.execute(user)
- unless user.can_be_deactivated?
- return redirect_back_or_admin_user(notice: format(_("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated"), minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period))
+ if result.success?
+ redirect_back_or_admin_user(notice: _("Successfully deactivated"))
+ else
+ redirect_back_or_admin_user(alert: result.message)
end
-
- user.deactivate
- redirect_back_or_admin_user(notice: _("Successfully deactivated"))
end
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 353f9098b95..9749af08dca 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -29,11 +29,9 @@ class ApplicationController < ActionController::Base
before_action :limit_session_time, if: -> { !current_user }
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
- before_action :validate_user_service_ticket!
before_action :check_password_expiration, if: :html_request?
before_action :ldap_security_check
before_action :default_headers
- before_action :default_cache_headers
before_action :add_gon_variables, if: :html_request?
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
@@ -61,12 +59,10 @@ class ApplicationController < ActionController::Base
helper_method :can?
helper_method :import_sources_enabled?, :github_import_enabled?,
:gitea_import_enabled?, :github_import_configured?,
- :gitlab_import_enabled?, :gitlab_import_configured?,
:bitbucket_import_enabled?, :bitbucket_import_configured?,
:bitbucket_server_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
- :manifest_import_enabled?, :phabricator_import_enabled?,
- :masked_page_url
+ :manifest_import_enabled?, :masked_page_url
def self.endpoint_id_for_action(action_name)
"#{name}##{action_name}"
@@ -90,7 +86,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,
@@ -112,6 +108,11 @@ class ApplicationController < ActionController::Base
render plain: e.message, status: :too_many_requests
end
+ rescue_from Gitlab::Git::ResourceExhaustedError do |e|
+ response.headers.merge!(e.headers)
+ render plain: e.message, status: :too_many_requests
+ end
+
content_security_policy do |p|
next if p.directives.blank?
next unless Gitlab::CurrentSettings.snowplow_enabled? && !Gitlab::CurrentSettings.snowplow_collector_hostname.blank?
@@ -260,10 +261,7 @@ class ApplicationController < ActionController::Base
respond_to do |format|
format.html do
- render template,
- layout: "errors",
- status: status,
- locals: { message: message }
+ render template, layout: "errors", status: status, locals: { message: message }
end
format.any { head status }
end
@@ -319,10 +317,6 @@ class ApplicationController < ActionController::Base
headers['X-Content-Type-Options'] = 'nosniff'
end
- def default_cache_headers
- headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
- end
-
def stream_csv_headers(csv_filename)
no_cache_headers
stream_headers
@@ -331,20 +325,6 @@ class ApplicationController < ActionController::Base
headers['Content-Disposition'] = "attachment; filename=\"#{csv_filename}\""
end
- def validate_user_service_ticket!
- return unless signed_in? && session[:service_tickets]
-
- valid = session[:service_tickets].all? do |provider, ticket|
- Gitlab::Auth::OAuth::Session.valid?(provider, ticket)
- end
-
- unless valid
- session[:service_tickets] = nil
- sign_out current_user
- redirect_to new_user_session_path
- end
- end
-
def check_password_expiration
return if session[:impersonator_id] || !current_user&.allow_password_authentication?
@@ -452,14 +432,6 @@ class ApplicationController < ActionController::Base
Gitlab::Auth::OAuth::Provider.enabled?(:github)
end
- def gitlab_import_enabled?
- request.host != 'gitlab.com' && Gitlab::CurrentSettings.import_sources.include?('gitlab')
- end
-
- def gitlab_import_configured?
- Gitlab::Auth::OAuth::Provider.enabled?(:gitlab)
- end
-
def bitbucket_import_enabled?
Gitlab::CurrentSettings.import_sources.include?('bitbucket')
end
@@ -484,10 +456,6 @@ class ApplicationController < ActionController::Base
Gitlab::CurrentSettings.import_sources.include?('manifest')
end
- def phabricator_import_enabled?
- Gitlab::PhabricatorImport.available?
- end
-
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index 6139168d29f..7328b793b09 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -52,13 +52,14 @@ class ChaosController < ActionController::Base
def validate_chaos_secret
unless chaos_secret_configured
render plain: "chaos misconfigured: please configure GITLAB_CHAOS_SECRET",
- status: :internal_server_error
+ status: :internal_server_error
+
return
end
unless Devise.secure_compare(chaos_secret_configured, chaos_secret_request)
render plain: "To experience chaos, please set a valid `X-Chaos-Secret` header or `token` param",
- status: :unauthorized
+ status: :unauthorized
end
end
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/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index b4a36b7db22..9fd86e6a7e0 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -25,13 +25,7 @@ module AuthenticatesWithTwoFactor
session[:user_password_hash] = Digest::SHA256.hexdigest(user.encrypted_password)
add_gon_variables
- push_frontend_feature_flag(:webauthn)
-
- if Feature.enabled?(:webauthn)
- setup_webauthn_authentication(user)
- else
- setup_u2f_authentication(user)
- end
+ setup_webauthn_authentication(user)
render 'devise/sessions/two_factor'
end
@@ -54,11 +48,7 @@ module AuthenticatesWithTwoFactor
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user_params[:device_response].present? && session[:otp_user_id]
- if user.two_factor_webauthn_enabled?
- authenticate_with_two_factor_via_webauthn(user)
- else
- authenticate_with_two_factor_via_u2f(user)
- end
+ authenticate_with_two_factor_via_webauthn(user)
elsif user && user.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
@@ -96,15 +86,6 @@ module AuthenticatesWithTwoFactor
end
end
- # Authenticate using the response from a U2F (universal 2nd factor) device
- def authenticate_with_two_factor_via_u2f(user)
- if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
- handle_two_factor_success(user)
- else
- handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
- end
- end
-
def authenticate_with_two_factor_via_webauthn(user)
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
handle_two_factor_success(user)
@@ -113,31 +94,17 @@ module AuthenticatesWithTwoFactor
end
end
- # Setup in preparation of communication with a U2F (universal 2nd factor) device
- # Actual communication is performed using a Javascript API
# rubocop: disable CodeReuse/ActiveRecord
- def setup_u2f_authentication(user)
- key_handles = user.u2f_registrations.pluck(:key_handle)
- u2f = U2F::U2F.new(u2f_app_id)
-
- if key_handles.present?
- sign_requests = u2f.authentication_requests(key_handles)
- session[:challenge] ||= u2f.challenge
- gon.push(u2f: { challenge: session[:challenge], app_id: u2f_app_id,
- sign_requests: sign_requests })
- end
- end
-
def setup_webauthn_authentication(user)
if user.webauthn_registrations.present?
webauthn_registration_ids = user.webauthn_registrations.pluck(:credential_xid)
- get_options = WebAuthn::Credential.options_for_get(allow: webauthn_registration_ids,
- user_verification: 'discouraged',
- extensions: { appid: WebAuthn.configuration.origin })
-
- session[:credentialRequestOptions] = get_options
+ get_options = WebAuthn::Credential.options_for_get(
+ allow: webauthn_registration_ids,
+ user_verification: 'discouraged',
+ extensions: { appid: WebAuthn.configuration.origin }
+ )
session[:challenge] = get_options.challenge
gon.push(webauthn: { options: Gitlab::Json.dump(get_options) })
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
index 574fc6c0f37..045ccf1e5b8 100644
--- a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
@@ -11,13 +11,7 @@ module AuthenticatesWithTwoFactorForAdminMode
return handle_locked_user(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
- push_frontend_feature_flag(:webauthn)
-
- if user.two_factor_webauthn_enabled?
- setup_webauthn_authentication(user)
- else
- setup_u2f_authentication(user)
- end
+ setup_webauthn_authentication(user)
render 'admin/sessions/two_factor', layout: 'application'
end
@@ -30,11 +24,7 @@ module AuthenticatesWithTwoFactorForAdminMode
if user_params[:otp_attempt].present? && session[:otp_user_id]
admin_mode_authenticate_with_two_factor_via_otp(user)
elsif user_params[:device_response].present? && session[:otp_user_id]
- if user.two_factor_webauthn_enabled?
- admin_mode_authenticate_with_two_factor_via_webauthn(user)
- else
- admin_mode_authenticate_with_two_factor_via_u2f(user)
- end
+ admin_mode_authenticate_with_two_factor_via_webauthn(user)
elsif user && user.valid_password?(user_params[:password])
admin_mode_prompt_for_two_factor(user)
else
@@ -56,14 +46,6 @@ module AuthenticatesWithTwoFactorForAdminMode
end
end
- def admin_mode_authenticate_with_two_factor_via_u2f(user)
- if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
- admin_handle_two_factor_success
- else
- admin_handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
- end
- end
-
def admin_mode_authenticate_with_two_factor_via_webauthn(user)
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
admin_handle_two_factor_success
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 2711c823275..2efea461a35 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -11,7 +11,7 @@ module ConfirmEmailWarning
protected
def show_confirm_warning?
- html_request? && request.get? && Feature.enabled?(:soft_email_confirmation)
+ html_request? && request.get? && Gitlab::CurrentSettings.email_confirmation_setting_soft?
end
def set_confirm_warning
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 5199d879595..87fa9772e75 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -19,7 +19,6 @@ module CycleAnalyticsParams
@options ||= {}.tap do |opts|
opts[:current_user] = current_user
opts[:projects] = params[:project_ids] if params[:project_ids]
- opts[:group] = params[:group_id] if params[:group_id]
opts[:from] = params[:from] || start_date(params)
opts[:to] = params[:to] if params[:to]
opts[:end_event_filter] = params[:end_event_filter] if params[:end_event_filter]
@@ -62,7 +61,7 @@ module CycleAnalyticsParams
end
def all_cycle_analytics_params
- permitted_cycle_analytics_params.merge(current_user: current_user)
+ permitted_cycle_analytics_params.merge(current_user: current_user, namespace: namespace)
end
def request_params
@@ -78,5 +77,3 @@ module CycleAnalyticsParams
end
end
end
-
-CycleAnalyticsParams.prepend_mod_with('CycleAnalyticsParams')
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index cdef1a45a27..8068913eea2 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -27,7 +27,8 @@ module EnforcesTwoFactorAuthentication
render_error(
format(
_("Authentication error: enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}"),
- mfa_help_page: mfa_help_page_url),
+ mfa_help_page: mfa_help_page_url
+ ),
status: :unauthorized
)
else
diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb
index 7bebafae0fd..c0816c2fe9c 100644
--- a/app/controllers/concerns/integrations/actions.rb
+++ b/app/controllers/concerns/integrations/actions.rb
@@ -8,6 +8,9 @@ module Integrations::Actions
include IntegrationsHelper
before_action :integration, only: [:edit, :update, :overrides, :test]
+ before_action :render_404, only: :edit, if: -> do
+ integration.to_param == 'prometheus' && Feature.enabled?(:remove_monitor_metrics)
+ end
urgency :low, [:test]
end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 4d181ded071..af984776828 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -8,6 +8,7 @@ module Integrations
:app_store_issuer_id,
:app_store_key_id,
:app_store_private_key,
+ :app_store_private_key_file_name,
:active,
:alert_events,
:api_key,
@@ -52,6 +53,9 @@ module Integrations
:issues_events,
:issues_url,
:jenkins_url,
+ :jira_auth_type,
+ :jira_issue_prefix,
+ :jira_issue_regex,
:jira_issue_transition_automatic,
:jira_issue_transition_id,
:manual_configuration,
@@ -60,6 +64,7 @@ module Integrations
:namespace,
:new_issue_url,
:notify_only_broken_pipelines,
+ :package_name,
:password,
:priority,
:project_key,
@@ -72,6 +77,8 @@ module Integrations
:server,
:server_host,
:server_port,
+ :service_account_key,
+ :service_account_key_file_name,
:sound,
:subdomain,
:teamcity_url,
diff --git a/app/controllers/concerns/invisible_captcha_on_signup.rb b/app/controllers/concerns/invisible_captcha_on_signup.rb
index b78869e02d0..a704ff251b3 100644
--- a/app/controllers/concerns/invisible_captcha_on_signup.rb
+++ b/app/controllers/concerns/invisible_captcha_on_signup.rb
@@ -26,15 +26,17 @@ module InvisibleCaptchaOnSignup
end
def invisible_captcha_honeypot_counter
- @invisible_captcha_honeypot_counter ||=
- Gitlab::Metrics.counter(:bot_blocked_by_invisible_captcha_honeypot,
- 'Counter of blocked sign up attempts with filled honeypot')
+ @invisible_captcha_honeypot_counter ||= Gitlab::Metrics.counter(
+ :bot_blocked_by_invisible_captcha_honeypot,
+ 'Counter of blocked sign up attempts with filled honeypot'
+ )
end
def invisible_captcha_timestamp_counter
- @invisible_captcha_timestamp_counter ||=
- Gitlab::Metrics.counter(:bot_blocked_by_invisible_captcha_timestamp,
- 'Counter of blocked sign up attempts with invalid timestamp')
+ @invisible_captcha_timestamp_counter ||= Gitlab::Metrics.counter(
+ :bot_blocked_by_invisible_captcha_timestamp,
+ 'Counter of blocked sign up attempts with invalid timestamp'
+ )
end
def log_request(message)
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index e1381b4173f..0ad8a08960a 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
}
@@ -97,7 +97,7 @@ module IssuableActions
index_path = polymorphic_path([parent, issuable.class])
respond_to do |format|
- format.html { redirect_to index_path }
+ format.html { redirect_to index_path, status: :see_other }
format.json do
render json: {
web_url: index_path
@@ -151,9 +151,7 @@ module IssuableActions
end
case issuable
- when MergeRequest
- render_mr_discussions(discussion_notes, discussion_serializer, discussion_cache_context)
- when Issue
+ when MergeRequest, Issue
if stale?(etag: [discussion_cache_context, discussion_notes])
render json: discussion_serializer.represent(discussion_notes, context: self)
end
@@ -164,23 +162,6 @@ module IssuableActions
private
- def render_mr_discussions(discussions, serializer, cache_context)
- return unless stale?(etag: [cache_context, discussions])
-
- if Feature.enabled?(:disabled_mr_discussions_redis_cache, project)
- render json: serializer.represent(discussions, context: self)
- else
- render_cached_discussions(discussions, serializer, cache_context)
- end
- end
-
- def render_cached_discussions(discussions, serializer, cache_context)
- render_cached(discussions,
- with: serializer,
- cache_context: ->(_) { cache_context },
- context: self)
- end
-
def notes_filter
strong_memoize(:notes_filter) do
notes_filter_param = params[:notes_filter]&.to_i
@@ -209,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/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index a202808e2c3..b02a636ff74 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -141,7 +141,7 @@ module IssuableCollections
common_attributes = [:author, :assignees, :labels, :milestone]
@preload_for_collection ||= case collection_type
when 'Issue'
- common_attributes + [:project, project: :namespace]
+ common_attributes + [:work_item_type, :project, project: :namespace]
when 'MergeRequest'
common_attributes + [
:target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers,
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index 31445eb3eca..86ad87cc2c1 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -48,8 +48,6 @@ module IssuableCollectionsAction
Issue::SORTING_PREFERENCE_FIELD
when 'merge_requests'
MergeRequest::SORTING_PREFERENCE_FIELD
- else
- nil
end
end
@@ -59,8 +57,6 @@ module IssuableCollectionsAction
IssuesFinder
when 'merge_requests'
MergeRequestsFinder
- else
- nil
end
end
diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb
new file mode 100644
index 00000000000..c66bf7c9e8c
--- /dev/null
+++ b/app/controllers/concerns/kas_cookie.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+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?
+
+ public_session_id = Gitlab::Session.current&.id&.public_id
+ return unless public_session_id
+
+ cookie_data = ::Gitlab::Kas::UserAccess.cookie_data(public_session_id)
+
+ cookies[::Gitlab::Kas::COOKIE_KEY] = cookie_data
+ end
+end
diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb
index cacc7e4628f..997f26fa959 100644
--- a/app/controllers/concerns/known_sign_in.rb
+++ b/app/controllers/concerns/known_sign_in.rb
@@ -26,8 +26,13 @@ module KnownSignIn
end
def update_cookie
- set_secure_cookie(KNOWN_SIGN_IN_COOKIE, current_user.id,
- type: COOKIE_TYPE_ENCRYPTED, httponly: true, expires: KNOWN_SIGN_IN_COOKIE_EXPIRY)
+ set_secure_cookie(
+ KNOWN_SIGN_IN_COOKIE,
+ current_user.id,
+ type: COOKIE_TYPE_ENCRYPTED,
+ httponly: true,
+ expires: KNOWN_SIGN_IN_COOKIE_EXPIRY
+ )
end
def sessions
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 773e4c15d6e..09b82e36b1a 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -51,7 +51,7 @@ module MembershipActions
_("User was successfully removed from project.")
end
- redirect_to members_page_url, notice: message
+ redirect_to members_page_url, notice: message, status: :see_other
end
format.js { head :ok }
@@ -63,10 +63,10 @@ module MembershipActions
if access_requester.persisted?
redirect_to polymorphic_path(membershipable),
- notice: _('Your request for access has been queued for review.')
+ notice: _('Your request for access has been queued for review.')
else
redirect_to polymorphic_path(membershipable),
- alert: format(_("Your request for access could not be processed: %{error_message}"), error_message: access_requester.errors.full_messages.to_sentence)
+ alert: format(_("Your request for access could not be processed: %{error_message}"), error_message: access_requester.errors.full_messages.to_sentence)
end
end
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index 338c3af235b..7e202235cfa 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -79,7 +79,7 @@ module MetricsDashboard
end
def starred_dashboards
- @starred_dashboards ||= begin
+ @starred_dashboards ||=
if project_for_dashboard.present?
::Metrics::UsersStarredDashboardsFinder
.new(user: current_user, project: project_for_dashboard)
@@ -89,7 +89,6 @@ module MetricsDashboard
else
Set.new
end
- end
end
# Project is not defined for group and admin level clusters.
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 512dbf0de5d..06b9c901e4a 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -45,7 +45,8 @@ module NotesActions
respond_to do |format|
format.json do
json = {
- commands_changes: @note.commands_changes&.slice(:emoji_award, :time_estimate, :spend_time)
+ commands_changes: @note.commands_changes&.slice(:emoji_award, :time_estimate, :spend_time),
+ command_names: @note.command_names
}
if @note.persisted? && return_discussion?
diff --git a/app/controllers/concerns/observability/content_security_policy.rb b/app/controllers/concerns/observability/content_security_policy.rb
index 3865e3b606d..1e25dc492a0 100644
--- a/app/controllers/concerns/observability/content_security_policy.rb
+++ b/app/controllers/concerns/observability/content_security_policy.rb
@@ -12,17 +12,17 @@ module Observability
defined?(project) ? project&.group : nil
end
- next if p.directives.blank? || !Gitlab::Observability.observability_enabled?(current_user, current_group)
+ next if p.directives.blank? || !Feature.enabled?(:observability_group_tab, current_group)
default_frame_src = p.directives['frame-src'] || p.directives['default-src']
# When ObservabilityUI is not authenticated, it needs to be able
# to redirect to the GL sign-in page, hence '/users/sign_in' and '/oauth/authorize'
- frame_src_values = Array.wrap(default_frame_src) | [Gitlab::Observability.observability_url,
- Gitlab::Utils.append_path(Gitlab.config.gitlab.url,
-'/users/sign_in'),
- Gitlab::Utils.append_path(Gitlab.config.gitlab.url,
-'/oauth/authorize')]
+ frame_src_values = Array.wrap(default_frame_src) | [
+ Gitlab::Observability.observability_url,
+ Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/users/sign_in'),
+ Gitlab::Utils.append_path(Gitlab.config.gitlab.url, '/oauth/authorize')
+ ]
p.frame_src(*frame_src_values)
end
diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb
index 5696e441ad0..5424354b92c 100644
--- a/app/controllers/concerns/product_analytics_tracking.rb
+++ b/app/controllers/concerns/product_analytics_tracking.rb
@@ -2,51 +2,26 @@
module ProductAnalyticsTracking
include Gitlab::Tracking::Helpers
- include RedisTracking
extend ActiveSupport::Concern
- MIGRATED_EVENTS = ['g_analytics_valuestream'].freeze
-
class_methods do
- # TODO: Remove once all the events are migrated to #track_custom_event
- # during https://gitlab.com/groups/gitlab-org/-/epics/8641
- def track_event(*controller_actions, name:, conditions: nil, destinations: [:redis_hll], &block)
- custom_conditions = [:trackable_html_request?, *conditions]
-
- after_action only: controller_actions, if: custom_conditions do
- route_events_to(destinations, name, &block)
- end
- end
-
- def track_custom_event(*controller_actions, name:, action:, label:, conditions: nil, destinations: [:redis_hll], &block)
+ def track_event(*controller_actions, name:, action: nil, label: nil, conditions: nil, destinations: [:redis_hll], &block)
custom_conditions = [:trackable_html_request?, *conditions]
after_action only: controller_actions, if: custom_conditions do
- route_custom_events_to(destinations, name, action, label, &block)
+ route_events_to(destinations, name, action, label, &block)
end
end
end
private
- def route_events_to(destinations, name, &block)
- track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll)
-
- return unless destinations.include?(:snowplow) && event_enabled?(name)
-
- Gitlab::Tracking.event(
- self.class.to_s,
- name,
- namespace: tracking_namespace_source,
- user: current_user,
- context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: name).to_context]
- )
- end
-
- def route_custom_events_to(destinations, name, action, label, &block)
+ 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
optional_arguments = {
namespace: tracking_namespace_source,
@@ -64,34 +39,22 @@ module ProductAnalyticsTracking
)
end
- def event_enabled?(event)
- return true if MIGRATED_EVENTS.include?(event)
+ def track_unique_redis_hll_event(event_name, &block)
+ custom_id = block ? yield(self) : nil
+
+ unique_id = custom_id || visitor_id
- events_to_ff = {
- i_search_paid: :_phase2,
- i_search_total: :_phase2,
- i_search_advanced: :_phase2,
- i_ecosystem_jira_service_list_issues: :_phase2,
- users_viewing_analytics_group_devops_adoption: :_phase2,
- i_analytics_dev_ops_adoption: :_phase2,
- i_analytics_dev_ops_score: :_phase2,
- p_analytics_merge_request: :_phase2,
- i_analytics_instance_statistics: :_phase2,
- g_analytics_contribution: :_phase2,
- p_analytics_pipelines: :_phase2,
- p_analytics_code_reviews: :_phase2,
- p_analytics_valuestream: :_phase2,
- p_analytics_insights: :_phase2,
- p_analytics_issues: :_phase2,
- p_analytics_repo: :_phase2,
- g_analytics_insights: :_phase2,
- g_analytics_issues: :_phase2,
- g_analytics_productivity: :_phase2,
- i_analytics_cohorts: :_phase2,
+ return unless unique_id
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: unique_id)
+ end
- g_compliance_dashboard: :_phase4
- }
+ def visitor_id
+ return cookies[:visitor_id] if cookies[:visitor_id].present?
+ return unless current_user
- Feature.enabled?("route_hll_to_snowplow#{events_to_ff[event.to_sym]}", tracking_namespace_source)
+ uuid = SecureRandom.uuid
+ cookies[:visitor_id] = { value: uuid, expires: 24.months }
+ uuid
end
end
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
deleted file mode 100644
index 445e72b8266..00000000000
--- a/app/controllers/concerns/redis_tracking.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-# Example:
-#
-# # In controller include module
-# # Track event for index action
-#
-# include RedisTracking
-#
-# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score'
-#
-# You can also pass custom conditions using `if:`, using the same format as with Rails callbacks.
-# You can also pass an optional block that calculates and returns a custom id to track.
-module RedisTracking
- include Gitlab::Tracking::Helpers
- extend ActiveSupport::Concern
-
- class_methods do
- def track_redis_hll_event(*controller_actions, name:, if: nil, &block)
- custom_conditions = Array.wrap(binding.local_variable_get('if'))
- conditions = [:trackable_html_request?, *custom_conditions]
-
- after_action only: controller_actions, if: conditions do
- track_unique_redis_hll_event(name, &block)
- end
- end
- end
-
- private
-
- def track_unique_redis_hll_event(event_name, &block)
- custom_id = block ? yield(self) : nil
-
- unique_id = custom_id || visitor_id
-
- return unless unique_id
-
- Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: unique_id)
- end
-
- def visitor_id
- return cookies[:visitor_id] if cookies[:visitor_id].present?
- return unless current_user
-
- uuid = SecureRandom.uuid
- cookies[:visitor_id] = { value: uuid, expires: 24.months }
- uuid
- end
-end
diff --git a/app/controllers/concerns/registrations_tracking.rb b/app/controllers/concerns/registrations_tracking.rb
deleted file mode 100644
index 14743349c1a..00000000000
--- a/app/controllers/concerns/registrations_tracking.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module RegistrationsTracking
- extend ActiveSupport::Concern
-
- included do
- helper_method :glm_tracking_params
- end
-
- private
-
- def glm_tracking_params
- params.permit(:glm_source, :glm_content)
- 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_notes.rb b/app/controllers/concerns/renders_notes.rb
index f8e3717acee..889d3f0a9d2 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -24,13 +24,13 @@ module RendersNotes
# rubocop: disable CodeReuse/ActiveRecord
def preload_noteable_for_regular_notes(notes)
- ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable)
+ ActiveRecord::Associations::Preloader.new(records: notes.reject(&:for_commit?), associations: :noteable).call
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def preload_author_status(notes)
- ActiveRecord::Associations::Preloader.new.preload(notes, { author: :status })
+ ActiveRecord::Associations::Preloader.new(records: notes, associations: { author: :status }).call
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 05bd9972ee7..2d37bc3f9a5 100644
--- a/app/controllers/concerns/renders_projects_list.rb
+++ b/app/controllers/concerns/renders_projects_list.rb
@@ -1,13 +1,17 @@
# 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
projects.each(&:forks_count)
projects.each(&:open_issues_count)
+ projects.each(&:open_merge_requests_count)
projects
end
diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb
index a77ebd276b6..7cce30dbb3c 100644
--- a/app/controllers/concerns/search_rate_limitable.rb
+++ b/app/controllers/concerns/search_rate_limitable.rb
@@ -7,9 +7,25 @@ module SearchRateLimitable
def check_search_rate_limit!
if current_user
- check_rate_limit!(:search_rate_limit, scope: [current_user])
+ # Because every search in the UI typically runs concurrent searches with different
+ # scopes to get counts, we apply rate limits on the search scope if it is present.
+ #
+ # If abusive search is detected, we have stricter limits and ignore the search scope.
+ check_rate_limit!(:search_rate_limit, scope: [current_user, safe_search_scope].compact)
else
check_rate_limit!(:search_rate_limit_unauthenticated, scope: [request.ip])
end
end
+
+ def safe_search_scope
+ # Sometimes search scope can have abusive length or invalid keyword. We don't want
+ # to send those to redis for rate limit checks, so we guard against that here.
+ return if Feature.disabled?(:search_rate_limited_scopes) || abuse_detected?
+
+ params[:scope]
+ end
+
+ def abuse_detected?
+ Gitlab::Search::Params.new(params, detect_abuse: true).abusive?
+ end
end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 2141b257b40..c8b987da58c 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -63,8 +63,7 @@ module SendFileUpload
private
def image_scaling_request?(file_upload)
- (avatar_safe_for_scaling?(file_upload) || pwa_icon_safe_for_scaling?(file_upload)) &&
- scaling_allowed_by_feature_flags?(file_upload)
+ avatar_safe_for_scaling?(file_upload) || pwa_icon_safe_for_scaling?(file_upload)
end
def pwa_icon_safe_for_scaling?(file_upload)
@@ -90,8 +89,4 @@ module SendFileUpload
def valid_image_scaling_width?(allowed_scalar_widths)
allowed_scalar_widths.include?(params[:width]&.to_i)
end
-
- def scaling_allowed_by_feature_flags?(file_upload)
- Feature.enabled?(:dynamic_image_resizing, type: :ops)
- end
end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 1bb81a46e50..62c5aee16e4 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -9,13 +9,13 @@ module SnippetsActions
include Gitlab::NoteableMetadata
include Snippets::SendBlob
include SnippetsSort
- include RedisTracking
+ include ProductAnalyticsTracking
included do
skip_before_action :verify_authenticity_token,
if: -> { action_name == 'show' && js_request? }
- track_redis_hll_event :show, name: 'i_snippets_show'
+ track_event :show, name: 'i_snippets_show'
respond_to :html
end
diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb
index 300c1d6d779..3dc1780d6fe 100644
--- a/app/controllers/concerns/sorting_preference.rb
+++ b/app/controllers/concerns/sorting_preference.rb
@@ -90,6 +90,10 @@ module SortingPreference
return false unless sort_order
return can_sort_by_issue_weight?(action_name == 'issues') if sort_order.include?('weight')
+ if sort_order.include?('merged_at')
+ return can_sort_by_merged_date?(controller_name == 'merge_requests' || action_name == 'merge_requests')
+ end
+
true
end
end
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 308da018a42..db756ae336f 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -5,11 +5,10 @@ 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
- skip_before_action :default_cache_headers, only: :show
rescue_from FileUploader::InvalidSecret, with: :render_404
end
diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
index fb48c0d8ba5..45869c05f41 100644
--- a/app/controllers/concerns/verifies_with_email.rb
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -78,7 +78,7 @@ module VerifiesWithEmail
def send_verification_instructions(user)
return if send_rate_limited?(user)
- service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token)
+ service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token, user: user)
raw_token, encrypted_token = service.execute
user.unlock_token = encrypted_token
user.lock_access!({ send_instructions: false })
diff --git a/app/controllers/concerns/vscode_cdn_csp.rb b/app/controllers/concerns/vscode_cdn_csp.rb
deleted file mode 100644
index dc8cea966e5..00000000000
--- a/app/controllers/concerns/vscode_cdn_csp.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# rubocop:disable Naming/FileName
-# frozen_string_literal: true
-
-module VSCodeCDNCSP
- extend ActiveSupport::Concern
-
- included do
- content_security_policy do |policy|
- next if policy.directives.blank?
-
- default_src = Array(policy.directives['default-src'] || [])
- policy.directives['frame-src'] ||= default_src
- policy.directives['frame-src'].concat(['https://*.vscode-cdn.net/'])
- end
- end
-end
-# rubocop:enable Naming/FileName
diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb
index f61600af951..ae971b7bc95 100644
--- a/app/controllers/concerns/web_hooks/hook_actions.rb
+++ b/app/controllers/concerns/web_hooks/hook_actions.rb
@@ -7,6 +7,8 @@ module WebHooks
included do
attr_writer :hooks, :hook
+
+ before_action :hook_logs, only: :edit
end
def index
@@ -80,5 +82,9 @@ module WebHooks
flash[:alert] = result[:message]
end
end
+
+ def hook_logs
+ @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count
+ end
end
end
diff --git a/app/controllers/concerns/web_ide_csp.rb b/app/controllers/concerns/web_ide_csp.rb
new file mode 100644
index 00000000000..c2d66abb538
--- /dev/null
+++ b/app/controllers/concerns/web_ide_csp.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module WebIdeCSP
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :include_web_ide_csp
+
+ # We want to include frames from `/assets/webpack` of the request's host to
+ # support URL flexibility with the Web IDE.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118875
+ def include_web_ide_csp
+ return if request.content_security_policy.directives.blank?
+
+ base_uri = URI(request.url)
+ base_uri.path = ::Gitlab.config.gitlab.relative_url_root || '/'
+ # `.path +=` handles combining `x/` and `/foo`
+ base_uri.path += '/assets/webpack/'
+ webpack_url = base_uri.to_s
+
+ default_src = Array(request.content_security_policy.directives['default-src'] || [])
+ request.content_security_policy.directives['frame-src'] ||= default_src
+ request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.vscode-cdn.net/'])
+
+ request.content_security_policy.directives['worker-src'] ||= default_src
+ request.content_security_policy.directives['worker-src'].concat([webpack_url])
+ end
+ end
+end
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 2b781c528ad..265cf2a7698 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -5,12 +5,21 @@ module WikiActions
include PreviewMarkdown
include SendsBlob
include Gitlab::Utils::StrongMemoize
- include RedisTracking
+ include ProductAnalyticsTracking
extend ActiveSupport::Concern
RESCUE_GIT_TIMEOUTS_IN = %w[show edit history diff pages].freeze
included do
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ default_frame_src = p.directives['frame-src'] || p.directives['default-src']
+ frame_src_values = Array.wrap(default_frame_src) | ['https://embed.diagrams.net'].compact
+
+ p.frame_src(*frame_src_values)
+ end
+
before_action { respond_to :html }
before_action :authorize_read_wiki!
@@ -37,9 +46,7 @@ module WikiActions
end
end
- # NOTE: We want to include wiki page views in the same counter as the other
- # Event-based wiki actions tracked through TrackUniqueEvents, so we use the same event name.
- track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s
+ track_event :show, name: 'wiki_action'
helper_method :view_file_button, :diff_file_html_data
@@ -48,6 +55,12 @@ module WikiActions
render 'shared/wikis/git_error'
end
+
+ rescue_from Gitlab::Git::Repository::NoRepository do
+ @error = _('Could not access the Wiki Repository at this time.')
+
+ render 'shared/wikis/empty'
+ end
end
def new
@@ -106,7 +119,11 @@ module WikiActions
def update
return render('shared/wikis/empty') unless can?(current_user, :create_wiki, container)
- response = WikiPages::UpdateService.new(container: container, current_user: current_user, params: wiki_params).execute(page)
+ response = WikiPages::UpdateService.new(
+ container: container,
+ current_user: current_user,
+ params: wiki_params
+ ).execute(page)
@page = response.payload[:page]
if response.success?
@@ -142,8 +159,7 @@ module WikiActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def history
if page
- @commits = Kaminari.paginate_array(page.versions(page: params[:page].to_i),
- total_count: page.count_versions)
+ @commits = Kaminari.paginate_array(page.versions(page: params[:page].to_i), total_count: page.count_versions)
.page(params[:page])
render 'shared/wikis/history'
@@ -178,8 +194,7 @@ module WikiActions
if response.success?
flash[:toast] = _("Wiki page was successfully deleted.")
- redirect_to wiki_path(wiki),
- status: :found
+ redirect_to wiki_path(wiki), status: :found
else
@error = response.message
render 'shared/wikis/edit'
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 6dd4d72bbc7..e94138c4d9b 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -10,7 +10,7 @@ class ConfirmationsController < Devise::ConfirmationsController
prepend_before_action :check_recaptcha, only: :create
before_action :load_recaptcha, only: :new
- feature_category :authentication_and_authorization
+ feature_category :user_management
def almost_there
flash[:notice] = nil
@@ -20,12 +20,12 @@ class ConfirmationsController < Devise::ConfirmationsController
protected
def after_resending_confirmation_instructions_path_for(resource)
- return users_almost_there_path unless Feature.enabled?(:soft_email_confirmation)
+ return users_almost_there_path unless Gitlab::CurrentSettings.email_confirmation_setting_soft?
stored_location_for(resource) || dashboard_projects_path
end
- def after_confirmation_path_for(resource_name, resource)
+ def after_confirmation_path_for(_resource_name, resource)
accept_pending_invitations
# incoming resource can either be a :user or an :email
@@ -34,10 +34,14 @@ class ConfirmationsController < Devise::ConfirmationsController
else
Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}")
flash[:notice] = flash[:notice] + _(" Please sign in.")
- new_session_path(:user, anchor: 'login-pane', invite_email: resource.email)
+ sign_in_path(resource)
end
end
+ def sign_in_path(user)
+ new_session_path(:user, anchor: 'login-pane', invite_email: resource.email)
+ end
+
def check_recaptcha
return unless resource_params[:email].present?
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 89d362c88a4..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, not_aimed_for_deletion: true }, current_user: current_user).execute
- @total_starred_projects_count = ProjectsFinder.new(params: { starred: true, 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/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 3005d19f8ed..a1b8dbcd304 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -29,9 +29,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
respond_to do |format|
format.html do
- redirect_to dashboard_todos_path,
- status: :found,
- notice: _('To-do item successfully marked as done.')
+ redirect_to dashboard_todos_path, status: :found, notice: _('To-do item successfully marked as done.')
end
format.js { head :ok }
format.json { render json: todos_counts }
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index b003ca564f3..d70b2e57a95 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -12,6 +12,10 @@ class DashboardController < Dashboard::ApplicationController
before_action :set_show_full_reference, only: [:issues, :merge_requests]
before_action :check_filters_presence!, only: [:issues, :merge_requests]
+ before_action only: :merge_requests do
+ push_frontend_feature_flag(:mr_approved_filter, type: :ops)
+ end
+
respond_to :html
feature_category :user_profile, [:activity]
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index ac355b861b3..96a7b5b144d 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -7,7 +7,12 @@ class Explore::GroupsController < Explore::ApplicationController
urgency :low
def index
- user = Feature.enabled?(:generic_explore_groups, current_user, type: :experiment) ? nil : current_user
+ # For gitlab.com, including internal visibility groups here causes
+ # a major performance issue: https://gitlab.com/gitlab-org/gitlab/-/issues/358944
+ #
+ # For self-hosted users, not including internal groups here causes
+ # a lack of visibility: https://gitlab.com/gitlab-org/gitlab/-/issues/389041
+ user = Gitlab.com? ? nil : current_user
render_group_tree GroupsFinder.new(user).execute
end
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/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 2f01bdecd23..ff4fce9ad1e 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -76,6 +76,13 @@ class GraphqlController < ApplicationController
render_error(exception.message, status: :forbidden)
end
+ rescue_from Gitlab::Git::ResourceExhaustedError do |exception|
+ log_exception(exception)
+
+ response.headers.merge!(exception.headers)
+ render_error(exception.message, status: :too_many_requests)
+ end
+
rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
render_error(exception.message, status: :unprocessable_entity)
end
@@ -102,6 +109,10 @@ class GraphqlController < ApplicationController
private
+ def permitted_multiplex_params
+ params.permit(_json: [:query, :operationName, { variables: {} }])
+ end
+
def disallow_mutations_for_get
return unless request.get? || request.head?
return unless any_mutating_query?
@@ -111,7 +122,7 @@ class GraphqlController < ApplicationController
def limit_query_size
total_size = if multiplex?
- params[:_json].sum { _1[:query].size }
+ multiplex_param.sum { _1[:query].size }
else
query.size
end
@@ -178,8 +189,12 @@ class GraphqlController < ApplicationController
params.fetch(:query, '')
end
+ def multiplex_param
+ permitted_multiplex_params[:_json]
+ end
+
def multiplex_queries
- params[:_json].map do |single_query_info|
+ multiplex_param.map do |single_query_info|
{
query: single_query_info[:query],
variables: build_variables(single_query_info[:variables]),
@@ -206,8 +221,10 @@ class GraphqlController < ApplicationController
Gitlab::Graphql::Variables.new(variable_info).to_h
end
+ # We support Apollo-style query batching where an array of queries will be in the `_json:` key.
+ # https://graphql-ruby.org/queries/multiplex.html#apollo-query-batching
def multiplex?
- params[:_json].present?
+ params[:_json].is_a?(Array)
end
def authorize_access_api!
diff --git a/app/controllers/groups/achievements_controller.rb b/app/controllers/groups/achievements_controller.rb
new file mode 100644
index 00000000000..52d63761819
--- /dev/null
+++ b/app/controllers/groups/achievements_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Groups
+ class AchievementsController < Groups::ApplicationController
+ feature_category :user_profile
+ urgency :low
+
+ before_action :authorize_read_achievement!
+
+ private
+
+ def authorize_read_achievement!
+ render_404 unless can?(current_user, :read_achievement, group)
+ end
+ end
+end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index d10c52f0301..ca3be1542aa 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -5,6 +5,8 @@ module Groups
extend ::Gitlab::Utils::Override
before_action :group
+ before_action :validate_per_page
+
skip_cross_project_access_check :index
feature_category :subgroups
@@ -41,10 +43,11 @@ module Groups
protected
def setup_children(parent)
- @children = GroupDescendantsFinder.new(current_user: current_user,
- parent_group: parent,
- params: params.to_unsafe_h).execute
- @children = @children.page(params[:page])
+ @children = GroupDescendantsFinder.new(
+ current_user: current_user,
+ parent_group: parent,
+ params: group_descendants_params
+ ).execute.page(params[:page])
end
private
@@ -53,5 +56,25 @@ module Groups
def has_project_list?
true
end
+
+ def group_descendants_params
+ @group_descendants_params ||= params.to_unsafe_h.compact
+ end
+
+ def validate_per_page
+ return unless group_descendants_params.key?(:per_page)
+
+ per_page = begin
+ Integer(group_descendants_params[:per_page])
+ rescue ArgumentError, TypeError
+ 0
+ end
+
+ respond_to do |format|
+ format.json do
+ render status: :bad_request, json: { message: 'per_page does not have a valid value' } if per_page < 1
+ end
+ end
+ end
end
end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index 427df9a7129..1b1aed0ec2e 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -172,6 +172,6 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
end
def manifest_header
- token_header.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES)
+ token_header.merge(Accept: ::DependencyProxy::Manifest::ACCEPTED_TYPES)
end
end
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index cc2ca728592..c74c48a960d 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -7,7 +7,7 @@ class Groups::GroupLinksController < Groups::ApplicationController
feature_category :subgroups
def update
- Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
+ Groups::GroupLinks::UpdateService.new(@group_link, current_user).execute(group_link_params)
if @group_link.expires?
render json: {
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index f0b857ca4c9..d614cc1cb24 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -16,10 +16,13 @@ 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
+ :approve_access_request, :leave, :resend_invite, :override
feature_category :subgroups
urgency :low
@@ -73,7 +76,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
end
def filter_params
- params.permit(:two_factor, :search).merge(sort: @sort)
+ params.permit(:two_factor, :search, :user_type).merge(sort: @sort)
end
def membershipable_members
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 494b8c5621d..903c8c214ae 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -45,6 +45,24 @@ class Groups::MilestonesController < Groups::ApplicationController
Milestones::UpdateService.new(@milestone.parent, current_user, milestone_params).execute(@milestone)
redirect_to milestone_path(@milestone)
+ rescue ActiveRecord::StaleObjectError
+ respond_to do |format|
+ format.html do
+ @conflict = true
+ render :edit
+ end
+
+ format.json do
+ render json: {
+ errors: [
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please refresh your browser and make sure your changes will not unintentionally remove theirs."), # rubocop:disable Layout/LineLength
+ model_name: _('milestone')
+ )
+ ]
+ }, status: :conflict
+ end
+ end
end
def destroy
@@ -63,7 +81,15 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def milestone_params
- params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
+ params.require(:milestone)
+ .permit(
+ :description,
+ :due_date,
+ :lock_version,
+ :start_date,
+ :state_event,
+ :title
+ )
end
def milestones
diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb
index 726af00a10e..525407f5849 100644
--- a/app/controllers/groups/observability_controller.rb
+++ b/app/controllers/groups/observability_controller.rb
@@ -30,7 +30,7 @@ module Groups
end
def check_observability_allowed
- render_404 unless Gitlab::Observability.observability_enabled?(current_user, group)
+ render_404 unless Gitlab::Observability.allowed_for_action?(current_user, group, params[:action])
end
end
end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 859bb0adb4e..4b52617d287 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,14 @@ class Groups::RunnersController < Groups::ApplicationController
end
end
+ def new
+ render_404 unless create_runner_workflow_for_namespace_enabled?
+ end
+
+ def register
+ render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available?
+ end
+
private
def runner
@@ -47,6 +61,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/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb
index d86ddcfe2d0..ff07e881bfa 100644
--- a/app/controllers/groups/settings/access_tokens_controller.rb
+++ b/app/controllers/groups/settings/access_tokens_controller.rb
@@ -7,7 +7,7 @@ module Groups
include AccessTokensActions
layout 'group_settings'
- feature_category :authentication_and_authorization
+ feature_category :system_access
alias_method :resource, :group
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index 3557d485422..3ae1ae824a0 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -6,18 +6,16 @@ module Groups
include OauthApplications
prepend_before_action :authorize_admin_group!
- before_action :set_application, only: [:show, :edit, :update, :destroy]
+ before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:index, :create, :edit, :update]
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
set_index_vars
end
- def show
- @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
- end
+ def show; end
def edit
end
@@ -28,15 +26,8 @@ module Groups
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- if Feature.enabled?('hash_oauth_secrets')
-
- @created = true
- render :show
- else
- set_created_session
-
- redirect_to group_settings_application_url(@group, @application)
- end
+ @created = true
+ render :show
else
set_index_vars
render :index
@@ -51,6 +42,16 @@ module Groups
end
end
+ def renew
+ @application.renew_secret
+
+ if @application.save
+ render json: { secret: @application.plaintext_secret }
+ else
+ render json: { errors: @application.errors }, status: :unprocessable_entity
+ end
+ end
+
def destroy
@application.destroy
redirect_to group_settings_applications_url(@group), status: :found, notice: _('Application was successfully destroyed.')
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 78e3ffa4af9..4bbaf92b126 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -12,6 +12,11 @@ module Groups
before_action :assign_variables_to_gon, only: [:show]
feature_category :continuous_integration
+
+ before_action do
+ push_frontend_feature_flag(:ci_variables_pages, current_user)
+ end
+
urgency :low
def show
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 9ddf6c80c70..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_authoring
+ feature_category :secrets_management
urgency :low, [:show]
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 8f7a2c177b7..d2f65104d86 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -32,22 +32,19 @@ 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)
+ before_action only: :merge_requests do
+ push_frontend_feature_flag(:mr_approved_filter, type: :ops)
end
helper_method :captcha_required?
- skip_cross_project_access_check :index, :new, :create, :edit, :update,
- :destroy, :projects
+ skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects
# When loading show as an atom feed, we render events that could leak cross
# project information
skip_cross_project_access_check :show, if: -> { request.format.html? }
@@ -76,6 +73,7 @@ class GroupsController < Groups::ApplicationController
end
def new
+ @parent_group = Group.find_by_id(params[:parent_id])
@group = Group.new(params.permit(:parent_id))
@group.build_namespace_settings
end
@@ -201,7 +199,7 @@ class GroupsController < Groups::ApplicationController
send_upload(@group.export_file, attachment: @group.export_file.filename)
else
redirect_to edit_group_path(@group),
- alert: _('The file containing the export is not available yet; it may still be transferring. Please try again later.')
+ alert: _('The file containing the export is not available yet; it may still be transferring. Please try again later.')
end
else
redirect_to edit_group_path(@group),
@@ -402,12 +400,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/ide_controller.rb b/app/controllers/ide_controller.rb
index d0e14000d8e..4cc943ac252 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class IdeController < ApplicationController
- include VSCodeCDNCSP
+ include WebIdeCSP
include StaticObjectExternalStorageCSP
include Gitlab::Utils::StrongMemoize
@@ -10,7 +10,6 @@ class IdeController < ApplicationController
before_action do
push_frontend_feature_flag(:build_service_proxy)
push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab)
- define_index_vars
end
feature_category :web_ide
@@ -20,9 +19,9 @@ class IdeController < ApplicationController
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
- if project && Feature.enabled?(:route_hll_to_snowplow_phase2, project&.namespace)
- Gitlab::Tracking.event(self.class.to_s, 'web_ide_views',
- namespace: project&.namespace, user: current_user)
+ if project
+ Gitlab::Tracking.event(self.class.to_s, 'web_ide_views', namespace: project.namespace, user: current_user)
+ @fork_info = fork_info(project, params[:branch])
end
render layout: 'fullscreen', locals: { minimal: helpers.use_new_web_ide? }
@@ -34,15 +33,6 @@ class IdeController < ApplicationController
render_404 unless can?(current_user, :read_project, project)
end
- def define_index_vars
- return unless project
-
- @branch = params[:branch]
- @path = params[:path]
- @merge_request = params[:merge_request_id]
- @fork_info = fork_info(project, @branch)
- end
-
def fork_info(project, branch)
return if can?(current_user, :push_code, project)
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 7ef07032913..bcb6aed9e38 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -18,7 +18,7 @@ class Import::BaseController < ApplicationController
if params[:namespace_id]&.present?
@namespace = Namespace.find_by_id(params[:namespace_id])
- render_404 unless current_user.can?(:create_projects, @namespace)
+ render_404 unless current_user.can?(:import_projects, @namespace)
end
end
end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 8a0f4a36781..c933b05e0c4 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -57,7 +57,7 @@ class Import::BitbucketController < Import::BaseController
extra: { user_role: user_role(current_user, target_namespace), import_type: 'bitbucket' }
)
- if current_user.can?(:create_projects, target_namespace)
+ if current_user.can?(:import_projects, target_namespace)
# The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
session[:bitbucket_token] = bitbucket_client.connection.token
@@ -70,7 +70,7 @@ class Import::BitbucketController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
+ render json: { errors: _('You are not allowed to import projects in this namespace.') }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index f4eea3abd32..d7d7ad84bc8 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -5,9 +5,6 @@ class Import::BulkImportsController < ApplicationController
before_action :ensure_bulk_import_enabled
before_action :verify_blocked_uri, only: :status
- before_action only: :status do
- push_frontend_feature_flag(:bulk_import_projects)
- end
feature_category :importers
urgency :low
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 77043e174b4..9ee8e59053f 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -19,7 +19,7 @@ class Import::FogbugzController < Import::BaseController
# If the URI is invalid various errors can occur
return redirect_to new_import_fogbugz_path(namespace_id: params[:namespace_id]), alert: _('Could not connect to FogBugz, check your URL')
end
- session[:fogbugz_token] = res.get_token
+ session[:fogbugz_token] = res.get_token.to_s
session[:fogbugz_uri] = params[:uri]
redirect_to new_user_map_import_fogbugz_path(namespace_id: params[:namespace_id])
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 61e32650db3..2778b97419a 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -32,7 +32,7 @@ class Import::GiteaController < Import::GithubController
if params[:namespace_id].present?
@namespace = Namespace.find_by_id(params[:namespace_id])
- render_404 unless current_user.can?(:create_projects, @namespace)
+ render_404 unless current_user.can?(:import_projects, @namespace)
end
end
end
@@ -71,6 +71,11 @@ class Import::GiteaController < Import::GithubController
end
end
+ override :serialized_imported_projects
+ def serialized_imported_projects(projects = already_added_projects)
+ ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
+ end
+
override :client_repos
def client_repos
@client_repos ||= filtered(client.repos)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 0bee1faccf5..41477519ba5 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
@@ -53,19 +57,24 @@ class Import::GithubController < Import::BaseController
render json: { imported_projects: serialized_imported_projects,
provider_repos: serialized_provider_repos,
incompatible_repos: serialized_incompatible_repos,
- page_info: client_repos_response[:page_info] }
+ page_info: client_repos_response[:page_info],
+ provider_repo_count: client_repos_response[:count] }
end
format.html do
if params[:namespace_id].present?
@namespace = Namespace.find_by_id(params[:namespace_id])
- render_404 unless current_user.can?(:create_projects, @namespace)
+ render_404 unless current_user.can?(:import_projects, @namespace)
end
end
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)
@@ -82,6 +91,21 @@ class Import::GithubController < Import::BaseController
render json: Import::GithubRealtimeRepoSerializer.new.represent(already_added_projects)
end
+ def failures
+ project = Project.imported_from(provider_name).find(params[:project_id])
+
+ unless project.import_finished?
+ return render status: :bad_request, json: {
+ message: _('The import is not complete.')
+ }
+ end
+
+ failures = project.import_failures.with_external_identifiers
+ serializer = Import::GithubFailureSerializer.new.with_pagination(request, response)
+
+ render json: serializer.represent(failures)
+ end
+
def cancel
project = Project.imported_from(provider_name).find(params[:project_id])
result = Import::Github::CancelProjectImportService.new(project, current_user).execute
@@ -110,6 +134,14 @@ class Import::GithubController < Import::BaseController
render json: canceled
end
+ def counts
+ render json: {
+ owned: client_proxy.count_repos_by('owned', current_user.id),
+ collaborated: client_proxy.count_repos_by('collaborated', current_user.id),
+ organization: client_proxy.count_repos_by('organization', current_user.id)
+ }
+ end
+
protected
override :importable_repos
@@ -145,7 +177,10 @@ class Import::GithubController < Import::BaseController
end
def serialized_imported_projects(projects = already_added_projects)
- ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
+ ProjectSerializer.new.represent(
+ projects,
+ serializer: :import, provider_url: provider_url, client: client_proxy
+ )
end
def expire_etag_cache
@@ -245,11 +280,7 @@ class Import::GithubController < Import::BaseController
{
before: params[:before].presence,
after: params[:after].presence,
- first: PAGE_LENGTH,
- # TODO: remove after rollout FF github_client_fetch_repos_via_graphql
- # https://gitlab.com/gitlab-org/gitlab/-/issues/385649
- page: [1, params[:page].to_i].max,
- per_page: PAGE_LENGTH
+ first: PAGE_LENGTH
}
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
deleted file mode 100644
index dd25698d0a9..00000000000
--- a/app/controllers/import/gitlab_controller.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-# frozen_string_literal: true
-
-class Import::GitlabController < Import::BaseController
- extend ::Gitlab::Utils::Override
-
- MAX_PROJECT_PAGES = 15
- PER_PAGE_PROJECTS = 100
-
- before_action :verify_gitlab_import_enabled
- before_action :gitlab_auth, except: :callback
-
- rescue_from OAuth2::Error, with: :gitlab_unauthorized
-
- def callback
- session[:gitlab_access_token] = client.get_token(params[:code], callback_import_gitlab_url(namespace_id: params[:namespace_id]))
- redirect_to status_import_gitlab_url(namespace_id: params[:namespace_id])
- end
-
- # We need to re-expose controller's internal method 'status' as action.
- # rubocop:disable Lint/UselessMethodDefinition
- def status
- super
- end
- # rubocop:enable Lint/UselessMethodDefinition
-
- def create
- repo = client.project(params[:repo_id].to_i)
- target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username'])
-
- if current_user.can?(:create_projects, target_namespace)
- project = Gitlab::GitlabImport::ProjectCreator.new(repo, target_namespace, current_user, access_params).execute
-
- if project.persisted?
- render json: ProjectSerializer.new.represent(project, serializer: :import)
- else
- render json: { errors: project_save_error(project) }, status: :unprocessable_entity
- end
- else
- render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
- end
- end
-
- protected
-
- override :importable_repos
- def importable_repos
- client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS).to_a
- end
-
- override :incompatible_repos
- def incompatible_repos
- []
- end
-
- override :provider_name
- def provider_name
- :gitlab
- end
-
- override :provider_url
- def provider_url
- 'https://gitlab.com'
- end
-
- private
-
- def client
- @client ||= Gitlab::GitlabImport::Client.new(session[:gitlab_access_token])
- end
-
- def verify_gitlab_import_enabled
- render_404 unless gitlab_import_enabled?
- end
-
- def gitlab_auth
- if session[:gitlab_access_token].blank?
- go_to_gitlab_for_permissions
- end
- end
-
- def go_to_gitlab_for_permissions
- redirect_to client.authorize_url(callback_import_gitlab_url(namespace_id: params[:namespace_id]))
- end
-
- def gitlab_unauthorized
- go_to_gitlab_for_permissions
- end
-
- def access_params
- { gitlab_access_token: session[:gitlab_access_token] }
- end
-end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 9b8c480e529..d1b182a57d8 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -8,7 +8,7 @@ class Import::GitlabProjectsController < Import::BaseController
def new
@namespace = Namespace.find(project_params[:namespace_id])
- return render_404 unless current_user.can?(:create_projects, @namespace)
+ return render_404 unless current_user.can?(:import_projects, @namespace)
@path = project_params[:path]
end
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 461ba982969..03884717e54 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -20,8 +20,8 @@ class Import::ManifestController < Import::BaseController
def upload
group = Group.find(params[:group_id])
- unless can?(current_user, :create_projects, group)
- @errors = ["You don't have enough permissions to create projects in the selected group"]
+ unless can?(current_user, :import_projects, group)
+ @errors = ["You don't have enough permissions to import projects in the selected group"]
render :new && return
end
diff --git a/app/controllers/import/phabricator_controller.rb b/app/controllers/import/phabricator_controller.rb
deleted file mode 100644
index d1c04817689..00000000000
--- a/app/controllers/import/phabricator_controller.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-class Import::PhabricatorController < Import::BaseController
- include ImportHelper
-
- before_action :verify_import_enabled
-
- def new
- end
-
- def create
- @project = Gitlab::PhabricatorImport::ProjectCreator
- .new(current_user, import_params).execute
-
- if @project&.persisted?
- redirect_to @project
- else
- @name = params[:name]
- @path = params[:path]
- @errors = @project&.errors&.full_messages || [_("Invalid import params")]
-
- render :new
- end
- end
-
- def verify_import_enabled
- render_404 unless phabricator_import_enabled?
- end
-
- private
-
- def import_params
- params.permit(:path, :phabricator_server_url, :api_token, :name, :namespace_id)
- end
-end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 2a7f2d42e2a..8a8ae38c6f3 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -13,7 +13,7 @@ class InvitesController < ApplicationController
respond_to :html
- feature_category :authentication_and_authorization
+ feature_category :system_access
def show
accept if skip_invitation_prompt?
@@ -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/jira_connect/branches_controller.rb b/app/controllers/jira_connect/branches_controller.rb
index 12ea6560e3a..4c1b0d2b208 100644
--- a/app/controllers/jira_connect/branches_controller.rb
+++ b/app/controllers/jira_connect/branches_controller.rb
@@ -23,7 +23,8 @@ class JiraConnect::BranchesController < ApplicationController
def new_branch_data
{
initial_branch_name: initial_branch_name,
- success_state_svg_path: ActionController::Base.helpers.image_path('illustrations/merge_requests.svg')
+ success_state_svg_path:
+ ActionController::Base.helpers.image_path('illustrations/empty-state/empty-merge-requests-md.svg')
}
end
end
diff --git a/app/controllers/jira_connect/public_keys_controller.rb b/app/controllers/jira_connect/public_keys_controller.rb
index 4505ab16926..8cb932c087f 100644
--- a/app/controllers/jira_connect/public_keys_controller.rb
+++ b/app/controllers/jira_connect/public_keys_controller.rb
@@ -22,8 +22,6 @@ module JiraConnect
end
def public_key_storage_enabled?
- return true if Gitlab.config.jira_connect.enable_public_keys_storage
-
Gitlab::CurrentSettings.jira_connect_public_key_storage_enabled?
end
end
diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb
index a206e7fbbd8..773ef2bddca 100644
--- a/app/controllers/jira_connect/subscriptions_controller.rb
+++ b/app/controllers/jira_connect/subscriptions_controller.rb
@@ -19,10 +19,6 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
p.style_src(*style_src_values)
end
- before_action do
- push_frontend_feature_flag(:jira_connect_oauth, @user)
- end
-
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :allow_self_managed_content_security_policy, only: :index
@@ -34,7 +30,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: JiraConnect::AppDataSerializer.new(@subscriptions, !!current_user).as_json
+ render json: JiraConnect::AppDataSerializer.new(@subscriptions).as_json
end
end
end
diff --git a/app/controllers/jira_connect/users_controller.rb b/app/controllers/jira_connect/users_controller.rb
deleted file mode 100644
index a37c68de299..00000000000
--- a/app/controllers/jira_connect/users_controller.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-class JiraConnect::UsersController < ApplicationController
- feature_category :integrations
-
- layout 'signup_onboarding'
-
- before_action :verify_return_to_url, only: [:show]
-
- def show
- @jira_app_link = params.delete(:return_to)
- end
-
- private
-
- def verify_return_to_url
- return unless params[:return_to].present?
-
- params.delete(:return_to) unless Integrations::Jira.valid_jira_cloud_url?(params[:return_to])
- end
-end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 7211eebdb4b..d299613f498 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -8,7 +8,7 @@ class JwtController < ApplicationController
# Add this before other actions, since we want to have the user or project
prepend_before_action :auth_user, :authenticate_project_or_user
- feature_category :authentication_and_authorization
+ feature_category :system_access
# https://gitlab.com/gitlab-org/gitlab/-/issues/357037
urgency :low
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index bfd6181a940..3dfa8d7b11e 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -10,9 +10,10 @@ class MetricsController < ActionController::Base
response = if Gitlab::Metrics.prometheus_metrics_enabled?
metrics_service.metrics_text
else
- help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics',
- anchor: 'gitlab-prometheus-metrics'
- )
+ help_page = help_page_url(
+ 'administration/monitoring/prometheus/gitlab_metrics',
+ anchor: 'gitlab-prometheus-metrics'
+ )
"# Metrics are disabled, see: #{help_page}\n"
end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 3b78b997da1..2d5421f9f74 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -23,9 +23,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
set_index_vars
end
- def show
- @created = get_created_session if Feature.disabled?('hash_oauth_secrets')
- end
+ def show; end
def create
@application = Applications::CreateService.new(current_user, application_params).execute(request)
@@ -33,20 +31,26 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
- if Feature.enabled?('hash_oauth_secrets')
- @created = true
- render :show
- else
- set_created_session
-
- redirect_to oauth_application_url(@application)
- end
+ @created = true
+ render :show
else
set_index_vars
render :index
end
end
+ def renew
+ set_application
+
+ @application.renew_secret
+
+ if @application.save
+ render json: { secret: @application.plaintext_secret }
+ else
+ render json: { errors: @application.errors }, status: :unprocessable_entity
+ end
+ end
+
private
def verify_user_oauth_applications_enabled
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 43bf895ea76..96a3fab7e1a 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -108,8 +108,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
end
def dangerous_scopes?
- doorkeeper_application&.includes_scope?(*::Gitlab::Auth::API_SCOPE, *::Gitlab::Auth::READ_API_SCOPE,
- *::Gitlab::Auth::ADMIN_SCOPES, *::Gitlab::Auth::REPOSITORY_SCOPES,
- *::Gitlab::Auth::REGISTRY_SCOPES) && !doorkeeper_application&.trusted?
+ doorkeeper_application&.includes_scope?(
+ *::Gitlab::Auth::API_SCOPE, *::Gitlab::Auth::READ_API_SCOPE,
+ *::Gitlab::Auth::ADMIN_SCOPES, *::Gitlab::Auth::REPOSITORY_SCOPES,
+ *::Gitlab::Auth::REGISTRY_SCOPES
+ ) && !doorkeeper_application&.trusted?
end
end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 3f476c0d717..6fc2eb6bc45 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -20,7 +20,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
end
redirect_to applications_profile_url,
- status: :found,
- notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
+ status: :found,
+ notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy])
end
end
diff --git a/app/controllers/oauth/jira_dvcs/authorizations_controller.rb b/app/controllers/oauth/jira_dvcs/authorizations_controller.rb
index 03921761f45..ba587944a36 100644
--- a/app/controllers/oauth/jira_dvcs/authorizations_controller.rb
+++ b/app/controllers/oauth/jira_dvcs/authorizations_controller.rb
@@ -8,6 +8,7 @@ class Oauth::JiraDvcs::AuthorizationsController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
+ before_action :reversible_end_of_life!
before_action :validate_redirect_uri, only: :new
feature_category :integrations
@@ -16,10 +17,12 @@ class Oauth::JiraDvcs::AuthorizationsController < ApplicationController
def new
session[:redirect_uri] = params['redirect_uri']
- redirect_to oauth_authorization_path(client_id: params['client_id'],
- response_type: 'code',
- scope: normalize_scope(params['scope']),
- redirect_uri: oauth_jira_dvcs_callback_url)
+ redirect_to oauth_authorization_path(
+ client_id: params['client_id'],
+ response_type: 'code',
+ scope: normalize_scope(params['scope']),
+ redirect_uri: oauth_jira_dvcs_callback_url
+ )
end
# 2. Handle the callback call as we were a Github Enterprise instance client.
@@ -53,6 +56,17 @@ class Oauth::JiraDvcs::AuthorizationsController < ApplicationController
private
+ # The endpoints in this controller have been deprecated since 15.1.
+ #
+ # Due to uncertainty about the impact of a full removal in 16.0, all endpoints return `404`
+ # by default but we allow customers to toggle a flag to reverse this breaking change.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/362168#note_1347692683.
+ #
+ # TODO Make the breaking change irreversible https://gitlab.com/gitlab-org/gitlab/-/issues/408148.
+ def reversible_end_of_life!
+ render_404 unless Feature.enabled?(:jira_dvcs_end_of_life_amnesty)
+ end
+
# When using the GitHub Enterprise connector in Jira we receive the "repo" scope,
# this doesn't exist in GitLab but we can map it to our "api" scope.
def normalize_scope(scope)
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 4046433f8ea..a2e0670d7e1 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -10,9 +10,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
after_action :verify_known_sign_in
- protect_from_forgery except: [:cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true
+ protect_from_forgery except: [:failure] + AuthHelper.saml_providers, with: :exception, prepend: true
- feature_category :authentication_and_authorization
+ feature_category :system_access
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
@@ -22,6 +22,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
alias_method provider, :handle_omniauth
end
+ # overridden in EE
+ def openid_connect
+ handle_omniauth
+ end
+
# Extend the standard implementation to also increment
# the number of failed sign in attempts
def failure
@@ -52,15 +57,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_unverified_saml_initiation
end
- def cas3
- ticket = params['ticket']
- if ticket
- handle_service_ticket oauth['provider'], ticket
- end
-
- handle_omniauth
- end
-
def auth0
if oauth['uid'].blank?
fail_auth0_login
@@ -141,12 +137,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
redirect_to profile_account_path, notice: _('Authentication method updated')
end
- def handle_service_ticket(provider, ticket)
- Gitlab::Auth::OAuth::Session.create provider, ticket
- session[:service_tickets] ||= {}
- session[:service_tickets][provider] = ticket
- end
-
def build_auth_user(auth_user_class)
auth_user_class.new(oauth)
end
@@ -178,7 +168,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)
@@ -310,7 +300,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/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 38cdb16c350..38839497fb6 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -12,7 +12,7 @@ class PasswordsController < Devise::PasswordsController
before_action :check_password_authentication_available, only: [:create]
before_action :throttle_reset, only: [:create]
- feature_category :authentication_and_authorization
+ feature_category :system_access
# rubocop: disable CodeReuse/ActiveRecord
def edit
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index cb8b2783000..eb64016379d 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -3,7 +3,7 @@
class Profiles::AccountsController < Profiles::ApplicationController
include AuthHelper
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low, [:show]
def show
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index 2607ba7d404..5a86179b89f 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::ActiveSessionsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
+ feature_category :system_access
def index
@sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index ae757c30d1c..c4384c962d2 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,15 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
def chat_names
@chat_names ||= current_user.chat_names
end
+
+ def integration_name
+ return s_('Integrations|GitLab for Slack app') if slack_app_params?
+
+ s_('Integrations|Mattermost slash commands')
+ end
+
+ def slack_app_params?
+ chat_name_params[:team_id].start_with?('T') &&
+ chat_name_params[:chat_id].start_with?('U', 'W')
+ end
end
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/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index c88616b6d6c..28a57ef19f6 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -3,9 +3,9 @@
class Profiles::EmailsController < Profiles::ApplicationController
before_action :find_email, only: [:destroy, :resend_confirmation_instructions]
before_action -> { check_rate_limit!(:profile_add_new_email, scope: current_user, redirect_back: true) },
- only: [:create]
+ only: [:create]
before_action -> { check_rate_limit!(:profile_resend_email_confirmation, scope: current_user, redirect_back: true) },
- only: [:resend_confirmation_instructions]
+ only: [:resend_confirmation_instructions]
feature_category :user_profile
urgency :low, [:index]
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 9323d266cd5..b663a75f04a 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -43,7 +43,10 @@ class Profiles::NotificationsController < Profiles::ApplicationController
.preload_source_route
projects = project_notifications.map(&:source)
- ActiveRecord::Associations::Preloader.new.preload(projects, { namespace: [:route, :owner], group: [] })
+ ActiveRecord::Associations::Preloader.new(
+ records: projects,
+ associations: { namespace: [:route, :owner], group: [] }
+ ).call
Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
project_notifications.select { |notification| current_user.can?(:read_project, notification.source) }
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index 738c41207d5..7a0dfbbba0d 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -11,7 +11,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
layout :determine_layout
- feature_category :authentication_and_authorization
+ feature_category :system_access
def new
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 1663aa61f62..4b6e2f768fa 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -3,7 +3,7 @@
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
include RenderAccessTokens
- feature_category :authentication_and_authorization
+ feature_category :system_access
before_action :check_personal_access_tokens_enabled
@@ -25,7 +25,10 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def create
result = ::PersonalAccessTokens::CreateService.new(
- current_user: current_user, target_user: current_user, params: personal_access_token_params
+ current_user: current_user,
+ target_user: current_user,
+ params: personal_access_token_params,
+ concatenate_errors: false
).execute
@personal_access_token = result.payload[:personal_access_token]
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 7786bad4251..a5a2cbf3733 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -37,7 +37,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def preferences_param_names
- [
+ preferences_param_names = [
:color_scheme_id,
:diffs_deletion_color,
:diffs_addition_color,
@@ -48,7 +48,6 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:first_day_of_week,
:preferred_language,
:time_display_relative,
- :time_format_in_24h,
:show_whitespace_in_diffs,
:view_diffs_file_by_file,
:tab_width,
@@ -57,9 +56,10 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:render_whitespace_in_code,
:markdown_surround_selection,
:markdown_automatic_lists,
- :use_legacy_web_ide,
:use_new_navigation
]
+ preferences_param_names << :enabled_following if ::Feature.enabled?(:disable_follow_users, user)
+ preferences_param_names
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index aded295bfab..bc6e67a3a7d 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -8,11 +8,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
helper_method :current_password_required?
- before_action do
- push_frontend_feature_flag(:webauthn)
- end
-
- feature_category :authentication_and_authorization
+ feature_category :system_access
def show
setup_show_page
@@ -41,32 +37,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@error = { message: _('Invalid pin code.') }
@qr_code = build_qr_code
@account_string = account_string
-
- if Feature.enabled?(:webauthn)
- setup_webauthn_registration
- else
- setup_u2f_registration
- end
+ setup_webauthn_registration
render 'show'
end
end
- # A U2F (universal 2nd factor) device's information is stored after successful
- # registration, which is then used while 2FA authentication is taking place.
- def create_u2f
- @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, device_registration_params, session[:challenges])
-
- if @u2f_registration.persisted?
- session.delete(:challenges)
- redirect_to profile_two_factor_auth_path, notice: s_("Your U2F device was registered!")
- else
- @qr_code = build_qr_code
- setup_u2f_registration
- render :show
- end
- end
-
def create_webauthn
@webauthn_registration = Webauthn::RegisterService.new(current_user, device_registration_params, session[:challenge]).execute
@@ -175,22 +151,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
Gitlab.config.gitlab.host
end
- # Setup in preparation of communication with a U2F (universal 2nd factor) device
- # Actual communication is performed using a Javascript API
- def setup_u2f_registration
- @u2f_registration ||= U2fRegistration.new
- @registrations = u2f_registrations
- u2f = U2F::U2F.new(u2f_app_id)
-
- registration_requests = u2f.registration_requests
- sign_requests = u2f.authentication_requests(current_user.u2f_registrations.map(&:key_handle))
- session[:challenges] = registration_requests.map(&:challenge)
-
- gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
- register_requests: registration_requests,
- sign_requests: sign_requests })
- end
-
def device_registration_params
params.require(:device_registration).permit(:device_response, :name)
end
@@ -209,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|
{
@@ -275,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/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
deleted file mode 100644
index 32ca303e722..00000000000
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class Profiles::U2fRegistrationsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
-
- def destroy
- u2f_registration = current_user.u2f_registrations.find(params[:id])
- u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted U2F device.")
- end
-end
diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb
index a4a6d84f1ae..345d7bdbca8 100644
--- a/app/controllers/profiles/webauthn_registrations_controller.rb
+++ b/app/controllers/profiles/webauthn_registrations_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController
- feature_category :authentication_and_authorization
+ feature_category :system_access
def destroy
webauthn_registration = current_user.webauthn_registrations.find(params[:id])
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 45b274fc920..da15b393e6c 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -10,14 +10,11 @@ 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]
- feature_category :authentication_and_authorization, [:audit_log]
+ feature_category :system_access, [:audit_log]
urgency :low, [:show, :update]
def show
@@ -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/airflow/dags_controller.rb b/app/controllers/projects/airflow/dags_controller.rb
deleted file mode 100644
index 9d1f0b0d63b..00000000000
--- a/app/controllers/projects/airflow/dags_controller.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module Airflow
- class DagsController < ::Projects::ApplicationController
- before_action :check_feature_flag
- before_action :authorize_read_airflow_dags!
-
- feature_category :dataops
-
- MAX_DAGS_PER_PAGE = 15
- def index
- page = params[:page].to_i
- page = 1 if page <= 0
-
- @dags = ::Airflow::Dags.by_project_id(@project.id)
-
- return unless @dags.any?
-
- @dags = @dags.page(page).per(MAX_DAGS_PER_PAGE)
- return redirect_to(url_for(page: @dags.total_pages)) if @dags.out_of_range?
-
- @pagination = {
- page: page,
- is_last_page: @dags.last_page?,
- per_page: MAX_DAGS_PER_PAGE,
- total_items: @dags.total_count
- }
- end
-
- private
-
- def check_feature_flag
- render_404 unless Feature.enabled?(:airflow_dags, @project)
- end
- end
- end
-end
diff --git a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
index a61b774f9c8..6d48aa765fb 100644
--- a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
+++ b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb
@@ -6,7 +6,7 @@ class Projects::Analytics::CycleAnalytics::StagesController < Projects::Applicat
respond_to :json
- feature_category :planning_analytics
+ feature_category :team_planning
before_action :authorize_read_cycle_analytics!
before_action :only_default_value_stream_is_allowed!
diff --git a/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
index 69327feeb02..1f58b97867f 100644
--- a/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
+++ b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
@@ -1,11 +1,12 @@
# frozen_string_literal: true
class Projects::Analytics::CycleAnalytics::SummaryController < Projects::ApplicationController
+ extend ::Gitlab::Utils::Override
include CycleAnalyticsParams
respond_to :json
- feature_category :planning_analytics
+ feature_category :team_planning
before_action :authorize_read_cycle_analytics!
@@ -17,6 +18,10 @@ class Projects::Analytics::CycleAnalytics::SummaryController < Projects::Applica
private
+ def namespace
+ @project.project_namespace
+ end
+
def project_level
@project_level ||= Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(allowed_params))
end
diff --git a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb
index f58730f1d33..bf6c667b87b 100644
--- a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb
+++ b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb
@@ -5,7 +5,7 @@ class Projects::Analytics::CycleAnalytics::ValueStreamsController < Projects::Ap
respond_to :json
- feature_category :planning_analytics
+ feature_category :team_planning
urgency :low
private
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 5f8060ad756..b5b023a4d64 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -19,17 +19,15 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy]
before_action :entry, only: [:external_file, :file]
+ before_action only: :index do
+ push_frontend_feature_flag(:ci_job_artifact_bulk_destroy, @project)
+ end
+
MAX_PER_PAGE = 20
feature_category :build_artifacts
- def index
- # Loading artifacts is very expensive in projects with a lot of artifacts.
- # This feature flag prevents a DOS attack vector.
- # It should be removed only after resolving the underlying performance
- # issues: https://gitlab.com/gitlab-org/gitlab/issues/32281
- return head :no_content unless Feature.enabled?(:artifacts_management_page, @project)
- end
+ def index; end
def destroy
notice = if artifact.destroy
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 70d9b524e4d..5db7609e07a 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -3,8 +3,6 @@
class Projects::AvatarsController < Projects::ApplicationController
include SendsBlob
- skip_before_action :default_cache_headers, only: :show
-
before_action :authorize_admin_project!, only: [:destroy]
feature_category :projects
diff --git a/app/controllers/projects/aws/base_controller.rb b/app/controllers/projects/aws/base_controller.rb
new file mode 100644
index 00000000000..e183c617a5e
--- /dev/null
+++ b/app/controllers/projects/aws/base_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Projects
+ module Aws
+ class BaseController < Projects::ApplicationController
+ feature_category :five_minute_production_app
+ urgency :low
+
+ before_action :admin_project_aws!
+ before_action :feature_flag_enabled!
+
+ def admin_project_aws!
+ return if can?(current_user, :admin_project_aws, project)
+
+ track_event(:error_invalid_user)
+ access_denied!
+ end
+
+ def feature_flag_enabled!
+ return if Feature.enabled?(:cloudseed_aws, current_user)
+ return if Feature.enabled?(:cloudseed_aws, project.group)
+ return if Feature.enabled?(:cloudseed_aws, project)
+
+ track_event(:error_feature_flag_not_enabled)
+ access_denied!
+ end
+
+ def track_event(action, label = nil)
+ Gitlab::Tracking.event(
+ self.class.name,
+ action.to_s,
+ label: label,
+ project: project,
+ user: current_user
+ )
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/aws/configuration_controller.rb b/app/controllers/projects/aws/configuration_controller.rb
new file mode 100644
index 00000000000..42479d01008
--- /dev/null
+++ b/app/controllers/projects/aws/configuration_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Projects
+ module Aws
+ class ConfigurationController < Projects::Aws::BaseController
+ def index
+ js_data = {}
+ @js_data = Gitlab::Json.dump(js_data)
+ track_event(:render_page)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index dbbffc4c283..372da64cdfa 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -40,6 +40,7 @@ class Projects::BadgesController < Projects::ApplicationController
.new(project, current_user, opts: {
key_text: params[:key_text],
key_width: params[:key_width],
+ value_width: params[:value_width],
order_by: params[:order_by]
})
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index cfff281604e..bb1b8760c42 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -8,28 +8,54 @@ 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
+ load_environment
+ load_blame
+ end
+
+ def streaming
+ show
+ render action: 'show'
+ end
+
+ def page
+ load_environment
+ load_blame
+
+ render partial: 'page'
+ end
+
+ private
+
+ def load_blob
@blob = @repository.blob_at(@commit.id, @path)
- unless @blob
- return redirect_to_tree_root_for_missing_path(@project, @ref, @path)
- end
+ return if @blob
+
+ redirect_to_tree_root_for_missing_path(@project, @ref, @path)
+ end
+ 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
+ end
- blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination))
-
- @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate!
+ def load_blame
+ @blame_mode = Gitlab::Git::BlameMode.new(@commit.project, blame_params)
+ @blame_pagination = Gitlab::Git::BlamePagination.new(@blob, @blame_mode, blame_params)
- @blame_pagination = blame_service.pagination
+ 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
- @blame_per_page = blame_service.per_page
+ 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 b71af82535a..727a4e0251d 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -10,7 +10,7 @@ class Projects::BlobController < Projects::ApplicationController
include RedirectsForMissingPathOnTree
include SourcegraphDecorator
include DiffHelper
- include RedisTracking
+ include ProductAnalyticsTracking
extend ::Gitlab::Utils::Override
prepend_before_action :authenticate_user!, only: [:edit]
@@ -38,14 +38,19 @@ class Projects::BlobController < Projects::ApplicationController
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
- track_redis_hll_event :create, :update, name: 'g_edit_by_sfe'
+ track_event :create, :update,
+ name: 'g_edit_by_sfe',
+ action: 'perform_sfe_action',
+ label: 'usage_activity_by_stage_monthly.create.action_monthly_active_users_sfe_edit',
+ destinations: [:redis_hll, :snowplow]
feature_category :source_code_management
urgency :low, [:create, :show, :edit, :update, :diff]
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_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -54,10 +59,13 @@ class Projects::BlobController < Projects::ApplicationController
end
def create
- create_commit(Files::CreateService, success_notice: _("The file has been successfully created."),
- success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
- failure_view: :new,
- failure_path: project_new_blob_path(@project, @ref))
+ create_commit(
+ Files::CreateService,
+ success_notice: _("The file has been successfully created."),
+ success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
+ failure_view: :new,
+ failure_path: project_new_blob_path(@project, @ref)
+ )
end
def show
@@ -87,11 +95,14 @@ class Projects::BlobController < Projects::ApplicationController
def update
@path = params[:file_path] if params[:file_path].present?
- create_commit(Files::UpdateService, success_path: -> { after_edit_path },
- failure_view: :edit,
- failure_path: project_blob_path(@project, @id))
+ create_commit(
+ Files::UpdateService, success_path: -> { after_edit_path },
+ failure_view: :edit,
+ failure_path: project_blob_path(@project, @id)
+ )
rescue Files::UpdateService::FileChangedError
@conflict = true
+ @different_project = different_project?
render :edit
end
@@ -107,9 +118,12 @@ class Projects::BlobController < Projects::ApplicationController
end
def destroy
- create_commit(Files::DeleteService, success_notice: _("The file has been successfully deleted."),
- success_path: -> { after_delete_path },
- failure_path: project_blob_path(@project, @id))
+ create_commit(
+ Files::DeleteService,
+ success_notice: _("The file has been successfully deleted."),
+ success_path: -> { after_delete_path },
+ failure_path: project_blob_path(@project, @id)
+ )
end
def diff
@@ -318,6 +332,12 @@ class Projects::BlobController < Projects::ApplicationController
file = file.cdn_enabled_url(request.remote_ip) if file.respond_to?(:cdn_enabled_url)
file.url
end
+
+ alias_method :tracking_project_source, :project
+
+ def tracking_namespace_source
+ project&.namespace
+ end
end
Projects::BlobController.prepend_mod
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index f19f143816f..1e17dd586c7 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -98,7 +98,7 @@ class Projects::BranchesController < Projects::ApplicationController
if success
render json: { name: branch_name, url: project_tree_url(@project, branch_name) }
else
- render json: result[:messsage], status: :unprocessable_entity
+ render json: result[:message], status: :unprocessable_entity
end
end
end
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 7ef5016ac00..6762f1c7110 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -3,7 +3,7 @@
class Projects::Ci::LintsController < Projects::ApplicationController
before_action :authorize_create_pipeline!
- feature_category :pipeline_authoring
+ feature_category :pipeline_composition
respond_to :json, only: [:create]
urgency :low, [:create]
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 3a2bc445737..d874c60daec 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -4,9 +4,10 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:ci_job_assistant_drawer, @project)
+ push_frontend_feature_flag(:ai_ci_config_generator, @project)
end
- feature_category :pipeline_authoring
+ feature_category :pipeline_composition
urgency :low, [:show]
diff --git a/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb b/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb
index 003441d4b91..72a07269d79 100644
--- a/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb
+++ b/app/controllers/projects/ci/prometheus_metrics/histograms_controller.rb
@@ -4,7 +4,7 @@ module Projects
module Ci
module PrometheusMetrics
class HistogramsController < Projects::ApplicationController
- feature_category :pipeline_authoring
+ feature_category :pipeline_composition
respond_to :json, only: [:create]
diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb
index 3f759e5c18c..bd58bd3e470 100644
--- a/app/controllers/projects/cluster_agents_controller.rb
+++ b/app/controllers/projects/cluster_agents_controller.rb
@@ -1,22 +1,17 @@
# frozen_string_literal: true
class Projects::ClusterAgentsController < Projects::ApplicationController
- before_action :authorize_can_read_cluster_agent!
+ include KasCookie
- feature_category :kubernetes_management
+ before_action :authorize_read_cluster_agent!
+ before_action :set_kas_cookie, only: [:show], if: -> { current_user }
+
+ 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 252b203b38a..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
@@ -115,8 +119,12 @@ class Projects::CommitController < Projects::ApplicationController
@branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch
- create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.",
- success_path: -> { successful_change_path(@project) }, failure_path: failed_change_path)
+ create_commit(
+ Commits::RevertService,
+ success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.",
+ success_path: -> { successful_change_path(@project) },
+ failure_path: failed_change_path
+ )
end
def cherry_pick
@@ -131,10 +139,13 @@ class Projects::CommitController < Projects::ApplicationController
@branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch
- create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.",
- success_path: -> { successful_change_path(target_project) },
- failure_path: failed_change_path,
- target_project: target_project)
+ create_commit(
+ Commits::CherryPickService,
+ success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.",
+ success_path: -> { successful_change_path(target_project) },
+ failure_path: failed_change_path,
+ target_project: target_project
+ )
end
private
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 3acc71d5dd3..94cd324f312 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -7,7 +7,6 @@ class Projects::CommitsController < Projects::ApplicationController
include RendersCommits
COMMITS_DEFAULT_LIMIT = 40
-
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
around_action :allow_gitaly_ref_name_caching
before_action :require_non_empty_project
@@ -77,15 +76,22 @@ class Projects::CommitsController < Projects::ApplicationController
# fully_qualified_ref is available in some situations from ExtractsRef
ref = @fully_qualified_ref || @ref
+
@commits =
if search.present?
@repository.find_commits_by_message(search, ref, @path, @limit, @offset)
- elsif author.present?
- @repository.commits(ref, author: author, path: @path, limit: @limit, offset: @offset)
else
- @repository.commits(ref, path: @path, limit: @limit, offset: @offset)
+ options = {
+ path: @path,
+ limit: @limit,
+ offset: @offset
+ }
+ options[:author] = author if author.present?
+
+ @repository.commits(ref, **options)
end
+ @commits.load_tags
@commits.each(&:lazy_author) # preload authors
@commits = @commits.with_markdown_cache.with_latest_pipeline(ref)
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index 43b4cdbe9a8..d41a7e4f2df 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -11,7 +11,7 @@ module Projects
before_action :authorize_read_issue!, only: [:issue, :production]
before_action :authorize_read_merge_request!, only: [:code, :review]
- feature_category :planning_analytics
+ feature_category :team_planning
urgency :low
def issue
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 9fe44659250..10dd18c0c86 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -6,22 +6,28 @@ 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_custom_event :show,
+ track_event :show,
name: 'p_analytics_valuestream',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
destinations: %i[redis_hll snowplow]
- feature_category :planning_analytics
+ feature_category :team_planning
urgency :low
before_action do
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)
+
+ 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
@@ -44,12 +50,13 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
override :all_cycle_analytics_params
def all_cycle_analytics_params
- super.merge({ project: @project, value_stream: @value_stream })
+ super.merge({ 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(namespace)
end
+ strong_memoize_attr :value_stream
def cycle_analytics_json
{
@@ -59,6 +66,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
}
end
+ def namespace
+ @project.project_namespace
+ end
+
def tracking_namespace_source
project.namespace
end
@@ -67,3 +78,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/design_management/designs/raw_images_controller.rb b/app/controllers/projects/design_management/designs/raw_images_controller.rb
index beb7e9d294b..ea406d2f2ef 100644
--- a/app/controllers/projects/design_management/designs/raw_images_controller.rb
+++ b/app/controllers/projects/design_management/designs/raw_images_controller.rb
@@ -7,8 +7,6 @@ module Projects
class RawImagesController < Projects::DesignManagement::DesignsController
include SendsBlob
- skip_before_action :default_cache_headers, only: :show
-
def show
blob = design_repository.blob_at(ref, design.full_path)
diff --git a/app/controllers/projects/design_management/designs/resized_image_controller.rb b/app/controllers/projects/design_management/designs/resized_image_controller.rb
index 6bf304419e1..a09d8a73892 100644
--- a/app/controllers/projects/design_management/designs/resized_image_controller.rb
+++ b/app/controllers/projects/design_management/designs/resized_image_controller.rb
@@ -10,8 +10,6 @@ module Projects
before_action :validate_size!
before_action :validate_sha!
- skip_before_action :default_cache_headers, only: :show
-
def show
relation = design.actions
relation = relation.up_to_version(version) if version
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 9a88a8160b6..f91ec55573d 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -9,6 +9,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
include MetricsDashboard
include ProductAnalyticsTracking
+ include KasCookie
layout 'project'
@@ -20,6 +21,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:environment_details_vue, @project)
end
+ before_action only: [:index] do
+ push_frontend_feature_flag(:kas_user_access_project, @project)
+ end
+
before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
@@ -28,19 +33,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
+ before_action :set_kas_cookie, only: [:index], if: -> { current_user }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
- track_event :index,
- :folder,
- :show,
- :new,
- :edit,
- :create,
- :update,
- :stop,
- :cancel_auto_stop,
- :terminal,
- name: 'users_visiting_environments_pages'
+ track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
+ name: 'users_visiting_environments_pages'
feature_category :continuous_delivery
urgency :low
@@ -179,10 +176,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def metrics_redirect
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
redirect_to project_metrics_dashboard_path(project)
end
def metrics
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
respond_to do |format|
format.html do
redirect_to project_metrics_dashboard_path(project, environment: environment)
@@ -198,6 +199,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def additional_metrics
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
respond_to do |format|
format.json do
additional_metrics = environment.additional_metrics(*metrics_params) || {}
@@ -255,11 +258,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def search_environments(type: nil)
search = params[:search] if params[:search] && params[:search].length >= MIN_SEARCH_LENGTH
- @search_environments ||=
- Environments::EnvironmentsFinder.new(project,
- current_user,
- type: type,
- search: search).execute
+ @search_environments ||= Environments::EnvironmentsFinder.new(project, current_user, type: type, search: search).execute
end
def metrics_params
@@ -301,16 +300,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def authorize_update_environment!
access_denied! unless can?(current_user, :update_environment, environment)
end
-
- def append_info_to_payload(payload)
- super
-
- return unless Feature.enabled?(:environments_search_logging) && params[:search].present?
-
- # Merging to :metadata will ensure these are logged as top level keys
- payload[:metadata] ||= {}
- payload[:metadata]['meta.environment.search'] = params[:search]
- end
end
Projects::EnvironmentsController.prepend_mod_with('Projects::EnvironmentsController')
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index d2e36ef5496..d70ee0fabea 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -74,8 +74,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
def render_errors(result)
unless result[:status] == :success
- render json: { message: result[:message] },
- status: result[:http_status] || :bad_request
+ render json: { message: result[:message] }, status: result[:http_status] || :bad_request
end
end
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index 16392775c09..83923965a45 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -97,23 +97,45 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
end
def create_params
- params.require(:operations_feature_flag)
- .permit(:name, :description, :active, :version,
- scopes_attributes: [:environment_scope, :active,
- strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
- strategies_attributes: [:name, :user_list_id,
- parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
- scopes_attributes: [:environment_scope]])
+ params.require(:operations_feature_flag).permit(
+ :name,
+ :description,
+ :active,
+ :version,
+ scopes_attributes: [
+ :environment_scope, :active,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]
+ ],
+ strategies_attributes: [
+ :name,
+ :user_list_id,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:environment_scope]
+ ]
+ )
end
def update_params
- params.require(:operations_feature_flag)
- .permit(:name, :description, :active,
- scopes_attributes: [:id, :environment_scope, :active, :_destroy,
- strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
- strategies_attributes: [:id, :name, :user_list_id, :_destroy,
- parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
- scopes_attributes: [:id, :environment_scope, :_destroy]])
+ params.require(:operations_feature_flag).permit(
+ :name,
+ :description,
+ :active,
+ scopes_attributes: [
+ :id,
+ :environment_scope,
+ :active,
+ :_destroy,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]
+ ],
+ strategies_attributes: [
+ :id,
+ :name,
+ :user_list_id,
+ :_destroy,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:id, :environment_scope, :_destroy]
+ ]
+ )
end
def feature_flag_json(feature_flag)
@@ -144,7 +166,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
end
def render_error_json(messages, status = :bad_request)
- render json: { message: messages },
- status: status
+ render json: { message: messages }, status: status
end
end
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index dfb73821b0f..7eccc0c1c77 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -45,8 +45,8 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
return_url = project_google_cloud_configuration_path(project)
state = generate_session_key_redirect(request.url, return_url)
@authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
- callback_google_api_auth_url,
- state: state).authorize_url
+ callback_google_api_auth_url,
+ state: state).authorize_url
redirect_to @authorize_url
end
diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb
index e109ab95d39..d35b2d54c53 100644
--- a/app/controllers/projects/google_cloud/configuration_controller.rb
+++ b/app/controllers/projects/google_cloud/configuration_controller.rb
@@ -10,7 +10,8 @@ module Projects
databasesUrl: project_google_cloud_databases_path(project),
serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
- emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'),
+ emptyIllustrationUrl:
+ ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg'),
configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project),
gcpRegions: gcp_regions,
revokeOauthUrl: revoke_oauth_url
diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb
index 9c20f10809c..ea79efd9f4f 100644
--- a/app/controllers/projects/google_cloud/databases_controller.rb
+++ b/app/controllers/projects/google_cloud/databases_controller.rb
@@ -15,7 +15,8 @@ module Projects
cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql),
cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver),
cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
- emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
+ emptyIllustrationUrl:
+ ActionController::Base.helpers.image_path('illustrations/empty-state/empty-pipeline-md.svg')
}
@js_data = Gitlab::Json.dump(js_data)
diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb
index 9cd511f6a11..2cc6c6c35ba 100644
--- a/app/controllers/projects/grafana_api_controller.rb
+++ b/app/controllers/projects/grafana_api_controller.rb
@@ -10,6 +10,8 @@ class Projects::GrafanaApiController < Projects::ApplicationController
urgency :low
def proxy
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
result = ::Grafana::ProxyService.new(
project,
params[:datasource_id],
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index d072381933a..e73e2a38149 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -9,7 +9,7 @@ class Projects::GraphsController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_read_repository_graphs!
- track_custom_event :charts,
+ track_event :charts,
name: 'p_analytics_repo',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 22b6bf6faf0..570fe74f31f 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -4,8 +4,8 @@ class Projects::HooksController < Projects::ApplicationController
include ::WebHooks::HookActions
# Authorize
- before_action :authorize_admin_project!
- before_action :hook_logs, only: :edit
+ before_action :authorize_admin_project!, except: :destroy
+ before_action :authorize_destroy_project_hook!, only: :destroy
before_action -> { check_rate_limit!(:project_testing_hook, scope: [@project, current_user]) }, only: :test
respond_to :html
@@ -34,11 +34,11 @@ class Projects::HooksController < Projects::ApplicationController
@hook ||= @project.hooks.find(params[:id])
end
- def hook_logs
- @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count
- end
-
def trigger_values
ProjectHook.triggers.values
end
+
+ def authorize_destroy_project_hook!
+ render_404 unless can?(current_user, :destroy_web_hook, hook)
+ end
end
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 41daeddcf7f..208fbc40556 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -56,7 +56,7 @@ class Projects::ImportsController < Projects::ApplicationController
end
def require_namespace_project_creation_permission
- render_404 unless can?(current_user, :admin_project, @project) || can?(current_user, :create_projects, @project.namespace)
+ render_404 unless can?(current_user, :admin_project, @project) || can?(current_user, :import_projects, @project.namespace)
end
def redirect_if_progress
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 8e4fbf24ca2..7121096bd77 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -10,7 +10,7 @@ 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)
+ push_frontend_feature_flag(:moved_mr_sidebar, project)
end
feature_category :incident_management
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 21227d62023..642d5943854 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -20,7 +20,8 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :disable_query_limiting, only: [:create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
- before_action :redirect_if_work_item, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
+ before_action :redirect_if_work_item, unless: ->(c) { work_item_redirect_except_actions.include?(c.action_name.to_sym) }
+ before_action :require_incident_for_incident_routes, only: :show
after_action :log_issue_show, only: :show
@@ -45,8 +46,10 @@ 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
before_action only: [:index, :show] do
@@ -64,8 +67,7 @@ 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(:use_iid_in_work_items_path, project&.group)
- push_frontend_feature_flag(:incident_event_tags, project)
+ push_frontend_feature_flag(:moved_mr_sidebar, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -389,6 +391,10 @@ class Projects::IssuesController < Projects::ApplicationController
private
+ def work_item_redirect_except_actions
+ ISSUES_EXCEPT_ACTIONS
+ end
+
def render_by_create_result_error(result)
Gitlab::AppLogger.warn(
message: 'Cannot create issue',
@@ -443,11 +449,16 @@ class Projects::IssuesController < Projects::ApplicationController
def redirect_if_work_item
return unless use_work_items_path?(issue)
- if Feature.enabled?(:use_iid_in_work_items_path, project.group)
- redirect_to project_work_items_path(project, issue.iid, params: request.query_parameters.merge(iid_path: true))
- else
- redirect_to project_work_items_path(project, issue.id, params: request.query_parameters)
- end
+ redirect_to project_work_items_path(project, issue.iid, params: request.query_parameters)
+ end
+
+ def require_incident_for_incident_routes
+ return unless params[:incident_tab].present?
+ return if issue.work_item_type&.incident?
+
+ # Redirect instead of 404 to gracefully handle
+ # issue type changes
+ redirect_to project_issue_path(project, issue)
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 3fea5c694f7..79ddcbf732d 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -8,8 +8,8 @@ class Projects::JobsController < Projects::ApplicationController
urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw]
- before_action :find_job_as_build, except: [:index, :play, :show, :retry]
- before_action :find_job_as_processable, only: [:play, :show, :retry]
+ before_action :find_job_as_build, except: [:index, :play, :retry]
+ before_action :find_job_as_processable, only: [:play, :retry]
before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build!
before_action :authorize_update_build!,
@@ -128,8 +128,7 @@ class Projects::JobsController < Projects::ApplicationController
service_response = Ci::BuildEraseService.new(@build, current_user).execute
if service_response.success?
- redirect_to project_job_path(project, @build),
- notice: _("Job has been successfully erased!")
+ redirect_to project_job_path(project, @build), notice: _("Job has been successfully erased!")
else
head service_response.http_status
end
@@ -138,9 +137,7 @@ class Projects::JobsController < Projects::ApplicationController
def raw
if @build.trace.archived?
workhorse_set_content_type!
- send_upload(@build.job_artifacts_trace.file,
- send_params: raw_send_params,
- redirect_params: raw_redirect_params)
+ send_upload(@build.job_artifacts_trace.file, send_params: raw_send_params, redirect_params: raw_redirect_params)
else
@build.trace.read do |stream|
if stream.file?
@@ -234,10 +231,12 @@ class Projects::JobsController < Projects::ApplicationController
end
def build_service_specification
- @build.service_specification(service: params['service'],
- port: params['port'],
- path: params['path'],
- subprotocols: proxy_subprotocol)
+ @build.service_specification(
+ service: params['service'],
+ port: params['port'],
+ path: params['path'],
+ subprotocols: proxy_subprotocol
+ )
end
def proxy_subprotocol
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 14f2e372bc5..649bead0b6d 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -82,9 +82,7 @@ class Projects::LabelsController < Projects::ApplicationController
@label.destroy
@labels = find_labels
- redirect_to project_labels_path(@project),
- status: :found,
- notice: 'Label was removed'
+ redirect_to project_labels_path(@project), status: :found, notice: 'Label was removed'
end
def remove_priority
@@ -138,8 +136,9 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to(project_labels_path(@project),
- notice: _('Failed to promote label due to internal error. Please contact administrators.'))
+ redirect_to(
+ project_labels_path(@project),
+ notice: _('Failed to promote label due to internal error. Please contact administrators.'))
end
format.js
end
@@ -165,13 +164,14 @@ class Projects::LabelsController < Projects::ApplicationController
end
def find_labels
- @available_labels ||=
- LabelsFinder.new(current_user,
- project_id: @project.id,
- include_ancestor_groups: true,
- search: params[:search],
- subscribed: params[:subscribed],
- sort: sort).execute
+ @available_labels ||= LabelsFinder.new(
+ current_user,
+ project_id: @project.id,
+ include_ancestor_groups: true,
+ search: params[:search],
+ subscribed: params[:subscribed],
+ sort: sort
+ ).execute
end
def sort
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/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 3b399e3294e..3a03831ab88 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -114,11 +114,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@target_project = @merge_request.target_project
@source_project = @merge_request.source_project
- @commits =
- set_commits_for_rendering(
- @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch),
- commits_count: @merge_request.commits_count
- )
+ @commits = set_commits_for_rendering(
+ @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch),
+ commits_count: @merge_request.commits_count
+ )
@commit = @merge_request.diff_head_commit
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index d92ef3de6d9..ad3b79b604c 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -32,18 +32,28 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
+ before_action only: :index do
+ push_frontend_feature_flag(:mr_approved_filter, type: :ops)
+ end
+
before_action only: [:show, :diffs] 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?)
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)
- end
-
- before_action do
- push_frontend_feature_flag(:permit_all_shared_groups_for_approval, @project)
+ push_frontend_feature_flag(:realtime_approvals, 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)
+ push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?)
+ push_frontend_feature_flag(:mr_activity_filters, current_user)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
@@ -123,8 +133,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
recent_commits,
commits_count: @merge_request.commits_count
)
+ commits_count = @merge_request.preparing? ? '-' : @merge_request.commits_count + @merge_request.context_commits_count
- render json: { html: view_to_html_string('projects/merge_requests/_commits'), next_page: @next_page }
+ render json: { html: view_to_html_string('projects/merge_requests/_commits'), next_page: @next_page, count: commits_count }
end
def pipelines
@@ -266,6 +277,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
@@ -383,10 +396,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.merge_request_reviewers.map(&:cache_key)
]
- render_cached(@merge_request,
- with: serializer,
- cache_context: ->(_) { [Digest::SHA256.hexdigest(cache_context.to_s)] },
- serializer: params[:serializer])
+ render_cached(
+ @merge_request,
+ with: serializer,
+ cache_context: ->(_) { [Digest::SHA256.hexdigest(cache_context.to_s)] },
+ serializer: params[:serializer]
+ )
else
render json: serializer.represent(@merge_request, serializer: params[:serializer])
end
@@ -485,8 +500,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
AutoMergeService.new(project, current_user, merge_params).update(merge_request)
else
AutoMergeService.new(project, current_user, merge_params)
- .execute(merge_request,
- params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ .execute(merge_request, params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
end
else
@merge_request.merge_async(current_user.id, merge_params)
@@ -591,6 +605,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
Date.strptime(date, "%Y-%m-%d")&.to_time&.to_i if date
rescue Date::Error, TypeError
end
+
+ def summarize_my_code_review_enabled?
+ namespace = project&.group&.root_ancestor
+ return false if namespace.nil?
+
+ Feature.enabled?(:summarize_my_code_review, current_user) &&
+ namespace.group_namespace? &&
+ namespace.licensed_feature_available?(:summarize_my_mr_code_review) &&
+ Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review) &&
+ merge_request.send_to_ai?
+ end
end
Projects::MergeRequestsController.prepend_mod_with('Projects::MergeRequestsController')
diff --git a/app/controllers/projects/metrics/dashboards/builder_controller.rb b/app/controllers/projects/metrics/dashboards/builder_controller.rb
index a6b57798923..02e3afcdc80 100644
--- a/app/controllers/projects/metrics/dashboards/builder_controller.rb
+++ b/app/controllers/projects/metrics/dashboards/builder_controller.rb
@@ -10,6 +10,8 @@ module Projects
urgency :low
def panel_preview
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
respond_to do |format|
format.json do
if rendered_panel.success?
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
index 08757d11912..510c882d537 100644
--- a/app/controllers/projects/metrics_dashboard_controller.rb
+++ b/app/controllers/projects/metrics_dashboard_controller.rb
@@ -9,6 +9,9 @@ module Projects
include Gitlab::Utils::StrongMemoize
before_action :authorize_metrics_dashboard!
+ before_action :render_404, only: :show, if: -> do
+ Feature.enabled?(:remove_monitor_metrics)
+ end
feature_category :metrics
urgency :low
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 78108cf3478..569a514b23b 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -85,6 +85,24 @@ class Projects::MilestonesController < Projects::ApplicationController
end
end
end
+ rescue ActiveRecord::StaleObjectError
+ respond_to do |format|
+ format.html do
+ @conflict = true
+ render :edit
+ end
+
+ format.json do
+ render json: {
+ errors: [
+ format(
+ _("Someone edited this %{model_name} at the same time you did. Please refresh your browser and make sure your changes will not unintentionally remove theirs."),
+ model_name: _('milestone')
+ )
+ ]
+ }, status: :conflict
+ end
+ end
end
def promote
@@ -152,7 +170,15 @@ class Projects::MilestonesController < Projects::ApplicationController
end
def milestone_params
- params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
+ params.require(:milestone)
+ .permit(
+ :description,
+ :due_date,
+ :lock_version,
+ :start_date,
+ :state_event,
+ :title
+ )
end
def search_params
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 db0762a6cff..332d33b8e52 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -43,9 +43,7 @@ class Projects::PagesController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to project_pages_path(@project),
- status: :found,
- notice: 'Pages were scheduled for removal'
+ redirect_to project_pages_path(@project), status: :found, notice: 'Pages were scheduled for removal'
end
end
end
@@ -77,7 +75,15 @@ class Projects::PagesController < Projects::ApplicationController
end
def project_params_attributes
- %i[pages_https_only]
+ attributes = %i[pages_https_only]
+
+ return attributes unless Feature.enabled?(:pages_unique_domain, @project)
+
+ attributes + [
+ project_setting_attributes: [
+ :pages_unique_domain_enabled
+ ]
+ ]
end
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 43952a2efe4..5cb69e8bf99 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -69,9 +69,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
respond_to do |format|
format.html do
- redirect_to project_pages_path(@project),
- status: :found,
- notice: 'Domain was removed'
+ redirect_to project_pages_path(@project), status: :found, notice: 'Domain was removed'
end
format.js
end
diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
index d043f8d0b9f..1255ec1dde2 100644
--- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb
+++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
@@ -16,6 +16,8 @@ module Projects
urgency :low
def create
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute
if result[:status] == :success
@@ -26,6 +28,8 @@ module Projects
end
def update
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
result = ::Metrics::Dashboard::UpdateDashboardService.new(project, current_user, dashboard_params.merge(file_content_params)).execute
if result[:status] == :success
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 19d031bd59b..fb332fec3b5 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -8,8 +8,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_read_pipeline_schedule!
before_action :authorize_create_pipeline_schedule!, only: [:new, :create]
before_action :authorize_update_pipeline_schedule!, only: [:edit, :update]
- before_action :authorize_take_ownership_pipeline_schedule!, only: [:take_ownership]
- before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
+ before_action :authorize_admin_pipeline_schedule!, only: [:take_ownership, :destroy]
before_action :push_schedule_feature_flag, only: [:index, :new, :edit]
feature_category :continuous_integration
@@ -78,9 +77,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
if schedule.destroy
redirect_to pipeline_schedules_path(@project), status: :found
else
- redirect_to pipeline_schedules_path(@project),
- status: :forbidden,
- alert: _("Failed to remove the pipeline schedule")
+ redirect_to pipeline_schedules_path(@project), status: :forbidden, alert: _("Failed to remove the pipeline schedule")
end
end
@@ -113,10 +110,6 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule)
end
- def authorize_take_ownership_pipeline_schedule!
- return access_denied! unless can?(current_user, :take_ownership_pipeline_schedule, schedule)
- end
-
def authorize_admin_pipeline_schedule!
return access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule)
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 10f58a9f479..39ebcd60e9a 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -2,23 +2,23 @@
class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
- include RedisTracking
+ include ProductAnalyticsTracking
include ProductAnalyticsTracking
include ProjectStatsRefreshConflictsGuard
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]
@@ -28,24 +28,24 @@ class Projects::PipelinesController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
- track_custom_event :charts,
+ track_event :charts,
name: 'p_analytics_pipelines',
action: 'perform_analytics_usage_action',
label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly',
destinations: %i[redis_hll snowplow]
- track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? }
- track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? }
- track_redis_hll_event :charts, name: 'p_analytics_ci_cd_lead_time', if: -> { should_track_ci_cd_lead_time? }
- track_redis_hll_event :charts, name: 'p_analytics_ci_cd_time_to_restore_service', if: -> { should_track_ci_cd_time_to_restore_service? }
- track_redis_hll_event :charts, name: 'p_analytics_ci_cd_change_failure_rate', if: -> { should_track_ci_cd_change_failure_rate? }
+ track_event :charts, name: 'p_analytics_ci_cd_pipelines', conditions: -> { should_track_ci_cd_pipelines? }
+ track_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', conditions: -> { should_track_ci_cd_deployment_frequency? }
+ track_event :charts, name: 'p_analytics_ci_cd_lead_time', conditions: -> { should_track_ci_cd_lead_time? }
+ track_event :charts, name: 'p_analytics_ci_cd_time_to_restore_service', conditions: -> { should_track_ci_cd_time_to_restore_service? }
+ track_event :charts, name: 'p_analytics_ci_cd_change_failure_rate', conditions: -> { should_track_ci_cd_change_failure_rate? }
wrap_parameters Ci::Pipeline
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
]
@@ -61,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)
@@ -98,15 +96,15 @@ class Projects::PipelinesController < Projects::ApplicationController
end
format.json do
if service_response.success?
- render json: PipelineSerializer
- .new(project: project, current_user: current_user)
- .represent(@pipeline),
- status: :created
+ render json: PipelineSerializer.new(project: project, current_user: current_user).represent(@pipeline),
+ status: :created
else
- render json: { errors: @pipeline.error_messages.map(&:content),
- warnings: @pipeline.warning_messages(limit: ::Gitlab::Ci::Warnings::MAX_LIMIT).map(&:content),
- total_warnings: @pipeline.warning_messages.length },
- status: :bad_request
+ bad_request_json = {
+ errors: @pipeline.error_messages.map(&:content),
+ warnings: @pipeline.warning_messages(limit: ::Gitlab::Ci::Warnings::MAX_LIMIT).map(&:content),
+ total_warnings: @pipeline.warning_messages.length
+ }
+ render json: bad_request_json, status: :bad_request
end
end
end
@@ -216,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,
@@ -241,7 +227,12 @@ class Projects::PipelinesController < Projects::ApplicationController
PipelineSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
- .represent(@pipelines, disable_coverage: true, preload: true)
+ .represent(
+ @pipelines,
+ disable_coverage: true,
+ preload: true,
+ disable_manual_and_scheduled_actions: true
+ )
end
def render_show
@@ -274,29 +265,34 @@ class Projects::PipelinesController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipeline
- @pipeline ||= if params[:id].blank? && params[:latest]
- latest_pipeline
- else
- project
- .all_pipelines
- .includes(builds: :tags, user: :status)
- .find(params[:id])
- .present(current_user: current_user)
- end
+ return @pipeline if defined?(@pipeline)
+
+ pipelines =
+ if find_latest_pipeline?
+ project.latest_pipelines(params['ref'])
+ else
+ project.all_pipelines.id_in(params[:id])
+ end
+
+ @pipeline = pipelines
+ .includes(builds: :tags, user: :status)
+ .take
+ &.present(current_user: current_user)
+
+ @pipeline || not_found
end
# rubocop: enable CodeReuse/ActiveRecord
def set_pipeline_path
- @pipeline_path ||= if params[:id].blank? && params[:latest]
+ @pipeline_path ||= if find_latest_pipeline?
latest_project_pipelines_path(@project, params['ref'])
else
project_pipeline_path(@project, @pipeline)
end
end
- def latest_pipeline
- @project.latest_pipeline(params['ref'])
- &.present(current_user: current_user)
+ def find_latest_pipeline?
+ params[:id].blank? && params[:latest]
end
def disable_query_limiting
@@ -326,17 +322,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
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/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
index db5471ea322..c20c80ba334 100644
--- a/app/controllers/projects/prometheus/metrics_controller.rb
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -68,7 +68,7 @@ module Projects
if @metric.persisted?
redirect_to edit_project_settings_integration_path(project, ::Integrations::Prometheus),
- notice: _('Metric was successfully added.')
+ notice: _('Metric was successfully added.')
else
render 'new'
end
@@ -79,7 +79,7 @@ module Projects
if @metric.update(metrics_params)
redirect_to edit_project_settings_integration_path(project, ::Integrations::Prometheus),
- notice: _('Metric was successfully updated.')
+ notice: _('Metric was successfully updated.')
else
render 'edit'
end
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index 895a9a00624..79b5990abba 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -6,8 +6,6 @@ class Projects::RawController < Projects::ApplicationController
include SendsBlob
include StaticObjectExternalStorage
- skip_before_action :default_cache_headers, only: :show
-
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) }
before_action :assign_ref_vars
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index f55fc2242a4..4c2bd2a9d42 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -22,7 +22,7 @@ class Projects::RefsController < Projects::ApplicationController
when "tree"
project_tree_path(@project, @id)
when "blob"
- project_blob_path(@project, @id, ref_type: ref_type)
+ project_blob_path(@project, @id)
when "graph"
project_network_path(@project, @id, ref_type: ref_type)
when "graphs"
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 1cd4c5b6137..4a9282432fd 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -8,8 +8,6 @@ class Projects::RepositoriesController < Projects::ApplicationController
prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) }
- skip_before_action :default_cache_headers, only: :archive
-
# Authorize
before_action :check_archive_rate_limiting!, only: :archive
before_action :require_non_empty_project, except: :create
@@ -29,9 +27,8 @@ class Projects::RepositoriesController < Projects::ApplicationController
end
def archive
- return render_404 if html_request?
-
set_cache_headers
+
return if archive_not_modified?
send_git_archive @repository, **repo_params
@@ -49,9 +46,14 @@ class Projects::RepositoriesController < Projects::ApplicationController
def set_cache_headers
commit_id = archive_metadata['CommitId']
- expires_in(cache_max_age(commit_id),
- public: Guest.can?(:download_code, project), must_revalidate: true, stale_if_error: 5.minutes,
- stale_while_revalidate: 1.minute, 's-maxage': 1.minute)
+ expires_in(
+ cache_max_age(commit_id),
+ public: Guest.can?(:download_code, project),
+ must_revalidate: true,
+ stale_if_error: 5.minutes,
+ stale_while_revalidate: 1.minute,
+ 's-maxage': 1.minute
+ )
fresh_when(strong_etag: [commit_id, archive_metadata['ArchivePath']])
end
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 6f896244acb..a7df1a0a412 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -16,7 +16,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
path = project_runners_path(project)
if ::Ci::Runners::AssignRunnerService.new(@runner, @project, current_user).execute.success?
- redirect_to path, notice: s_('Runners|Runner assigned to project.')
+ flash[:success] = s_('Runners|Runner assigned to project.')
+ redirect_to path
else
assign_to_messages = @runner.errors.messages[:assign_to]
alert = assign_to_messages&.join(',') || 'Failed adding runner to project'
@@ -30,6 +31,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
::Ci::Runners::UnassignRunnerService.new(runner_project, current_user).execute
- redirect_to project_runners_path(project), status: :found, notice: s_('Runners|Runner unassigned from project.')
+ flash[:success] = s_('Runners|Runner unassigned from project.')
+ redirect_to project_runners_path(project), status: :found
end
end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index ee12b85b3a4..2b2c2cef8e2 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -2,7 +2,8 @@
class Projects::RunnersController < Projects::ApplicationController
before_action :authorize_admin_build!
- before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
+ before_action :authorize_create_runner!, only: [:new, :register]
+ before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show, :register]
feature_category :runner
urgency :low
@@ -22,6 +23,14 @@ class Projects::RunnersController < Projects::ApplicationController
end
end
+ def new
+ render_404 unless create_runner_workflow_for_namespace_enabled?
+ end
+
+ def register
+ render_404 unless create_runner_workflow_for_namespace_enabled? && runner.registration_available?
+ end
+
def destroy
if @runner.only_for?(project)
Ci::Runners::UnregisterRunnerService.new(@runner, current_user).execute
@@ -75,4 +84,8 @@ class Projects::RunnersController < Projects::ApplicationController
def runner_params
params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
end
+
+ def create_runner_workflow_for_namespace_enabled?
+ Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace)
+ end
end
diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb
index 00a2a5d1193..ee2e60b5a1a 100644
--- a/app/controllers/projects/security/configuration_controller.rb
+++ b/app/controllers/projects/security/configuration_controller.rb
@@ -32,9 +32,7 @@ module Projects
end
def configuration_presenter
- ::Projects::Security::ConfigurationPresenter.new(project,
- **presenter_attributes,
- current_user: current_user)
+ ::Projects::Security::ConfigurationPresenter.new(project, **presenter_attributes, current_user: current_user)
end
def presenter_attributes
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index 0884816ef62..af1527ba6a3 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -7,7 +7,7 @@ module Projects
include AccessTokensActions
layout 'project_settings'
- feature_category :authentication_and_authorization
+ feature_category :system_access
alias_method :resource, :project
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 4ca665679c0..ce760051f79 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -13,7 +13,11 @@ module Projects
before_action :define_variables
before_action do
- push_frontend_feature_flag(:ci_inbound_job_token_scope, @project)
+ push_frontend_feature_flag(:ci_variables_pages, current_user)
+ push_frontend_feature_flag(:ci_limit_environment_scope, @project)
+ push_frontend_feature_flag(:create_runner_workflow_for_namespace, @project.namespace)
+ push_frontend_feature_flag(:frozen_outbound_job_token_scopes, @project)
+ push_frontend_feature_flag(:frozen_outbound_job_token_scopes_override, @project)
end
helper_method :highlight_badge
@@ -131,7 +135,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/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index 16c1373df2b..a0e72fb1687 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -25,6 +25,7 @@ module Projects
end
def edit
+ render_404 if integration.to_param == 'prometheus' && Feature.enabled?(:remove_monitor_metrics)
end
def update
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 478d9f0b38e..952c9e90a2c 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -133,7 +133,11 @@ module Projects
],
grafana_integration_attributes: [:token, :grafana_url, :enabled]
- }
+ }.tap do |potential_params|
+ if Feature.enabled?(:remove_monitor_metrics)
+ potential_params.except!(:metrics_setting_attributes, :grafana_integration_attributes)
+ end
+ end
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 74d730db026..a6f4e2fcd73 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -122,21 +122,6 @@ module Projects
]
end
- def access_levels_options
- {
- create_access_levels: levels_for_dropdown,
- push_access_levels: levels_for_dropdown,
- merge_access_levels: levels_for_dropdown
- }
- end
-
- def levels_for_dropdown
- roles = ProtectedRefAccess::HUMAN_ACCESS_LEVELS.map do |id, text|
- { id: id, text: text, before_divider: true }
- end
- { roles: roles }
- end
-
def protectable_tags_for_dropdown
{ open_tags: ProtectableDropdown.new(@project, :tags).hash }
end
@@ -154,7 +139,7 @@ module Projects
def load_gon_index
gon.push(protectable_tags_for_dropdown)
gon.push(protectable_branches_for_dropdown)
- gon.push(access_levels_options)
+ gon.push(helpers.protected_access_levels_for_dropdowns)
gon.push(current_project_id: project.id) if project
end
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 1f7912e15df..495241df912 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,8 @@ 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_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
@@ -49,9 +50,12 @@ class Projects::TreeController < Projects::ApplicationController
def create_dir
return render_404 unless @commit_params.values.all?
- create_commit(Files::CreateDirService, success_notice: _("The directory has been successfully created."),
- success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
- failure_path: project_tree_path(@project, @ref))
+ create_commit(
+ Files::CreateDirService,
+ success_notice: _("The directory has been successfully created."),
+ success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
+ failure_path: project_tree_path(@project, @ref)
+ )
end
private
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 a83ccccbeae..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_authoring
+ feature_category :secrets_management
urgency :low, [:show, :update]
diff --git a/app/controllers/projects/web_ide_terminals_controller.rb b/app/controllers/projects/web_ide_terminals_controller.rb
index cfccc949244..be7423e3919 100644
--- a/app/controllers/projects/web_ide_terminals_controller.rb
+++ b/app/controllers/projects/web_ide_terminals_controller.rb
@@ -29,10 +29,7 @@ class Projects::WebIdeTerminalsController < Projects::ApplicationController
end
def create
- result = ::Ci::CreateWebIdeTerminalService.new(project,
- current_user,
- ref: params[:branch])
- .execute
+ result = ::Ci::CreateWebIdeTerminalService.new(project, current_user, ref: params[:branch]).execute
if result[:status] == :error
render status: :bad_request, json: result[:message]
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
index db9dca14aab..7da31c199a1 100644
--- a/app/controllers/projects/work_items_controller.rb
+++ b/app/controllers/projects/work_items_controller.rb
@@ -1,15 +1,71 @@
# 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?)
push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?)
- push_frontend_feature_flag(:use_iid_in_work_items_path, project&.group)
+ push_force_frontend_feature_flag(:saved_replies, current_user)
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 d71b782c62b..0f3143606ff 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -38,8 +38,9 @@ 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(:increase_page_size_exponentially, @project)
+ push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
+ push_frontend_feature_flag(:remove_monitor_metrics, @project)
+ push_frontend_feature_flag(:explain_code_chat, current_user)
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?)
@@ -78,11 +79,11 @@ class ProjectsController < Projects::ApplicationController
@namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace)
+ @parent_group = Group.find_by(id: params[:namespace_id])
+
@current_user_group =
if current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).count == 1
current_user.manageable_groups(include_groups_with_developer_maintainer_access: true).first
- else
- nil
end
@project = Project.new(namespace_id: @namespace&.id)
@@ -207,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/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index cfb4e939b35..ac8959e0f52 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -4,13 +4,14 @@ module Registrations
class WelcomeController < ApplicationController
include OneTrustCSP
include GoogleAnalyticsCSP
- include RegistrationsTracking
layout 'minimal'
skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
before_action :require_current_user
- feature_category :authentication_and_authorization
+ helper_method :welcome_update_params
+
+ feature_category :user_management
def show
return redirect_to path_for_signed_in_user(current_user) if completed_welcome_step?
@@ -48,16 +49,7 @@ module Registrations
params.require(:user).permit(:role, :setup_for_company)
end
- def requires_confirmation?(user)
- return false if user.confirmed?
- return false if Feature.enabled?(:soft_email_confirmation)
-
- true
- end
-
def path_for_signed_in_user(user)
- return users_almost_there_path(email: user.email) if requires_confirmation?(user)
-
stored_location_for(user) || members_activity_path(user.members)
end
@@ -98,6 +90,11 @@ module Registrations
# overridden in EE
def track_event(action); end
+
+ # overridden in EE
+ def welcome_update_params
+ {}
+ end
end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index ed0e019d02b..3e6683fc867 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -9,7 +9,6 @@ class RegistrationsController < Devise::RegistrationsController
include BizibleCSP
include GoogleAnalyticsCSP
include PreferredLanguageSwitcher
- include RegistrationsTracking
include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
layout 'devise'
@@ -25,10 +24,12 @@ class RegistrationsController < Devise::RegistrationsController
before_action only: [:new] do
push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)
- push_frontend_feature_flag(:trial_email_validation, type: :development)
end
- feature_category :authentication_and_authorization
+ feature_category :user_management
+
+ helper_method :arkose_labs_enabled?
+ helper_method :registration_path_params
def new
@resource = build_resource
@@ -39,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)
@@ -51,8 +53,6 @@ class RegistrationsController < Devise::RegistrationsController
end
after_request_hook(new_user)
-
- yield new_user if block_given?
end
# Devise sets a flash message on both successful & failed signups,
@@ -128,13 +128,18 @@ class RegistrationsController < Devise::RegistrationsController
def after_inactive_sign_up_path_for(resource)
Gitlab::AppLogger.info(user_created_message)
return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval?
- return dashboard_projects_path if Feature.enabled?(:soft_email_confirmation)
+ return dashboard_projects_path if Gitlab::CurrentSettings.email_confirmation_setting_soft?
- # when email confirmation is enabled, path to redirect is saved
+ # when email_confirmation_setting is set to `hard`, path to redirect is saved
# after user confirms and comes back, he will be redirected
store_location_for(:redirect, after_sign_up_path)
- return identity_verification_redirect_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
Gitlab::Tracking.event(self.class.name, 'render', user: resource)
users_almost_there_path(email: resource.email)
@@ -143,7 +148,12 @@ class RegistrationsController < Devise::RegistrationsController
private
def after_sign_up_path
- users_sign_up_welcome_path(glm_tracking_params)
+ users_sign_up_welcome_path
+ end
+
+ # overridden in EE
+ def registration_path_params
+ {}
end
def track_creation(user:)
@@ -183,6 +193,7 @@ class RegistrationsController < Devise::RegistrationsController
flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
flash.delete :recaptcha_error
add_gon_variables
+ set_minimum_password_length
render action: 'new'
end
@@ -221,7 +232,7 @@ class RegistrationsController < Devise::RegistrationsController
def resource
@resource ||= Users::RegistrationsBuildService
- .new(current_user, sign_up_params.merge({ skip_confirmation: registered_with_invite_email?,
+ .new(current_user, sign_up_params.merge({ skip_confirmation: skip_confirmation?,
preferred_language: preferred_language }))
.execute
end
@@ -230,6 +241,10 @@ class RegistrationsController < Devise::RegistrationsController
@devise_mapping ||= Devise.mappings[:user]
end
+ def skip_confirmation?
+ registered_with_invite_email?
+ end
+
def registered_with_invite_email?
invite_email = session.delete(:invite_email)
@@ -282,17 +297,26 @@ 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
def send_custom_confirmation_instructions
# overridden by EE module
end
+
+ def arkose_labs_enabled?
+ false
+ end
end
RegistrationsController.prepend_mod_with('RegistrationsController')
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index bd3461d8331..4f228ced542 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -8,6 +8,7 @@ module Repositories
prepend_before_action :deny_head_requests, only: [:info_refs]
rescue_from Gitlab::GitAccess::ForbiddenError, with: :render_403_with_exception
+ rescue_from JWT::DecodeError, with: :render_403_with_exception
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception
rescue_from Gitlab::GitAccessProject::CreationError, with: :render_422_with_exception
rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception
@@ -19,6 +20,7 @@ module Repositories
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
log_user_activity if upload_pack?
+ log_user_activity if receive_pack? && Feature.enabled?(:log_user_git_push_activity)
render_ok
end
@@ -49,6 +51,10 @@ module Repositories
git_command == 'git-upload-pack'
end
+ def receive_pack?
+ git_command == 'git-receive-pack'
+ end
+
def git_command
if action_name == 'info_refs'
params[:service]
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 83973d07a17..d52ae723eee 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -172,13 +172,15 @@ module Repositories
LfsObjectsProject.link_to_project!(lfs_object, project)
- Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project",
- lfs_object_oid: lfs_object.oid,
- lfs_object_size: lfs_object.size,
- source_project_id: project.fork_source.id,
- source_project_path: project.fork_source.full_path,
- target_project_id: project.project_id,
- target_project_path: project.full_path)
+ Gitlab::AppJsonLogger.info(
+ message: "LFS object auto-linked to forked project",
+ lfs_object_oid: lfs_object.oid,
+ lfs_object_size: lfs_object.size,
+ source_project_id: project.fork_source.id,
+ source_project_path: project.fork_source.full_path,
+ target_project_id: project.project_id,
+ target_project_path: project.full_path
+ )
end
end
end
diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb
index ea858d63236..52ae9068c75 100644
--- a/app/controllers/repositories/lfs_locks_api_controller.rb
+++ b/app/controllers/repositories/lfs_locks_api_controller.rb
@@ -37,9 +37,7 @@ module Repositories
private
def render_json(data, process = true)
- render json: build_payload(data, process),
- content_type: LfsRequest::CONTENT_TYPE,
- status: @result[:http_status]
+ render json: build_payload(data, process), content_type: LfsRequest::CONTENT_TYPE, status: @result[:http_status]
end
def build_payload(data, process)
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 1ca34dee3d6..a3c6499bc54 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -3,18 +3,18 @@
class SearchController < ApplicationController
include ControllerWithCrossProjectAccessCheck
include SearchHelper
- include RedisTracking
+ include ProductAnalyticsTracking
include ProductAnalyticsTracking
include SearchRateLimitable
RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze
CODE_SEARCH_LITERALS = %w[blob: extension: path: filename:].freeze
- track_custom_event :show,
- name: 'i_search_total',
- label: 'redis_hll_counters.search.search_total_unique_counts_monthly',
- action: 'executed',
- destinations: [:redis_hll, :snowplow]
+ track_event :show,
+ name: 'i_search_total',
+ label: 'redis_hll_counters.search.search_total_unique_counts_monthly',
+ action: 'executed',
+ destinations: [:redis_hll, :snowplow]
def self.search_rate_limited_endpoints
%i[show count autocomplete]
@@ -24,7 +24,6 @@ class SearchController < ApplicationController
before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch
skip_before_action :authenticate_user!
- skip_before_action :default_cache_headers, only: :count
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
@@ -33,9 +32,6 @@ class SearchController < ApplicationController
before_action :check_search_rate_limit!, only: search_rate_limited_endpoints
before_action only: :show do
- push_frontend_feature_flag(:search_blobs_language_aggregation, current_user)
- end
- before_action only: :show do
update_scope_for_code_search
end
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
@@ -116,6 +112,9 @@ class SearchController < ApplicationController
@ref = params[:project_ref] if params[:project_ref].present?
@filter = params[:filter]
+ # Cache the response on the frontend
+ expires_in 1.minute
+
render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter))
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index b6aba04c877..8a79353f490 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -36,9 +36,6 @@ class SessionsController < Devise::SessionsController
before_action :save_failed_login, if: :action_new_and_failed_login?
before_action :load_recaptcha
before_action :set_invite_params, only: [:new]
- before_action do
- push_frontend_feature_flag(:webauthn)
- end
after_action :log_failed_login, if: :action_new_and_failed_login?
after_action :verify_known_sign_in, only: [:create]
@@ -56,7 +53,7 @@ class SessionsController < Devise::SessionsController
# token mismatch.
protect_from_forgery with: :exception, prepend: true, except: :destroy
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha'
@@ -72,8 +69,7 @@ class SessionsController < Devise::SessionsController
super do |resource|
# User has successfully signed in, so clear any unused reset token
if resource.reset_password_token.present?
- resource.update(reset_password_token: nil,
- reset_password_sent_at: nil)
+ resource.update(reset_password_token: nil, reset_password_sent_at: nil)
end
if resource.deactivated?
@@ -311,10 +307,8 @@ class SessionsController < Devise::SessionsController
def authentication_method
if user_params[:otp_attempt]
AuthenticationEvent::TWO_FACTOR
- elsif user_params[:device_response] && Feature.enabled?(:webauthn)
+ elsif user_params[:device_response]
AuthenticationEvent::TWO_FACTOR_WEBAUTHN
- elsif user_params[:device_response] && !Feature.enabled?(:webauthn)
- AuthenticationEvent::TWO_FACTOR_U2F
else
AuthenticationEvent::STANDARD
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index e81868faa6e..3f20e1c0e86 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -14,7 +14,7 @@ class SnippetsController < Snippets::ApplicationController
skip_before_action :authenticate_user!, only: [:index, :show, :raw]
- layout 'snippets'
+ layout :determine_layout
def index
if params[:username].present?
@@ -48,4 +48,12 @@ class SnippetsController < Snippets::ApplicationController
def spammable_path
snippet_path(@snippet)
end
+
+ def determine_layout
+ if action_name == 'show' && @snippet.author != current_user
+ 'explore'
+ else
+ 'snippets'
+ end
+ end
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/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 9546f71cd37..4db5745c005 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -9,20 +9,21 @@ class UsersController < ApplicationController
include Gitlab::NoteableMetadata
requires_cross_project_access show: false,
- groups: false,
- projects: false,
- contributed: false,
- snippets: true,
- calendar: false,
- followers: false,
- following: false,
- calendar_activities: true
+ groups: false,
+ projects: false,
+ contributed: false,
+ snippets: true,
+ calendar: false,
+ followers: false,
+ following: false,
+ calendar_activities: true
skip_before_action :authenticate_user!
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists]
- before_action :authorize_read_user_profile!,
- only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following]
+ before_action :authorize_read_user_profile!, only: [
+ :calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following
+ ]
before_action only: [:exists] do
check_rate_limit!(:username_exists, scope: request.ip)
end
@@ -71,7 +72,19 @@ class UsersController < ApplicationController
format.json do
load_events
- pager_json("events/_events", @events.count, events: @events)
+
+ if Feature.enabled?(:profile_tabs_vue, current_user)
+ @events = if user.include_private_contributions?
+ @events
+ else
+ @events.select { |event| event.visible_to_user?(current_user) }
+ end
+
+ render json: ::Profile::EventSerializer.new(current_user: current_user, target_user: user)
+ .represent(@events)
+ else
+ pager_json("events/_events", @events.count, events: @events)
+ end
end
end
end
@@ -169,7 +182,7 @@ class UsersController < ApplicationController
def exists
if Gitlab::CurrentSettings.signup_enabled? || current_user
- render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) }
+ render json: { exists: !!Namespace.without_project_namespaces.find_by_path_or_name(params[:username]) }
else
render json: { error: _('You must be authenticated to access this path.') }, status: :unauthorized
end
@@ -178,7 +191,12 @@ class UsersController < ApplicationController
def follow
followee = current_user.follow(user)
- flash[:alert] = followee.errors.full_messages.join(', ') if followee&.errors&.any?
+ if followee
+ flash[:alert] = followee.errors.full_messages.join(', ') if followee&.errors&.any?
+ else
+ flash[:alert] = s_('Action not allowed.')
+ end
+
redirect_path = referer_path(request) || @user
redirect_to redirect_path
diff --git a/app/controllers/web_ide/remote_ide_controller.rb b/app/controllers/web_ide/remote_ide_controller.rb
index fe70e78b1e5..90652a1b6e2 100644
--- a/app/controllers/web_ide/remote_ide_controller.rb
+++ b/app/controllers/web_ide/remote_ide_controller.rb
@@ -4,7 +4,7 @@ require 'uri'
module WebIde
class RemoteIdeController < ApplicationController
- include VSCodeCDNCSP
+ include WebIdeCSP
rescue_from URI::InvalidComponentError, with: :render_404
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 04043f36426..6a6d0413194 100644
--- a/app/finders/abuse_reports_finder.rb
+++ b/app/finders/abuse_reports_finder.rb
@@ -1,18 +1,93 @@
# frozen_string_literal: true
class AbuseReportsFinder
- attr_reader :params
+ 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
def initialize(params = {})
@params = params
+ @reports = AbuseReport.all
end
def execute
- reports = AbuseReport.all
- reports = reports.by_user(params[:user_id]) if params[:user_id].present?
+ filter_reports
+ sort_reports
+
+ reports.with_users.page(params[:page])
+ end
+
+ private
+
+ def filter_reports
+ filter_by_user_id
+
+ filter_by_user
+ filter_by_reporter
+ filter_by_status
+ filter_by_category
+ end
+
+ def filter_by_status
+ return unless Feature.enabled?(:abuse_reports_list)
+ return unless params[:status].present?
+
+ status = params[:status]
+ status = DEFAULT_STATUS_FILTER unless status.in?(AbuseReport.statuses.keys)
+
+ case status
+ when 'open'
+ @reports = @reports.open
+ when 'closed'
+ @reports = @reports.closed
+ end
+ end
+
+ def filter_by_category
+ return unless params[:category].present?
+
+ @reports = @reports.by_category(params[:category])
+ end
+
+ def filter_by_user
+ return unless params[:user].present?
+
+ user_id = find_user_id(params[:user])
+ return unless user_id
+
+ @reports = @reports.by_user_id(user_id)
+ end
+
+ def filter_by_reporter
+ return unless params[:reporter].present?
+
+ user_id = find_user_id(params[:reporter])
+ return unless user_id
+
+ @reports = @reports.by_reporter_id(user_id)
+ end
+
+ def filter_by_user_id
+ return unless params[:user_id].present?
+
+ @reports = @reports.by_user_id(params[:user_id])
+ end
+
+ def sort_reports
+ if Feature.disabled?(:abuse_reports_list)
+ @reports = @reports.with_order_id_desc
+ return
+ end
+
+ sort_by = params[:sort]
+ sort_by = DEFAULT_SORT unless sort_by.in?(ALLOWED_SORT)
+
+ @reports = @reports.order_by(sort_by)
+ end
- reports.with_order_id_desc
- .with_users
- .page(params[:page])
+ def find_user_id(username)
+ User.by_username(username).pick(:id)
end
end
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 bb91f84de99..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
@@ -98,7 +98,7 @@ module Autocomplete
# rubocop: disable CodeReuse/ActiveRecord
def preload_associations(items)
- ActiveRecord::Associations::Preloader.new.preload(items, :status)
+ ActiveRecord::Associations::Preloader.new(records: items, associations: :status).call
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index a2d1805286d..e52fc510628 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -2,6 +2,8 @@
module Ci
class PipelinesFinder
+ include UpdatedAtFilter
+
attr_reader :project, :pipelines, :params, :current_user
ALLOWED_INDEXED_COLUMNS = %w[id status ref updated_at user_id].freeze
@@ -146,13 +148,6 @@ module Ci
end
# rubocop: enable CodeReuse/ActiveRecord
- 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?
-
- items
- end
-
def by_name(items)
return items unless
Feature.enabled?(:pipeline_name_search, project) &&
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..9ec00245250 100644
--- a/app/finders/clusters/agent_tokens_finder.rb
+++ b/app/finders/clusters/agent_tokens_finder.rb
@@ -11,21 +11,31 @@ 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) }
+ agent_tokens_by_status
end
private
attr_reader :agent, :current_user, :params
- def by_status(agent_tokens)
- params[:status].present? ? agent_tokens.with_status(params[:status]) : agent_tokens
+ def agent_tokens_by_status
+ # If the `status` parameter is set to `active`, we use the `active_agent_tokens` scope
+ # in case this called from GraphQL's AgentTokensResolver. This prevents a repeat query
+ # to the database, because `active_agent_tokens` is already preloaded in the AgentsResolver
+ return agent.active_agent_tokens if active_tokens_only?
+
+ # Else, we use the `agent_tokens` scope combined with `with_status` if necessary
+ params[:status].present? ? agent.agent_tokens.with_status(params[:status]) : agent.agent_tokens
+ end
+
+ def active_tokens_only?
+ params[:status].present? && params[:status].to_sym == :active
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/authorizations/user_access/finder.rb b/app/finders/clusters/agents/authorizations/user_access/finder.rb
new file mode 100644
index 00000000000..bde75fa46cb
--- /dev/null
+++ b/app/finders/clusters/agents/authorizations/user_access/finder.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class Finder
+ def initialize(user, agent: nil, project: nil, preload: true, limit: nil)
+ @user = user
+ @agent = agent
+ @project = project
+ @limit = limit
+ @preload = preload
+ end
+
+ def execute
+ project_authorizations + group_authorizations
+ end
+
+ private
+
+ attr_reader :user, :agent, :project, :preload, :limit
+
+ def project_authorizations
+ authorizations = Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization.for_user(user)
+ authorizations = filter_by_agent(authorizations)
+ authorizations = filter_by_project(authorizations)
+ authorizations = apply_limit(authorizations)
+ authorizations = apply_preload(authorizations)
+ authorizations.to_a
+ end
+
+ def group_authorizations
+ authorizations = Clusters::Agents::Authorizations::UserAccess::GroupAuthorization.for_user(user)
+ authorizations = filter_by_agent(authorizations)
+ authorizations = filter_by_project(authorizations)
+ authorizations = apply_limit(authorizations)
+ authorizations = apply_preload(authorizations)
+ authorizations.to_a
+ end
+
+ def filter_by_agent(authorizations)
+ return authorizations unless agent.present?
+
+ authorizations.for_agent(agent)
+ end
+
+ def filter_by_project(authorizations)
+ return authorizations unless project.present?
+
+ authorizations.for_project(project)
+ end
+
+ def apply_limit(authorizations)
+ return authorizations unless limit.present?
+
+ authorizations.limit(limit)
+ end
+
+ def apply_preload(authorizations)
+ return authorizations unless preload
+
+ authorizations.preloaded
+ 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..99b58fa6954 100644
--- a/app/finders/concerns/finder_with_group_hierarchy.rb
+++ b/app/finders/concerns/finder_with_group_hierarchy.rb
@@ -27,11 +27,7 @@ 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
groups_user_can_read_items(groups).map(&:id)
end
@@ -77,4 +73,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
new file mode 100644
index 00000000000..0e9a3fb5e8c
--- /dev/null
+++ b/app/finders/concerns/updated_at_filter.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module UpdatedAtFilter
+ def by_updated_at(items)
+ 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
+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 21869f6f31d..316dffcb3b2 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -14,6 +14,8 @@
# order_by: String (see ALLOWED_SORT_VALUES constant)
# sort: String (asc | desc)
class DeploymentsFinder
+ include UpdatedAtFilter
+
attr_reader :params
# Warning:
@@ -50,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'
@@ -61,12 +59,20 @@ class DeploymentsFinder
# Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API.
# We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500.
- if (filter_by_updated_at? && !order_by_updated_at?) || (!filter_by_updated_at? && order_by_updated_at?)
- error = InefficientQueryError.new('`updated_at` filter and `updated_at` sorting must be paired')
+ if filter_by_updated_at? && !order_by_updated_at?
+ error = InefficientQueryError.new('`updated_at` filter requires `updated_at` sort')
Gitlab::ErrorTracking.log_exception(error)
- raise error if raise_for_inefficient_updated_at_query?
+ # We are adding a Feature Flag to introduce the breaking change indicated in
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/328500
+ # We are also adding a way to override this flag for special case users that
+ # are running into large volume of errors when the flag is enabled.
+ # These Feature Flags must be removed by 16.1
+ if Feature.enabled?(:deployments_raise_updated_at_inefficient_error) &&
+ Feature.disabled?(:deployments_raise_updated_at_inefficient_error_override, params[:project])
+ raise error
+ end
end
if filter_by_finished_at? && !order_by_finished_at?
@@ -109,13 +115,6 @@ class DeploymentsFinder
items.order(sort_params) # rubocop: disable CodeReuse/ActiveRecord
end
- 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?
-
- items
- end
-
def by_finished_at(items)
items = items.finished_before(params[:finished_before]) if params[:finished_before].present?
items = items.finished_after(params[:finished_after]) if params[:finished_after].present?
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 47ed623b252..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)
@@ -64,6 +67,7 @@ class GroupMembersFinder < UnionFinder
members = members.by_access_level(params[:access_levels])
end
+ members = filter_by_user_type(members)
members = apply_additional_filters(members)
by_created_at(members)
@@ -77,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)
@@ -91,6 +132,12 @@ class GroupMembersFinder < UnionFinder
end
end
+ def filter_by_user_type(members)
+ return members unless params[:user_type] && can_manage_members
+
+ members.filter_by_user_type(params[:user_type])
+ end
+
def apply_additional_filters(members)
# overridden in EE to include additional filtering conditions.
members
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..76463086943
--- /dev/null
+++ b/app/finders/groups/accepting_project_creations_finder.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Groups
+ class AcceptingProjectCreationsFinder
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute
+ 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_imports_finder.rb b/app/finders/groups/accepting_project_imports_finder.rb
new file mode 100644
index 00000000000..55d72edf7bb
--- /dev/null
+++ b/app/finders/groups/accepting_project_imports_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Groups
+ class AcceptingProjectImportsFinder
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute
+ ::Group.from_union(
+ [
+ current_user.manageable_groups,
+ managable_groups_originating_from_group_shares
+ ]
+ )
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def managable_groups_originating_from_group_shares
+ GroupGroupLink
+ .with_owner_or_maintainer_access
+ .groups_accessible_via(
+ current_user.owned_or_maintainers_groups
+ .select(:id)
+ )
+ end
+ end
+end
diff --git a/app/finders/groups/accepting_project_shares_finder.rb b/app/finders/groups/accepting_project_shares_finder.rb
index c4963fcc352..c85e5a0f538 100644
--- a/app/finders/groups/accepting_project_shares_finder.rb
+++ b/app/finders/groups/accepting_project_shares_finder.rb
@@ -25,7 +25,9 @@ module Groups
groups_with_guest_access_plus
end
- groups = groups.search(params[:search]) if params[:search].present?
+ groups = by_hierarchy(groups)
+ groups = by_ignorable(groups)
+ groups = by_search(groups)
sort(groups).with_route
end
@@ -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..536b81b2300 100644
--- a/app/finders/groups/user_groups_finder.rb
+++ b/app/finders/groups/user_groups_finder.rb
@@ -36,9 +36,11 @@ 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
+ elsif permission_scope_import_projects?
+ Groups::AcceptingProjectImportsFinder.new(target_user).execute # rubocop: disable CodeReuse/Finder
else
target_user.groups
end
@@ -51,5 +53,9 @@ module Groups
def permission_scope_transfer_projects?
params[:permission_scope] == :transfer_projects
end
+
+ def permission_scope_import_projects?
+ params[:permission_scope] == :import_projects
+ end
end
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 159836062cb..478a2ba622c 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -43,6 +43,7 @@ class IssuableFinder
include FinderMethods
include CreatedAtFilter
include Gitlab::Utils::StrongMemoize
+ include UpdatedAtFilter
requires_cross_project_access unless: -> { params.project? }
@@ -289,13 +290,6 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def by_updated_at(items)
- items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
- items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
-
- items
- end
-
def by_closed_at(items)
items = items.closed_after(params[:closed_after]) if params[:closed_after].present?
items = items.closed_before(params[:closed_before]) if params[:closed_before].present?
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..3c0714441b2 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class MembersFinder
- RELATIONS = %i[direct inherited descendants invited_groups].freeze
+ RELATIONS = %i[direct inherited descendants invited_groups shared_into_ancestors].freeze
DEFAULT_RELATIONS = %i[direct inherited].freeze
# Params can be any of the following:
@@ -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
@@ -62,15 +58,20 @@ class MembersFinder
def group_union_members(include_relations)
[].tap do |members|
- members << direct_group_members(include_relations.include?(:descendants)) if group
+ members << direct_group_members(include_relations) if group
members << project_invited_groups if include_relations.include?(:invited_groups)
end
end
- def direct_group_members(include_descendants)
+ def direct_group_members(include_relations)
requested_relations = [:inherited, :direct]
- requested_relations << :descendants if include_descendants
- GroupMembersFinder.new(group).execute(include_relations: requested_relations).non_invite.non_minimal_access # rubocop: disable CodeReuse/Finder
+ requested_relations << :descendants if include_relations.include?(:descendants)
+ requested_relations << :shared_from_groups if include_relations.include?(:shared_into_ancestors)
+
+ GroupMembersFinder.new(group, current_user) # rubocop: disable CodeReuse/Finder
+ .execute(include_relations: requested_relations)
+ .non_invite
+ .non_minimal_access
end
def project_invited_groups
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index ffa912afd1e..f1c5d5e08ad 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -36,6 +36,7 @@ class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [
+ :approved,
:approved_by_ids,
:deployed_after,
:deployed_before,
@@ -71,8 +72,9 @@ class MergeRequestsFinder < IssuableFinder
items = by_approvals(items)
items = by_deployments(items)
items = by_reviewer(items)
+ items = by_source_project_id(items)
- by_source_project_id(items)
+ by_approved(items)
end
def filter_negated_items(items)
@@ -183,6 +185,17 @@ class MergeRequestsFinder < IssuableFinder
end
# rubocop: enable CodeReuse/Finder
+ def by_approved(items)
+ approved_param = Gitlab::Utils.to_boolean(params.fetch(:approved, nil))
+ return items if approved_param.nil? || Feature.disabled?(:mr_approved_filter, type: :ops)
+
+ if approved_param
+ items.with_approvals
+ else
+ items.without_approvals
+ end
+ end
+
def by_deployments(items)
env = params[:environment]
before = parse_datetime(params[:deployed_before])
diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb
index e44e96054d3..2c218898dcf 100644
--- a/app/finders/merge_requests_finder/params.rb
+++ b/app/finders/merge_requests_finder/params.rb
@@ -16,8 +16,6 @@ class MergeRequestsFinder
User.find_by_id(params[:reviewer_id])
elsif reviewer_username?
User.find_by_username(params[:reviewer_username])
- else
- nil
end
end
end
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 5fe55e88086..9ffd623338f 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -15,6 +15,7 @@
class MilestonesFinder
include FinderMethods
include TimeFrameFilter
+ include UpdatedAtFilter
attr_reader :params
@@ -30,9 +31,12 @@ class MilestonesFinder
items = by_groups_and_projects(items)
items = by_title(items)
items = by_search_title(items)
+ items = by_search(items)
items = by_state(items)
items = by_timeframe(items)
items = containing_date(items)
+ items = by_updated_at(items)
+ items = by_iids(items)
order(items)
end
@@ -67,6 +71,12 @@ class MilestonesFinder
end
end
+ def by_search(items)
+ return items if params[:search].blank?
+
+ items.search(params[:search])
+ end
+
def by_state(items)
Milestone.filter_by_state(items, params[:state])
end
@@ -84,4 +94,10 @@ class MilestonesFinder
def sort_by_expired_last?(sort_by)
EXPIRED_LAST_SORTS.include?(sort_by)
end
+
+ def by_iids(items)
+ return items unless params[:iids].present? && !params[:include_parent_milestones]
+
+ items.by_iid(params[:iids])
+ end
end
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/conan/package_finder.rb b/app/finders/packages/conan/package_finder.rb
index 210b37635b3..161a3d0d409 100644
--- a/app/finders/packages/conan/package_finder.rb
+++ b/app/finders/packages/conan/package_finder.rb
@@ -3,25 +3,43 @@
module Packages
module Conan
class PackageFinder
- attr_reader :current_user, :query
+ MAX_PACKAGES_COUNT = 500
- def initialize(current_user, params)
+ def initialize(current_user, params, project: nil)
@current_user = current_user
@query = params[:query]
+ @project = project
end
def execute
- packages_for_current_user.installable.with_name_like(query).order_name_asc if query
+ return ::Packages::Package.none unless query
+
+ packages
end
private
+ attr_reader :current_user, :query, :project
+
def packages
- Packages::Package.conan
+ base
+ .conan
+ .installable
+ .preload_conan_metadatum
+ .with_name_like(query)
+ .limit_recent(MAX_PACKAGES_COUNT)
+ end
+
+ def base
+ project ? packages_of_project : packages_for_current_user
+ end
+
+ def packages_of_project
+ project.packages
end
def packages_for_current_user
- packages.for_projects(projects_visible_to_current_user)
+ Packages::Package.for_projects(projects_visible_to_current_user)
end
def projects_visible_to_current_user
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/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb
index babff65cc37..a1a72840236 100644
--- a/app/finders/pending_todos_finder.rb
+++ b/app/finders/pending_todos_finder.rb
@@ -13,24 +13,36 @@
class PendingTodosFinder
attr_reader :users, :params
- # users - The list of users to retrieve the todos for.
+ # users - The list of users to retrieve the todos for. If nil is passed, it won't filter todos based on users
# params - A Hash containing columns and values to use for filtering todos.
- def initialize(users, params = {})
- @users = users
+ def initialize(params = {})
@params = params
+
+ # To prevent N+1 queries when fetching the users of the PendingTodos.
+ @preload_user_association = params.fetch(:preload_user_association, false)
end
def execute
- todos = Todo.for_user(users)
- todos = todos.pending
+ todos = Todo.pending
+ todos = by_users(todos)
todos = by_project(todos)
todos = by_target_id(todos)
todos = by_target_type(todos)
+ todos = by_author_id(todos)
todos = by_discussion(todos)
todos = by_commit_id(todos)
+
+ todos = todos.with_preloaded_user if @preload_user_association
+
by_action(todos)
end
+ def by_users(todos)
+ return todos unless params[:users].present?
+
+ todos.for_user(params[:users])
+ end
+
def by_project(todos)
if (id = params[:project_id])
todos.for_project(id)
@@ -55,6 +67,12 @@ class PendingTodosFinder
end
end
+ def by_author_id(todos)
+ return todos unless params[:author_id]
+
+ todos.for_author(params[:author_id])
+ end
+
def by_commit_id(todos)
if (id = params[:commit_id])
todos.for_commit(id)
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/finders/serverless_domain_finder.rb b/app/finders/serverless_domain_finder.rb
deleted file mode 100644
index 661cd0ca363..00000000000
--- a/app/finders/serverless_domain_finder.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-class ServerlessDomainFinder
- attr_reader :match, :serverless_domain_cluster, :environment
-
- def initialize(uri)
- @match = ::Serverless::Domain::REGEXP.match(uri)
- end
-
- def execute
- return unless serverless?
-
- @serverless_domain_cluster = ::Serverless::DomainCluster.for_uuid(serverless_domain_cluster_uuid)
- return unless serverless_domain_cluster&.knative&.external_ip
-
- @environment = ::Environment.for_id_and_slug(match[:environment_id].to_i(16), match[:environment_slug])
- return unless environment
-
- ::Serverless::Domain.new(
- function_name: match[:function_name],
- serverless_domain_cluster: serverless_domain_cluster,
- environment: environment
- )
- end
-
- def serverless_domain_cluster_uuid
- return unless serverless?
-
- match[:cluster_left] + match[:cluster_middle] + match[:cluster_right]
- end
-
- def serverless?
- !!match
- end
-end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index bf20a2c2c3d..9dd7e508c22 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -179,8 +179,6 @@ class SnippetsFinder < UnionFinder
Snippet::INTERNAL
when 'are_public'
Snippet::PUBLIC
- else
- nil
end
end
diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb
index b82b601541c..c6c5c30cbf7 100644
--- a/app/finders/template_finder.rb
+++ b/app/finders/template_finder.rb
@@ -16,16 +16,27 @@ class TemplateFinder
def build(type, project, params = {})
if type.to_s == 'licenses'
LicenseTemplateFinder.new(project, params) # rubocop: disable CodeReuse/Finder
- else
+ elsif type_allowed?(type)
new(type, project, params)
end
end
def all_template_names(project, type)
- return {} if !VENDORED_TEMPLATES.key?(type.to_s) && type.to_s != 'licenses'
+ return {} unless type_allowed?(type)
build(type, project).template_names
end
+
+ def type_allowed?(type)
+ case type.to_s
+ when 'licenses'
+ true
+ when 'metrics_dashboard_ymls'
+ !Feature.enabled?(:remove_monitor_metrics)
+ else
+ VENDORED_TEMPLATES.key?(type)
+ end
+ end
end
attr_reader :type, :project, :params
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/award.rb b/app/graphql/mutations/achievements/award.rb
new file mode 100644
index 00000000000..b486049594d
--- /dev/null
+++ b/app/graphql/mutations/achievements/award.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class Award < BaseMutation
+ graphql_name 'AchievementsAward'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :user_achievement,
+ ::Types::Achievements::UserAchievementType,
+ null: true,
+ description: 'Achievement award.'
+
+ argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement],
+ required: true,
+ description: 'Global ID of the achievement being awarded.'
+
+ argument :user_id, ::Types::GlobalIDType[::User],
+ required: true,
+ description: 'Global ID of the user being awarded the achievement.'
+
+ authorize :award_achievement
+
+ def resolve(args)
+ achievement = authorized_find!(id: args[:achievement_id])
+
+ recipient_id = args[:user_id].model_id
+ result = ::Achievements::AwardService.new(current_user, achievement.id, recipient_id).execute
+ { user_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/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/revoke.rb b/app/graphql/mutations/achievements/revoke.rb
new file mode 100644
index 00000000000..9d21b1c3741
--- /dev/null
+++ b/app/graphql/mutations/achievements/revoke.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class Revoke < BaseMutation
+ graphql_name 'AchievementsRevoke'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :user_achievement,
+ ::Types::Achievements::UserAchievementType,
+ null: true,
+ description: 'Achievement award.'
+
+ argument :user_achievement_id, ::Types::GlobalIDType[::Achievements::UserAchievement],
+ required: true,
+ description: 'Global ID of the user achievement being revoked.'
+
+ authorize :award_achievement
+
+ def resolve(args)
+ user_achievement = authorized_find!(id: args[:user_achievement_id])
+
+ result = ::Achievements::RevokeService.new(current_user, user_achievement).execute
+ { user_achievement: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement)
+ 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/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index 2eef6bb9db7..771ace5510f 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -45,8 +45,6 @@ module Mutations
namespace = project.namespace
track_usage_event(event, current_user.id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace)
-
Gitlab::Tracking.event(
self.class.to_s,
event,
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/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 5f98b222099..994668b5f8f 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -28,7 +28,7 @@ module Mutations
end
def ready?(**args)
- raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only?
+ raise_resource_not_available_error!(ERROR_MESSAGE) if read_only?
missing_args = self.class.arguments.values
.reject { |arg| arg.accepts?(args.fetch(arg.keyword, :not_given)) }
@@ -39,6 +39,10 @@ module Mutations
true
end
+ def read_only?
+ Gitlab::Database.read_only?
+ end
+
def load_application_object(argument, id, context)
::Gitlab::Graphql::Lazy.new { super }
end
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/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb
index 98b8e9567e7..a7d99d2a496 100644
--- a/app/graphql/mutations/ci/ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/ci_cd_settings_update.rb
@@ -2,9 +2,16 @@
module Mutations
module Ci
- # TODO: Remove in 16.0, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87002
+ # TODO: Remove after 16.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/361801#note_1373963840
class CiCdSettingsUpdate < ProjectCiCdSettingsUpdate
graphql_name 'CiCdSettingsUpdate'
+
+ def ready?(**args)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`remove_cicd_settings_update` feature flag is enabled.' \
+ if Feature.enabled?(:remove_cicd_settings_update)
+
+ super
+ end
end
end
end
diff --git a/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb
new file mode 100644
index 00000000000..53036496de4
--- /dev/null
+++ b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module JobArtifact
+ class BulkDestroy < BaseMutation
+ graphql_name 'BulkDestroyJobArtifacts'
+
+ authorize :destroy_artifacts
+
+ ArtifactId = ::Types::GlobalIDType[::Ci::JobArtifact]
+ ProjectId = ::Types::GlobalIDType[::Project]
+
+ argument :ids, [ArtifactId],
+ required: true,
+ description: 'Global IDs of the job artifacts to destroy.',
+ prepare: ->(global_ids, _ctx) { GitlabSchema.parse_gids(global_ids, expected_type: ::Ci::JobArtifact) }
+
+ argument :project_id, ProjectId,
+ required: true,
+ description: 'Global Project ID of the job artifacts to destroy. Incompatible with projectPath.'
+
+ field :destroyed_count, ::GraphQL::Types::Int,
+ null: true,
+ description: 'Number of job artifacts deleted.'
+
+ field :destroyed_ids, [ArtifactId],
+ null: true,
+ description: 'IDs of job artifacts that were deleted.'
+
+ def find_object(id:)
+ GlobalID::Locator.locate(id)
+ end
+
+ def resolve(**args)
+ ids = args[:ids]
+ project_id = args[:project_id]
+
+ project = authorized_find!(id: project_id)
+
+ if Feature.disabled?(:ci_job_artifact_bulk_destroy, project)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ '`ci_job_artifact_bulk_destroy` feature flag is disabled.'
+ end
+
+ raise Gitlab::Graphql::Errors::ArgumentError, 'IDs array of job artifacts can not be empty' if ids.empty?
+
+ result = ::Ci::JobArtifacts::BulkDeleteByProjectService.new(
+ job_artifact_ids: model_ids_of(ids),
+ current_user: current_user,
+ project: project
+ ).execute
+
+ if result.success?
+ result.payload.slice(:destroyed_count, :destroyed_ids).merge(errors: [])
+ else
+ { errors: result.errors }
+ end
+ end
+
+ private
+
+ def model_ids_of(global_ids)
+ global_ids.filter_map { |gid| gid.model_id.to_i }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb
index 6f0f87b47a1..6071d6750c2 100644
--- a/app/graphql/mutations/ci/job_token_scope/add_project.rb
+++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb
@@ -21,16 +21,28 @@ module Mutations
argument :direction,
::Types::Ci::JobTokenScope::DirectionEnum,
required: false,
- description: 'Direction of access, which defaults to outbound.'
+ deprecated: {
+ reason: 'Outbound job token scope is being removed. This field can now only be set to INBOUND',
+ milestone: '16.0'
+ },
+ description: 'Direction of access, which defaults to INBOUND.'
field :ci_job_token_scope,
Types::Ci::JobTokenScopeType,
null: true,
description: "CI job token's access scope."
- def resolve(project_path:, target_project_path:, direction: :outbound)
+ def resolve(project_path:, target_project_path:, direction: nil)
project = authorized_find!(project_path)
target_project = Project.find_by_full_path(target_project_path)
+ frozen_outbound = project.frozen_outbound_job_token_scopes?
+
+ if direction == :outbound && frozen_outbound
+ raise Gitlab::Graphql::Errors::ArgumentError, 'direction: OUTBOUND scope entries can only be removed. ' \
+ 'Only INBOUND scope can be expanded.'
+ end
+
+ direction ||= frozen_outbound ? :inbound : :outbound
result = ::Ci::JobTokenScope::AddProjectService
.new(project, current_user)
diff --git a/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb b/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb
index 2e4312f0045..d71ef738cab 100644
--- a/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb
+++ b/app/graphql/mutations/ci/pipeline_schedule/take_ownership.rb
@@ -6,7 +6,7 @@ module Mutations
class TakeOwnership < Base
graphql_name 'PipelineScheduleTakeOwnership'
- authorize :take_ownership_pipeline_schedule
+ authorize :admin_pipeline_schedule
field :pipeline_schedule,
Types::Ci::PipelineScheduleType,
diff --git a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
index d214aa46cfc..d4e55fd1792 100644
--- a/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/project_ci_cd_settings_update.rb
@@ -19,6 +19,10 @@ module Mutations
argument :job_token_scope_enabled, GraphQL::Types::Boolean,
required: false,
+ deprecated: {
+ reason: 'Outbound job token scope is being removed. This field can now only be set to false',
+ milestone: '16.0'
+ },
description: 'Indicates CI/CD job tokens generated in this project ' \
'have restricted access to other projects.'
@@ -27,10 +31,6 @@ module Mutations
description: 'Indicates CI/CD job tokens generated in other projects ' \
'have restricted access to this project.'
- argument :opt_in_jwt, GraphQL::Types::Boolean,
- required: false,
- description: 'When disabled, the JSON Web Token is always available in all jobs in the pipeline.'
-
field :ci_cd_settings,
Types::Ci::CiCdSettingType,
null: false,
@@ -39,7 +39,9 @@ module Mutations
def resolve(full_path:, **args)
project = authorized_find!(full_path)
- args.delete(:inbound_job_token_scope_enabled) unless Feature.enabled?(:ci_inbound_job_token_scope, project)
+ if args[:job_token_scope_enabled] && project.frozen_outbound_job_token_scopes?
+ raise Gitlab::Graphql::Errors::ArgumentError, 'job_token_scope_enabled can only be set to false'
+ end
settings = project.ci_cd_settings
settings.update(args)
diff --git a/app/graphql/mutations/ci/runner/common_mutation_arguments.rb b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb
new file mode 100644
index 00000000000..f4fbd0a38c7
--- /dev/null
+++ b/app/graphql/mutations/ci/runner/common_mutation_arguments.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module Runner
+ module CommonMutationArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: 'Description of the runner.'
+
+ argument :maintenance_note, GraphQL::Types::String,
+ required: false,
+ description: 'Runner\'s maintenance notes.'
+
+ argument :maximum_timeout, GraphQL::Types::Int,
+ required: false,
+ description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
+
+ argument :access_level, ::Types::Ci::RunnerAccessLevelEnum,
+ required: false,
+ description: 'Access level of the runner.'
+
+ argument :paused, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Indicates the runner is not allowed to receive jobs.'
+
+ argument :locked, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Indicates the runner is locked.'
+
+ argument :run_untagged, GraphQL::Types::Boolean,
+ required: false,
+ description: 'Indicates the runner is able to run untagged jobs.'
+
+ argument :tag_list, [GraphQL::Types::String],
+ required: false,
+ description: 'Tags associated with the runner.'
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/runner/create.rb b/app/graphql/mutations/ci/runner/create.rb
new file mode 100644
index 00000000000..7eca6c27d10
--- /dev/null
+++ b/app/graphql/mutations/ci/runner/create.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module Runner
+ class Create < BaseMutation
+ graphql_name 'RunnerCreate'
+
+ authorize :create_runner
+
+ 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 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
+
+ parse_gid(**args)
+
+ check_feature_flag(**args)
+
+ super
+ end
+
+ 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
+
+ response = { runner: nil, errors: [] }
+ result = ::Ci::Runners::CreateRunnerService.new(user: current_user, params: args).execute
+
+ if result.success?
+ response[:runner] = result.payload[:runner]
+ else
+ response[:errors] = result.errors
+ end
+
+ 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
+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 4f0bf19f09c..da28397bb71 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -8,53 +8,23 @@ module Mutations
authorize :update_runner
+ include Mutations::Ci::Runner::CommonMutationArguments
+
RunnerID = ::Types::GlobalIDType[::Ci::Runner]
argument :id, RunnerID,
required: true,
description: 'ID of the runner to update.'
- argument :description, GraphQL::Types::String,
- required: false,
- description: 'Description of the runner.'
-
- argument :maintenance_note, GraphQL::Types::String,
- required: false,
- description: 'Runner\'s maintenance notes.'
-
- argument :maximum_timeout, GraphQL::Types::Int,
- required: false,
- description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
-
- argument :access_level, ::Types::Ci::RunnerAccessLevelEnum,
- required: false,
- description: 'Access level of the runner.'
-
argument :active, GraphQL::Types::Boolean,
required: false,
description: 'Indicates the runner is allowed to receive jobs.',
deprecated: { reason: :renamed, replacement: 'paused', milestone: '14.8' }
- argument :paused, GraphQL::Types::Boolean,
- required: false,
- description: 'Indicates the runner is not allowed to receive jobs.'
-
- argument :locked, GraphQL::Types::Boolean,
- required: false,
- description: 'Indicates the runner is locked.'
-
- argument :run_untagged, GraphQL::Types::Boolean,
- required: false,
- description: 'Indicates the runner is able to run untagged jobs.'
-
- 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 } }
+ prepare: ->(global_ids, _ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } }
field :runner,
Types::Ci::RunnerType,
@@ -62,7 +32,7 @@ module Mutations
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)
@@ -75,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 c10e1633350..e717ff4d798 100644
--- a/app/graphql/mutations/clusters/agent_tokens/create.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/create.rb
@@ -40,9 +40,9 @@ module Mutations
result = ::Clusters::AgentTokens::CreateService
.new(
- container: cluster_agent.project,
+ agent: cluster_agent,
current_user: current_user,
- params: args.merge(agent_id: cluster_agent.id)
+ params: args
)
.execute
@@ -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 974db976f1d..c4187746464 100644
--- a/app/graphql/mutations/clusters/agent_tokens/revoke.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/revoke.rb
@@ -16,15 +16,10 @@ module Mutations
def resolve(id:)
token = authorized_find!(id: id)
- token.update(status: token.class.statuses[:revoked])
- { errors: errors_on_object(token) }
- end
+ ::Clusters::AgentTokens::RevokeService.new(token: token, current_user: current_user).execute
- private
-
- def find_object(id:)
- GitlabSchema.find_by_gid(id)
+ { errors: errors_on_object(token) }
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/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb
index e61f66c02a5..4c1ce0c8c5e 100644
--- a/app/graphql/mutations/concerns/mutations/spam_protection.rb
+++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb
@@ -26,8 +26,6 @@ module Mutations
elsif fields[:needs_captcha_response]
fields.delete :spam
raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields)
- else
- nil
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 6738f268e92..f009abdba70 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -36,6 +36,18 @@ module Mutations
argument :milestone_widget, ::Types::WorkItems::Widgets::MilestoneInputType,
required: false,
description: 'Input for milestone widget.'
+ argument :notifications_widget,
+ ::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
new file mode 100644
index 00000000000..67732b70f29
--- /dev/null
+++ b/app/graphql/mutations/design_management/update.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DesignManagement
+ class Update < ::Mutations::BaseMutation
+ graphql_name "DesignManagementUpdate"
+
+ authorize :update_design
+
+ argument :id, ::Types::GlobalIDType[::DesignManagement::Design],
+ required: true,
+ description: "ID of the design to update."
+
+ argument :description, GraphQL::Types::String,
+ required: false,
+ description: copy_field_description(Types::DesignManagement::DesignType, :description)
+
+ field :design, Types::DesignManagement::DesignType,
+ null: false,
+ description: "Updated design."
+
+ def resolve(id:, description:)
+ design = authorized_find!(id: id)
+ design.update(description: description)
+
+ {
+ design: design.reset,
+ errors: errors_on_object(design)
+ }
+ 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/environments/stop.rb b/app/graphql/mutations/environments/stop.rb
new file mode 100644
index 00000000000..b0b87b9b534
--- /dev/null
+++ b/app/graphql/mutations/environments/stop.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ class Stop < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentStop'
+ description 'Stop an environment.'
+
+ authorize :stop_environment
+
+ argument :id,
+ ::Types::GlobalIDType[::Environment],
+ required: true,
+ description: 'Global ID of the environment to stop.'
+
+ argument :force,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Force environment to stop without executing on_stop actions.'
+
+ field :environment,
+ Types::EnvironmentType,
+ null: true,
+ description: 'Environment after attempt to stop.'
+
+ def resolve(id:, **kwargs)
+ environment = authorized_find!(id: id)
+
+ response = ::Environments::StopService.new(environment.project, current_user, kwargs).execute(environment)
+
+ if response.success?
+ { environment: response.payload[:environment], errors: [] }
+ else
+ { environment: response.payload[:environment], errors: response.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
index bb1da9278ff..c29dc98c872 100644
--- a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
+++ b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb
@@ -35,7 +35,7 @@ module Mutations
end
def authorize!(object)
- raise_noteable_not_incident! if object && !object.try(:incident?)
+ raise_noteable_not_incident! if object && !object.try(:incident_type_issue?)
super
end
diff --git a/app/graphql/mutations/issues/bulk_update.rb b/app/graphql/mutations/issues/bulk_update.rb
index 3d80f119079..9c9dd3cf2fc 100644
--- a/app/graphql/mutations/issues/bulk_update.rb
+++ b/app/graphql/mutations/issues/bulk_update.rb
@@ -14,7 +14,8 @@ module Mutations
argument :parent_id, ::Types::GlobalIDType[::IssueParent],
required: true,
- description: 'Global ID of the parent that the bulk update will be scoped to . ' \
+ description: 'Global ID of the parent to which the bulk update will be scoped. ' \
+ 'The parent can be a project **(FREE)** or a group **(PREMIUM)**. ' \
'Example `IssueParentID` are `"gid://gitlab/Project/1"` and `"gid://gitlab/Group/1"`.'
argument :ids, [::Types::GlobalIDType[::Issue]],
@@ -31,6 +32,22 @@ module Mutations
required: false,
description: 'Global ID of the milestone that will be assigned to the issues.'
+ argument :state_event, Types::IssueStateEventEnum,
+ description: 'Close or reopen an issue.',
+ required: false
+
+ argument :add_label_ids, [::Types::GlobalIDType[::Label]],
+ description: 'Global ID array of the labels that will be added to the issues. ',
+ required: false
+
+ argument :remove_label_ids, [::Types::GlobalIDType[::Label]],
+ description: 'Global ID array of the labels that will be removed from the issues. ',
+ required: false
+
+ argument :subscription_event, Types::IssuableSubscriptionEventEnum,
+ description: 'Subscribe to or unsubscribe from issue notifications.',
+ required: false
+
field :updated_issue_count, GraphQL::Types::Int,
null: true,
description: 'Number of issues that were successfully updated.'
@@ -74,7 +91,7 @@ module Mutations
end
def prepared_params(attributes, ids)
- prepared = { issuable_ids: model_ids_from(ids).uniq }
+ prepared = attributes.except(*global_id_arguments).merge(issuable_ids: model_ids_from(ids).uniq)
global_id_arguments.each do |argument|
next unless attributes.key?(argument)
@@ -92,7 +109,7 @@ module Mutations
end
def global_id_arguments
- %i[assignee_ids milestone_id]
+ %i[assignee_ids milestone_id add_label_ids remove_label_ids]
end
def model_ids_from(attributes)
diff --git a/app/graphql/mutations/members/bulk_update_base.rb b/app/graphql/mutations/members/bulk_update_base.rb
new file mode 100644
index 00000000000..1e0208e864d
--- /dev/null
+++ b/app/graphql/mutations/members/bulk_update_base.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Members
+ class BulkUpdateBase < BaseMutation
+ include ::API::Helpers::MembersHelpers
+
+ argument :user_ids,
+ [::Types::GlobalIDType[::User]],
+ required: true,
+ description: 'Global IDs of the members.'
+
+ argument :access_level,
+ ::Types::MemberAccessLevelEnum,
+ required: true,
+ description: 'Access level to update the members to.'
+
+ argument :expires_at,
+ Types::TimeType,
+ required: false,
+ description: 'Date and time the membership expires.'
+
+ MAX_MEMBERS_UPDATE_LIMIT = 50
+ MAX_MEMBERS_UPDATE_ERROR = "Count of members to be updated should be less than #{MAX_MEMBERS_UPDATE_LIMIT}."
+ .freeze
+ INVALID_MEMBERS_ERROR = 'Only access level of direct members can be updated.'
+
+ def resolve(**args)
+ result = ::Members::UpdateService
+ .new(current_user, args.except(:user_ids, source_id_param_name))
+ .execute(@updatable_members)
+
+ {
+ source_members_key => result[:members],
+ errors: Array.wrap(result[:message])
+ }
+ rescue Gitlab::Access::AccessDeniedError
+ {
+ errors: ["Unable to update members, please check user permissions."]
+ }
+ end
+
+ private
+
+ def ready?(**args)
+ source = authorized_find!(source_id: args[source_id_param_name])
+ user_ids = args.fetch(:user_ids, {}).map(&:model_id)
+ @updatable_members = only_direct_members(source, user_ids)
+
+ if @updatable_members.size > MAX_MEMBERS_UPDATE_LIMIT
+ raise Gitlab::Graphql::Errors::InvalidMemberCountError, MAX_MEMBERS_UPDATE_ERROR
+ end
+
+ if @updatable_members.size != user_ids.size
+ raise Gitlab::Graphql::Errors::InvalidMembersError, INVALID_MEMBERS_ERROR
+ end
+
+ super
+ end
+
+ def find_object(source_id:)
+ GitlabSchema.object_from_id(source_id, expected_type: source_type)
+ end
+
+ def only_direct_members(source, user_ids)
+ source_members(source)
+ .with_user(user_ids)
+ .to_a
+ end
+
+ def source_id_param_name
+ "#{source_name}_id".to_sym
+ end
+
+ def source_members_key
+ "#{source_name}_members".to_sym
+ end
+
+ def source_name
+ source_type.name.downcase
+ end
+
+ def source_type
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/members/groups/bulk_update.rb b/app/graphql/mutations/members/groups/bulk_update.rb
index d0b19bd9634..fe3c7521c20 100644
--- a/app/graphql/mutations/members/groups/bulk_update.rb
+++ b/app/graphql/mutations/members/groups/bulk_update.rb
@@ -3,81 +3,22 @@
module Mutations
module Members
module Groups
- class BulkUpdate < ::Mutations::BaseMutation
+ class BulkUpdate < BulkUpdateBase
graphql_name 'GroupMemberBulkUpdate'
-
- include Gitlab::Utils::StrongMemoize
-
authorize :admin_group_member
field :group_members,
- [Types::GroupMemberType],
- null: true,
- description: 'Group members after mutation.'
+ [Types::GroupMemberType],
+ null: true,
+ description: 'Group members after mutation.'
argument :group_id,
- ::Types::GlobalIDType[::Group],
- required: true,
- description: 'Global ID of the group.'
-
- argument :user_ids,
- [::Types::GlobalIDType[::User]],
- required: true,
- description: 'Global IDs of the group members.'
-
- argument :access_level,
- ::Types::MemberAccessLevelEnum,
- required: true,
- description: 'Access level to update the members to.'
-
- argument :expires_at,
- Types::TimeType,
- required: false,
- description: 'Date and time the membership expires.'
-
- MAX_MEMBERS_UPDATE_LIMIT = 50
- MAX_MEMBERS_UPDATE_ERROR = "Count of members to be updated should be less than #{MAX_MEMBERS_UPDATE_LIMIT}."
- INVALID_MEMBERS_ERROR = 'Only access level of direct members can be updated.'
-
- def resolve(group_id:, **args)
- result = ::Members::UpdateService.new(current_user, args.except(:user_ids)).execute(@updatable_group_members)
-
- {
- group_members: result[:members],
- errors: Array.wrap(result[:message])
- }
- rescue Gitlab::Access::AccessDeniedError
- {
- errors: ["Unable to update members, please check user permissions."]
- }
- end
-
- private
-
- def ready?(**args)
- group = authorized_find!(group_id: args[:group_id])
- user_ids = args.fetch(:user_ids, {}).map(&:model_id)
- @updatable_group_members = only_direct_group_members(group, user_ids)
-
- if @updatable_group_members.size > MAX_MEMBERS_UPDATE_LIMIT
- raise Gitlab::Graphql::Errors::InvalidMemberCountError, MAX_MEMBERS_UPDATE_ERROR
- end
-
- if @updatable_group_members.size != user_ids.size
- raise Gitlab::Graphql::Errors::InvalidMembersError, INVALID_MEMBERS_ERROR
- end
-
- super
- end
-
- def find_object(group_id:)
- GitlabSchema.object_from_id(group_id, expected_type: ::Group)
- end
+ ::Types::GlobalIDType[::Group],
+ required: true,
+ description: 'Global ID of the group.'
- def only_direct_group_members(group, user_ids)
- group
- .members
- .with_user(user_ids).to_a
+ def source_type
+ ::Group
end
end
end
diff --git a/app/graphql/mutations/members/projects/bulk_update.rb b/app/graphql/mutations/members/projects/bulk_update.rb
new file mode 100644
index 00000000000..9bf7968670e
--- /dev/null
+++ b/app/graphql/mutations/members/projects/bulk_update.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Members
+ 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,
+ [Types::ProjectMemberType],
+ null: true,
+ description: 'Project members after mutation.'
+
+ argument :project_id,
+ ::Types::GlobalIDType[::Project],
+ required: true,
+ description: 'Global ID of the project.'
+
+ def source_type
+ ::Project
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index 2e7c0c5a2f9..296efa19bb7 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -10,7 +10,7 @@ module Mutations
ANNOTATION_SOURCE_ARGUMENT_ERROR = 'Either a cluster or environment global id is required'
INVALID_ANNOTATION_SOURCE_ERROR = 'Invalid cluster or environment id'
- authorize :create_metrics_dashboard_annotation
+ authorize :admin_metrics_dashboard_annotation
field :annotation,
Types::Metrics::Dashboards::AnnotationType,
@@ -75,6 +75,8 @@ module Mutations
private
def ready?(**args)
+ raise_resource_not_available_error! if Feature.enabled?(:remove_monitor_metrics)
+
# Raise error if both cluster_id and environment_id are present or neither is present
unless args[:cluster_id].present? ^ args[:environment_id].present?
raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR
@@ -83,10 +85,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/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
index e0fadff13d4..32047cda213 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
@@ -7,13 +7,15 @@ module Mutations
class Delete < Base
graphql_name 'DeleteAnnotation'
- authorize :delete_metrics_dashboard_annotation
+ authorize :admin_metrics_dashboard_annotation
argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation],
required: true,
description: 'Global ID of the annotation to delete.'
def resolve(id:)
+ raise_resource_not_available_error! if Feature.enabled?(:remove_monitor_metrics)
+
annotation = authorized_find!(id: id)
result = ::Metrics::Dashboard::Annotations::DeleteService.new(context[:current_user], annotation).execute
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
new file mode 100644
index 00000000000..4520f6388c5
--- /dev/null
+++ b/app/graphql/mutations/projects/sync_fork.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Projects
+ class SyncFork < BaseMutation
+ graphql_name 'ProjectSyncFork'
+
+ include FindsProject
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project to initialize.'
+
+ argument :target_branch, GraphQL::Types::String,
+ required: true,
+ description: 'Ref of the fork to fetch into.'
+
+ field :details, Types::Projects::ForkDetailsType,
+ null: true,
+ description: 'Updated fork details.'
+
+ def resolve(project_path:, target_branch:)
+ 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, ['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)
+
+ return respond(nil, ['This branch of this project cannot be updated from the upstream']) unless details
+
+ enqueue_sync_fork(project, target_branch, details)
+ end
+
+ def enqueue_sync_fork(project, target_branch, details)
+ return respond(details, []) if details.counts[:behind] == 0
+
+ if details.has_conflicts?
+ return respond(details, ['The synchronization cannot happen due to the merge conflict'])
+ end
+
+ return respond(details, ['This service has been called too many times.']) if rate_limit_throttled?(project)
+ return respond(details, ['Another fork sync is already in progress']) unless details.exclusive_lease.try_obtain
+
+ ::Projects::Forks::SyncWorker.perform_async(project.id, current_user.id, target_branch) # rubocop:disable CodeReuse/Worker
+
+ respond(details, [])
+ end
+
+ def rate_limit_throttled?(project)
+ Gitlab::ApplicationRateLimiter.throttled?(:project_fork_sync, scope: [project, current_user])
+ end
+
+ 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_push_to_branch?(target_branch)
+
+ raise_resource_not_available_error!
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/release_asset_links/create.rb b/app/graphql/mutations/release_asset_links/create.rb
index f6445514ce9..bda998764b9 100644
--- a/app/graphql/mutations/release_asset_links/create.rb
+++ b/app/graphql/mutations/release_asset_links/create.rb
@@ -36,13 +36,15 @@ module Mutations
raise_resource_not_available_error!
end
- new_link = release.links.create(link_attrs)
-
- unless new_link.persisted?
- return { link: nil, errors: new_link.errors.full_messages }
+ result = ::Releases::Links::CreateService
+ .new(release, current_user, link_attrs)
+ .execute
+
+ if result.success?
+ { link: result.payload[:link], errors: [] }
+ else
+ { link: nil, errors: result.message }
end
-
- { link: new_link, errors: [] }
end
end
end
diff --git a/app/graphql/mutations/release_asset_links/delete.rb b/app/graphql/mutations/release_asset_links/delete.rb
index 91fa74859f6..891d8e5a4d8 100644
--- a/app/graphql/mutations/release_asset_links/delete.rb
+++ b/app/graphql/mutations/release_asset_links/delete.rb
@@ -19,17 +19,17 @@ module Mutations
description: 'Deleted release asset link.'
def resolve(id:)
- link = authorized_find!(id)
+ link = authorized_find!(id: id)
- unless link.destroy
- return { link: nil, errors: link.errors.full_messages }
- end
-
- { link: link, errors: [] }
- end
+ result = ::Releases::Links::DestroyService
+ .new(link.release, current_user)
+ .execute(link)
- def find_object(id)
- GitlabSchema.find_by_gid(id)
+ if result.success?
+ { link: result.payload[:link], errors: [] }
+ else
+ { link: nil, errors: result.message }
+ 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 f9368927371..3df2d28b88c 100644
--- a/app/graphql/mutations/release_asset_links/update.rb
+++ b/app/graphql/mutations/release_asset_links/update.rb
@@ -44,17 +44,17 @@ module Mutations
end
def resolve(id:, **link_attrs)
- link = authorized_find!(id)
+ link = authorized_find!(id: id)
- unless link.update(link_attrs)
- return { link: nil, errors: link.errors.full_messages }
- end
-
- { link: link, errors: [] }
- end
+ result = ::Releases::Links::UpdateService
+ .new(link.release, current_user, link_attrs)
+ .execute(link)
- def find_object(id)
- GitlabSchema.find_by_gid(id)
+ if result.success?
+ { link: result.payload[:link], errors: [] }
+ else
+ { link: nil, errors: result.message }
+ 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/user_preferences/update.rb b/app/graphql/mutations/user_preferences/update.rb
index c92c6d725b7..16c7b37532c 100644
--- a/app/graphql/mutations/user_preferences/update.rb
+++ b/app/graphql/mutations/user_preferences/update.rb
@@ -8,6 +8,9 @@ module Mutations
argument :issues_sort, Types::IssueSortEnum,
required: false,
description: 'Sort order for issue lists.'
+ argument :visibility_pipeline_id_type, Types::VisibilityPipelineIdTypeEnum,
+ required: false,
+ description: 'Determines whether the pipeline list shows ID or IID.'
field :user_preferences,
Types::UserPreferencesType,
diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb
new file mode 100644
index 00000000000..83bca56d900
--- /dev/null
+++ b/app/graphql/mutations/work_items/convert.rb
@@ -0,0 +1,70 @@
+# 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])
+
+ 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/export.rb b/app/graphql/mutations/work_items/export.rb
new file mode 100644
index 00000000000..a3c7bae5571
--- /dev/null
+++ b/app/graphql/mutations/work_items/export.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module WorkItems
+ class Export < BaseMutation
+ graphql_name 'WorkItemExport'
+
+ include FindsProject
+ include ::WorkItems::SharedFilterArguments
+ include ::SearchArguments
+
+ authorize :export_work_items
+
+ argument :project_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Full project path.'
+
+ argument :selected_fields,
+ [::Types::WorkItems::AvailableExportFieldsEnum],
+ required: false,
+ description: 'List of selected fields to be exported. Omit to export all available fields.'
+
+ field :message, GraphQL::Types::String,
+ null: true,
+ description: 'Export request result message.'
+
+ def resolve(args)
+ project_path = args.delete(:project_path)
+ project = authorized_find!(project_path)
+
+ check_export_available_for!(project)
+
+ # rubocop:disable CodeReuse/Worker
+ IssuableExportCsvWorker.perform_async(:work_item, current_user.id, project.id, args)
+ # rubocop:enable CodeReuse/Worker
+
+ {
+ message: format(_('Your CSV export request has succeeded. The result will be emailed to %{email}.'),
+ email: current_user.notification_email_or_default),
+ errors: []
+ }
+ end
+
+ def check_export_available_for!(project)
+ return if Feature.enabled?(:import_export_work_items_csv, project)
+
+ error = '`import_export_work_items_csv` feature flag is disabled.'
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, error
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index db6af38d82e..3fd0f5aab62 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -17,6 +17,8 @@ module Mutations
description: 'Updated work item.'
def resolve(id:, **attributes)
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/408575')
+
work_item = authorized_find!(id: id)
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
@@ -42,10 +44,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)
@@ -60,21 +58,10 @@ module Mutations
description_param[:description] = description if description && description != original_description
- # Widgets have a set of quick action params that they must process.
- # Map them to widget_params so they can be picked up by widget services.
- work_item.work_item_type.widgets
- .filter { |widget| widget.respond_to?(:quick_action_params) }
- .each do |widget|
- widget.quick_action_params
- .filter { |param_name| command_params.key?(param_name) }
- .each do |param_name|
- widget_params[widget.api_symbol] ||= {}
- widget_params[widget.api_symbol][param_name] = command_params.delete(param_name)
- end
- end
-
- # The command_params not processed by widgets (e.g. title) should be placed in 'attributes'.
- attributes.merge!(command_params || {})
+ parsed_params = work_item.transform_quick_action_params(command_params)
+
+ widget_params.merge!(parsed_params[:widgets])
+ attributes.merge!(parsed_params[:common])
end
end
end
diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index 914be3a72c1..facbf1555fc 100644
--- a/app/graphql/queries/repository/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -27,7 +27,30 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
avatarUrl
webPath
}
- signatureHtml
+ signature {
+ __typename
+ ... on GpgSignature {
+ gpgKeyPrimaryKeyid
+ verificationStatus
+ }
+ ... on X509Signature {
+ verificationStatus
+ x509Certificate {
+ id
+ subject
+ subjectKeyIdentifier
+ x509Issuer {
+ id
+ subject
+ subjectKeyIdentifier
+ }
+ }
+ }
+ ... on SshSignature {
+ verificationStatus
+ keyFingerprintSha256
+ }
+ }
pipelines(ref: $ref, first: 1) {
__typename
edges {
diff --git a/app/graphql/resolvers/achievements/achievements_resolver.rb b/app/graphql/resolvers/achievements/achievements_resolver.rb
new file mode 100644
index 00000000000..eb3f6eaf92e
--- /dev/null
+++ b/app/graphql/resolvers/achievements/achievements_resolver.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Achievements
+ class AchievementsResolver < BaseResolver
+ include LooksAhead
+
+ 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(**args)
+ return ::Achievements::Achievement.none if Feature.disabled?(:achievements, namespace)
+
+ params = {}
+ params[:ids] = args[:ids].map(&:model_id) if args[:ids].present?
+
+ achievements = ::Achievements::AchievementsFinder.new(namespace, params).execute
+ apply_lookahead(achievements)
+ end
+
+ private
+
+ def preloads
+ {
+ user_achievements: [{ user_achievements: [:user, :awarded_by_user, :revoked_by_user] }]
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/achievements/user_achievements_resolver.rb b/app/graphql/resolvers/achievements/user_achievements_resolver.rb
new file mode 100644
index 00000000000..77fb15c3d93
--- /dev/null
+++ b/app/graphql/resolvers/achievements/user_achievements_resolver.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Achievements
+ class UserAchievementsResolver < BaseResolver
+ include LooksAhead
+
+ type ::Types::Achievements::UserAchievementType.connection_type, null: true
+
+ def resolve_with_lookahead
+ user_achievements = object.user_achievements.not_revoked.order_by_id_asc
+
+ apply_lookahead(user_achievements)
+ end
+
+ private
+
+ def unconditional_includes
+ [
+ { achievement: [:namespace] }
+ ]
+ end
+
+ def preloads
+ {
+ user: [:user],
+ awarded_by_user: [:awarded_by_user],
+ revoked_by_user: [:revoked_by_user]
+ }
+ end
+ end
+ end
+end
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..82d38ff89d9
--- /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: 'Timestamp marking the start date and time.'
+
+ argument :to, Types::TimeType,
+ required: true,
+ description: 'Timestamp marking the end date and time.'
+
+ 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
new file mode 100644
index 00000000000..8128023aecb
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class BaseIssueResolver < BaseCountResolver
+ type Types::Analytics::CycleAnalytics::MetricType, null: true
+
+ argument :assignee_usernames, [GraphQL::Types::String],
+ required: false,
+ description: 'Usernames of users assigned to the issue.'
+
+ argument :author_username, GraphQL::Types::String,
+ required: false,
+ description: 'Username of the author of the issue.'
+
+ argument :milestone_title, GraphQL::Types::String,
+ required: false,
+ description: 'Milestone applied to the issue.'
+
+ argument :label_names, [GraphQL::Types::String],
+ required: false,
+ description: 'Labels applied to the issue.'
+
+ def finder_params
+ { project_id: object.project.id }
+ end
+
+ # :project level: no customization, returning the original resolver
+ # :group level: add the project_ids argument
+ def self.[](context = :project)
+ case context
+ when :project
+ self
+ when :group
+ Class.new(self) do
+ argument :project_ids, [GraphQL::Types::ID],
+ required: false,
+ description: 'Project IDs within the group hierarchy.'
+
+ define_method :finder_params do
+ { group_id: object.id, include_subgroups: true }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+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
new file mode 100644
index 00000000000..51a1afdd5ab
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseCountResolver)
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class DeploymentCountResolver < BaseCountResolver
+ def resolve(**args)
+ value = count(args)
+ {
+ value: value,
+ title: n_('Deploy', 'Deploys', value.to_i),
+ identifier: 'deploys',
+ links: []
+ }
+ end
+
+ private
+
+ def count(args)
+ finder = DeploymentsFinder.new({
+ finished_after: args[:from],
+ finished_before: args[:to],
+ project: object.project,
+ status: :success,
+ order_by: :finished_at
+ })
+
+ finder.execute.count
+ end
+
+ # :project level: no customization, returning the original resolver
+ # :group level: add the project_ids argument
+ def self.[](context = :project)
+ case context
+ when :project
+ self
+ when :group
+ Class.new(self) do
+ argument :project_ids, [GraphQL::Types::ID],
+ required: false,
+ description: 'Project IDs within the group hierarchy.'
+ end
+
+ end
+ end
+ end
+ 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/analytics/cycle_analytics/issue_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb
new file mode 100644
index 00000000000..fd20800ee16
--- /dev/null
+++ b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseIssueResolver)
+module Resolvers
+ module Analytics
+ module CycleAnalytics
+ class IssueCountResolver < BaseIssueResolver
+ def resolve(**args)
+ value = IssuesFinder
+ .new(current_user, process_params(args))
+ .execute
+ .count
+
+ {
+ value: value,
+ title: n_('New Issue', 'New Issues', value),
+ identifier: 'issues',
+ links: []
+ }
+ end
+
+ private
+
+ def process_params(params)
+ params[:assignee_username] = params.delete(:assignee_usernames) if params[:assignee_usernames]
+ params[:label_name] = params.delete(:label_names) if params[:label_names]
+ params[:created_after] = params.delete(:from)
+ params[:created_before] = params.delete(:to)
+ params[:projects] = params[:project_ids] if params[:project_ids]
+
+ params.merge(finder_params)
+ end
+ end
+ end
+ end
+end
+# rubocop:enable Graphql/ResolverType
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..5d0193e0e1c 100644
--- a/app/graphql/resolvers/ci/all_jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/all_jobs_resolver.rb
@@ -3,14 +3,37 @@
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],
+ kind: [:metadata],
+ retryable: [:metadata],
+ 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/inherited_variables_resolver.rb b/app/graphql/resolvers/ci/inherited_variables_resolver.rb
new file mode 100644
index 00000000000..01f966942a4
--- /dev/null
+++ b/app/graphql/resolvers/ci/inherited_variables_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class InheritedVariablesResolver < BaseResolver
+ type Types::Ci::ProjectVariableType.connection_type, null: true
+
+ def resolve
+ object.group&.self_and_ancestors&.flat_map(&:variables) || []
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index 31cc350f331..95816a1ac1a 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -23,12 +23,17 @@ module Resolvers
required: false,
description: 'Filter jobs by when they are executed.'
- def resolve(statuses: nil, security_report_types: [], retried: nil, when_executed: nil)
+ argument :job_kind, ::Types::Ci::JobKindEnum,
+ required: false,
+ description: 'Filter jobs by kind.'
+
+ def resolve(statuses: nil, security_report_types: [], retried: nil, when_executed: nil, job_kind: nil)
jobs = init_collection(security_report_types)
jobs = jobs.with_status(statuses) if statuses.present?
jobs = jobs.retried if retried
jobs = jobs.with_when_executed(when_executed) if when_executed.present?
jobs = jobs.latest if retried == false
+ jobs = jobs.with_type(job_kind) if job_kind
jobs
end
diff --git a/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb b/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb
index 35d30827561..561c61e3b27 100644
--- a/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb
+++ b/app/graphql/resolvers/ci/pipeline_job_artifacts_resolver.rb
@@ -15,7 +15,7 @@ module Resolvers
def find_job_artifacts
BatchLoader::GraphQL.for(pipeline).batch do |pipelines, loader|
- ActiveRecord::Associations::Preloader.new.preload(pipelines, :job_artifacts) # rubocop: disable CodeReuse/ActiveRecord
+ ActiveRecord::Associations::Preloader.new(records: pipelines, associations: :job_artifacts).call # rubocop: disable CodeReuse/ActiveRecord
pipelines.each { |pl| loader.call(pl, pl.job_artifacts) }
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 2a2d63f85de..c5037965e20 100644
--- a/app/graphql/resolvers/ci/runner_projects_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb
@@ -15,10 +15,10 @@ module Resolvers
argument :sort, GraphQL::Types::String,
required: false,
- default_value: 'id_asc', # TODO: Remove in %16.0 and move :sort to ProjectSearchArguments, see https://gitlab.com/gitlab-org/gitlab/-/issues/372117
+ default_value: 'id_asc', # TODO: Remove in %17.0 and move :sort to ProjectSearchArguments, see https://gitlab.com/gitlab-org/gitlab/-/issues/372117
deprecated: {
- reason: 'Default sort order will change in 16.0. ' \
- 'Specify `"id_asc"` if query results\' order is important',
+ reason: 'Default sort order will change in GitLab 17.0. ' \
+ 'Specify `"id_asc"` if you require the query results to be ordered by ascending IDs',
milestone: '15.4'
},
description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \
@@ -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
@@ -68,9 +73,9 @@ module Resolvers
def preloads
super.merge({
- full_path: [:route, { namespace: [:route] }],
- web_url: [:route, { namespace: [:route] }]
- })
+ full_path: [:route, { namespace: [:route] }],
+ web_url: [:route, { namespace: [:route] }]
+ })
end
end
end
diff --git a/app/graphql/resolvers/ci/runner_resolver.rb b/app/graphql/resolvers/ci/runner_resolver.rb
index ca94e28b2e9..4250b069d20 100644
--- a/app/graphql/resolvers/ci/runner_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_resolver.rb
@@ -22,11 +22,15 @@ module Resolvers
def find_runner(id:)
runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id.to_i
- preload_tag_list = lookahead.selects?(:tag_list)
+ key = {
+ preload_tag_list: lookahead.selects?(:tag_list),
+ preload_creator: lookahead.selects?(:created_by)
+ }
- BatchLoader::GraphQL.for(runner_id).batch(key: { preload_tag_list: preload_tag_list }) do |ids, loader, batch|
+ BatchLoader::GraphQL.for(runner_id).batch(key: key) do |ids, loader, batch|
results = ::Ci::Runner.id_in(ids)
results = results.with_tags if batch[:key][:preload_tag_list]
+ results = results.with_creator if batch[:key][:preload_creator]
results.each { |record| loader.call(record.id, record) }
end
diff --git a/app/graphql/resolvers/ci/runner_status_resolver.rb b/app/graphql/resolvers/ci/runner_status_resolver.rb
index 447ab306ba7..c8f47b16f75 100644
--- a/app/graphql/resolvers/ci/runner_status_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_status_resolver.rb
@@ -12,17 +12,16 @@ module Resolvers
argument :legacy_mode,
type: GraphQL::Types::String,
- default_value: '14.5',
+ default_value: nil,
required: false,
- description: 'Compatibility mode. A null value turns off compatibility mode.',
+ description: 'No-op, left for compatibility.',
deprecated: {
- reason: 'Will be removed in 17.0. In GitLab 16.0 and later, ' \
- 'the field will act as if `legacyMode` is null',
+ reason: 'Will be removed in 17.0',
milestone: '15.0'
}
- def resolve(legacy_mode:, **args)
- runner.status(legacy_mode)
+ def resolve(**args)
+ runner.status
end
end
end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index b52a4cc0ab4..735e38c1a5c 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -61,9 +61,7 @@ module Resolvers
upgrade_status: params[:upgrade_status],
search: params[:search],
sort: params[:sort]&.to_s,
- preload: {
- tag_name: node_selection&.selects?(:tag_list)
- }
+ preload: false # we'll handle preloading ourselves
}.compact
.merge(parent_param)
end
@@ -79,6 +77,31 @@ module Resolvers
def parent
object.respond_to?(:sync) ? object.sync : object
end
+
+ def preloads
+ super.merge({
+ created_by: [:creator],
+ tag_list: [:tags]
+ })
+ end
+
+ def nested_preloads
+ {
+ created_by: {
+ creator: {
+ full_path: [:route],
+ web_path: [:route],
+ web_url: [:route]
+ }
+ },
+ owner_project: {
+ owner_project: {
+ full_path: [:route, { namespace: [:route] }],
+ web_url: [:route, { namespace: [:route] }]
+ }
+ }
+ }
+ end
end
end
end
diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
index b7355a1752e..0b9422db2a9 100644
--- a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
+++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb
@@ -9,12 +9,8 @@ module Resolvers
delegate :project, to: :agent
- argument :status, Types::Clusters::AgentTokenStatusEnum,
- required: false,
- description: 'Status of the token.'
-
- def resolve(**args)
- ::Clusters::AgentTokensFinder.new(agent, current_user, args).execute
+ def resolve(**_args)
+ ::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute
end
end
end
diff --git a/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb b/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb
new file mode 100644
index 00000000000..c36338439e6
--- /dev/null
+++ b/app/graphql/resolvers/clusters/agents/authorizations/ci_access_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Clusters
+ module Agents
+ module Authorizations
+ class CiAccessResolver < BaseResolver
+ type Types::Clusters::Agents::Authorizations::CiAccessType, null: true
+
+ alias_method :project, :object
+
+ def resolve(*)
+ ::Clusters::Agents::Authorizations::CiAccess::Finder.new(project).execute
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/clusters/agents/authorizations/user_access_resolver.rb b/app/graphql/resolvers/clusters/agents/authorizations/user_access_resolver.rb
new file mode 100644
index 00000000000..280db570aa6
--- /dev/null
+++ b/app/graphql/resolvers/clusters/agents/authorizations/user_access_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Clusters
+ module Agents
+ module Authorizations
+ class UserAccessResolver < BaseResolver
+ type Types::Clusters::Agents::Authorizations::UserAccessType, null: true
+
+ alias_method :project, :object
+
+ def resolve(*)
+ ::Clusters::Agents::Authorizations::UserAccess::Finder.new(current_user, project: project).execute
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/clusters/agents_resolver.rb b/app/graphql/resolvers/clusters/agents_resolver.rb
index 0b9eb361dbd..f138ad2b510 100644
--- a/app/graphql/resolvers/clusters/agents_resolver.rb
+++ b/app/graphql/resolvers/clusters/agents_resolver.rb
@@ -28,7 +28,7 @@ module Resolvers
def preloads
{
activity_events: { activity_events: [:user, agent_token: :agent] },
- tokens: :agent_tokens
+ tokens: :active_agent_tokens
}
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/concerns/time_frame_arguments.rb b/app/graphql/resolvers/concerns/time_frame_arguments.rb
index 87b7a96045c..c26898bb2f1 100644
--- a/app/graphql/resolvers/concerns/time_frame_arguments.rb
+++ b/app/graphql/resolvers/concerns/time_frame_arguments.rb
@@ -3,51 +3,15 @@
module TimeFrameArguments
extend ActiveSupport::Concern
- OVERLAPPING_TIMEFRAME_DESC = 'List items overlapping a time frame defined by startDate..endDate (if one date is provided, both must be present)'
-
included do
- argument :start_date, Types::TimeType,
- required: false,
- description: OVERLAPPING_TIMEFRAME_DESC,
- deprecated: { reason: 'Use timeframe.start', milestone: '13.5' }
-
- argument :end_date, Types::TimeType,
- required: false,
- description: OVERLAPPING_TIMEFRAME_DESC,
- deprecated: { reason: 'Use timeframe.end', milestone: '13.5' }
-
argument :timeframe, Types::TimeframeInputType,
required: false,
description: 'List items overlapping the given timeframe.'
end
- # TODO: remove when the start_date and end_date arguments are removed
- def validate_timeframe_params!(args)
- return unless %i[start_date end_date timeframe].any? { |k| args[k].present? }
-
- # the timeframe is passed in as a TimeframeInputType
- timeframe = args[:timeframe].to_h if args[:timeframe]
- return if timeframe && %i[start_date end_date].all? { |k| args[k].nil? }
-
- error_message =
- if timeframe.present?
- "startDate and endDate are deprecated in favor of timeframe. Please use only timeframe."
- elsif args[:start_date].nil? || args[:end_date].nil?
- "Both startDate and endDate must be present."
- elsif args[:start_date] > args[:end_date]
- "startDate is after endDate"
- end
-
- if error_message
- raise Gitlab::Graphql::Errors::ArgumentError, error_message
- end
- end
-
def transform_timeframe_parameters(args)
- if args[:timeframe]
- args[:timeframe].to_h.transform_keys { |k| :"#{k}_date" }
- else
- args.slice(:start_date, :end_date)
- end
+ return {} unless args[:timeframe]
+
+ args[:timeframe].to_h.transform_keys { |k| :"#{k}_date" }
end
end
diff --git a/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb
new file mode 100644
index 00000000000..ecb105a64d0
--- /dev/null
+++ b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module SharedFilterArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :author_username,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Filter work items by author username.',
+ alpha: { milestone: '15.9' }
+ argument :iids,
+ [GraphQL::Types::String],
+ required: false,
+ description: 'List of IIDs of work items. For example, `["1", "2"]`.'
+ argument :state,
+ Types::IssuableStateEnum,
+ required: false,
+ description: 'Current state of the work item.'
+ argument :types,
+ [Types::IssueTypeEnum],
+ as: :issue_types,
+ description: 'Filter work items by the given work item types.',
+ required: false
+ end
+ end
+end
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..b88f62b984a 100644
--- a/app/graphql/resolvers/group_labels_resolver.rb
+++ b/app/graphql/resolvers/group_labels_resolver.rb
@@ -13,5 +13,9 @@ module Resolvers
required: false,
description: 'Include only group level labels.',
default_value: false
+
+ before_connection_authorization do |nodes, current_user|
+ Preloaders::LabelsPreloader.new(nodes, current_user).preload_all
+ end
end
end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index bbf45efa33e..17e3e159a5b 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -2,14 +2,19 @@
module Resolvers
class IssuesResolver < Issues::BaseResolver
+ extend ::Gitlab::Utils::Override
prepend ::Issues::LookAheadPreloads
include ::Issues::SortArguments
- NON_FILTER_ARGUMENTS = %i[sort lookahead].freeze
+ NON_FILTER_ARGUMENTS = %i[sort lookahead include_archived].freeze
+ argument :include_archived, GraphQL::Types::Boolean,
+ required: false,
+ default_value: false,
+ description: 'Whether to include issues from archived projects. Defaults to `false`.'
argument :state, Types::IssuableStateEnum,
- required: false,
- description: 'Current state of this issue.'
+ required: false,
+ description: 'Current state of this issue.'
# see app/graphql/types/issue_connection.rb
type 'Types::IssueConnection', null: true
@@ -44,6 +49,13 @@ module Resolvers
private
+ override :prepare_finder_params
+ def prepare_finder_params(args)
+ super.tap do |prepared|
+ prepared[:non_archived] = !prepared.delete(:include_archived)
+ end
+ end
+
def filter_provided?(args)
args.except(*NON_FILTER_ARGUMENTS).values.any?(&:present?)
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..10f5917d84b 100644
--- a/app/graphql/resolvers/labels_resolver.rb
+++ b/app/graphql/resolvers/labels_resolver.rb
@@ -17,6 +17,10 @@ module Resolvers
description: 'Include labels from ancestor groups.',
default_value: false
+ before_connection_authorization do |nodes, current_user|
+ Preloaders::LabelsPreloader.new(nodes, current_user).preload_all
+ end
+
def resolve(**args)
return Label.none if parent.nil?
@@ -24,6 +28,11 @@ 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.
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..0cdff272ee5 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -55,6 +55,13 @@ module Resolvers
required: false,
description: 'Limit result to draft merge requests.'
+ argument :approved, GraphQL::Types::Boolean,
+ required: false,
+ description: <<~DESC
+ Limit results to approved merge requests.
+ Available only when the feature flag `mr_approved_filter` is enabled.
+ DESC
+
argument :created_after, Types::TimeType,
required: false,
description: 'Merge requests created after this timestamp.'
diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb
index d2be9fcdd89..5abad0de539 100644
--- a/app/graphql/resolvers/metrics/dashboard_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboard_resolver.rb
@@ -15,6 +15,7 @@ module Resolvers
alias_method :environment, :object
def resolve(path:)
+ return if Feature.enabled?(:remove_monitor_metrics)
return unless environment
::PerformanceMonitoring::PrometheusDashboard.find_for(path: path, **service_params)
diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
index 9d6b0486c04..aad9bbebafb 100644
--- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
@@ -17,6 +17,7 @@ module Resolvers
alias_method :dashboard, :object
def resolve(**args)
+ return if Feature.enabled?(:remove_monitor_metrics)
return [] unless dashboard
::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 25ff783b408..563c6594665 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -40,8 +40,6 @@ module Resolvers
NON_STABLE_CURSOR_SORTS = %i[expired_last_due_date_asc expired_last_due_date_desc].freeze
def resolve_with_lookahead(**args)
- validate_timeframe_params!(args)
-
milestones = apply_lookahead(MilestonesFinder.new(milestones_finder_params(args)).execute)
if non_stable_cursor_sort?(args[:sort])
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/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb
index 66c020a0c14..6a240541341 100644
--- a/app/graphql/resolvers/project_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/project_merge_requests_resolver.rb
@@ -22,7 +22,13 @@ module Resolvers
def only_count_is_selected_with_merged_at_filter?(args)
return unless lookahead
- argument_names = args.compact.except(:lookahead, :sort, :merged_before, :merged_after).keys
+ # Filter out all elements with blank values. If any of the values are not
+ # scalars, e.g. hashes or array, filter blank values from them and remove
+ # them if the resulting collection is empty.
+ argument_names = args.except(:lookahead, :sort, :merged_before, :merged_after).filter_map do |key, value|
+ value = value.to_hash.compact if value.respond_to?(:to_hash)
+ key if value.present?
+ end
# no extra filtering arguments are provided
return unless argument_names.empty?
diff --git a/app/graphql/resolvers/projects/commit_references_resolver.rb b/app/graphql/resolvers/projects/commit_references_resolver.rb
new file mode 100644
index 00000000000..ca25bad468c
--- /dev/null
+++ b/app/graphql/resolvers/projects/commit_references_resolver.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class CommitReferencesResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ argument :commit_sha, GraphQL::Types::String,
+ required: true,
+ description: 'Project commit SHA identifier. For example, `287774414568010855642518513f085491644061`.'
+
+ authorize :read_commit
+
+ alias_method :project, :object
+
+ calls_gitaly!
+
+ type ::Types::CommitReferencesType, null: true
+
+ def resolve(commit_sha:)
+ authorized_find!(oid: commit_sha)
+ end
+
+ def find_object(oid:)
+ project.repository.commit(oid)
+ 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 fcc13a1bc1e..620ce395915 100644
--- a/app/graphql/resolvers/projects/fork_details_resolver.rb
+++ b/app/graphql/resolvers/projects/fork_details_resolver.rb
@@ -13,8 +13,15 @@ module Resolvers
def resolve(**args)
return unless project.forked?
+ return unless authorized_fork_source?
- ::Projects::Forks::DivergenceCounts.new(project, args[:ref]).counts
+ ::Projects::Forks::Details.new(project, args[:ref])
+ end
+
+ private
+
+ def authorized_fork_source?
+ Ability.allowed?(current_user, :read_code, project.fork_source)
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/user_resolver.rb b/app/graphql/resolvers/user_resolver.rb
index f0fd60e9cbb..ddced5ee859 100644
--- a/app/graphql/resolvers/user_resolver.rb
+++ b/app/graphql/resolvers/user_resolver.rb
@@ -39,7 +39,7 @@ module Resolvers
def batch_load(username)
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
- loader.call(user.username, user)
+ loader.call(username, user)
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 0c9aac80274..14eec4f696a 100644
--- a/app/graphql/resolvers/work_items_resolver.rb
+++ b/app/graphql/resolvers/work_items_resolver.rb
@@ -4,30 +4,19 @@ module Resolvers
class WorkItemsResolver < BaseResolver
include SearchArguments
include LooksAhead
+ include ::WorkItems::SharedFilterArguments
- type Types::WorkItemType.connection_type, null: true
+ argument :iid,
+ GraphQL::Types::String,
+ required: false,
+ description: 'IID of the work item. For example, "1".'
+ argument :sort,
+ Types::WorkItemSortEnum,
+ description: 'Sort work items by criteria.',
+ required: false,
+ default_value: :created_desc
- argument :author_username, GraphQL::Types::String,
- required: false,
- description: 'Filter work items by author username.',
- alpha: { milestone: '15.9' }
- argument :iid, GraphQL::Types::String,
- required: false,
- description: 'IID of the issue. For example, "1".'
- argument :iids, [GraphQL::Types::String],
- required: false,
- description: 'List of IIDs of work items. For example, `["1", "2"]`.'
- argument :sort, Types::WorkItemSortEnum,
- description: 'Sort work items by this criteria.',
- required: false,
- default_value: :created_desc
- argument :state, Types::IssuableStateEnum,
- required: false,
- description: 'Current state of this work item.'
- argument :types, [Types::IssueTypeEnum],
- as: :issue_types,
- description: 'Filter work items by the given work item types.',
- required: false
+ type Types::WorkItemType.connection_type, null: true
def resolve_with_lookahead(**args)
return WorkItem.none if resource_parent.nil?
@@ -42,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
@@ -66,7 +55,9 @@ module Resolvers
parent: :work_item_parent,
children: { work_item_children_by_relative_position: [:author, { project: :project_feature }] },
labels: :labels,
- milestone: { milestone: [:project, :group] }
+ milestone: { milestone: [:project, :group] },
+ 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/achievement_type.rb b/app/graphql/types/achievements/achievement_type.rb
index 67cc9778797..ec558981465 100644
--- a/app/graphql/types/achievements/achievement_type.rb
+++ b/app/graphql/types/achievements/achievement_type.rb
@@ -14,7 +14,6 @@ module Types
field :namespace,
::Types::NamespaceType,
- null: false,
description: 'Namespace of the achievement.'
field :name,
@@ -42,6 +41,14 @@ module Types
null: false,
description: 'Timestamp the achievement was last updated.'
+ field :user_achievements,
+ Types::Achievements::UserAchievementType.connection_type,
+ null: true,
+ alpha: { milestone: '15.10' },
+ description: "Recipients for the achievement.",
+ extras: [:lookahead],
+ resolver: ::Resolvers::Achievements::UserAchievementsResolver
+
def avatar_url
object.avatar_url(only_path: false)
end
diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb
new file mode 100644
index 00000000000..bf161d2f1e5
--- /dev/null
+++ b/app/graphql/types/achievements/user_achievement_type.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Types
+ module Achievements
+ class UserAchievementType < BaseObject
+ graphql_name 'UserAchievement'
+
+ authorize :read_user_achievement
+
+ field :id,
+ ::Types::GlobalIDType[::Achievements::UserAchievement],
+ null: false,
+ description: 'ID of the user achievement.'
+
+ field :achievement,
+ ::Types::Achievements::AchievementType,
+ null: false,
+ description: 'Achievement awarded.'
+
+ field :user,
+ ::Types::UserType,
+ null: false,
+ description: 'Achievement recipient.'
+
+ field :awarded_by_user,
+ ::Types::UserType,
+ null: false,
+ description: 'Awarded by.'
+
+ field :revoked_by_user,
+ ::Types::UserType,
+ null: true,
+ description: 'Revoked by.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the achievement was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the achievement was last updated.'
+
+ field :revoked_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp the achievement was revoked.'
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index a13453f9194..5784c7a4872 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -114,7 +114,9 @@ module Types
field :metrics_dashboard_url,
GraphQL::Types::String,
null: true,
- description: 'URL for metrics embed for the alert.'
+ description: 'URL for metrics embed for the alert.',
+ deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0',
+ milestone: '16.0' }
field :runbook,
GraphQL::Types::String,
@@ -145,6 +147,12 @@ module Types
def notes
object.ordered_notes
end
+
+ def metrics_dashboard_url
+ return if Feature.enabled?(:remove_monitor_metrics)
+
+ object.metrics_dashboard_url
+ end
end
end
end
diff --git a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
new file mode 100644
index 00000000000..c9a28767e11
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+ module Analytics
+ module CycleAnalytics
+ module FlowMetrics
+ def self.[](context = :project)
+ Class.new(BaseObject) do
+ graphql_name "#{context.capitalize}ValueStreamAnalyticsFlowMetrics"
+ description 'Exposes aggregated value stream flow metrics'
+
+ field :issue_count,
+ Types::Analytics::CycleAnalytics::MetricType,
+ null: true,
+ description: 'Number of issues opened in the given period.',
+ resolver: Resolvers::Analytics::CycleAnalytics::IssueCountResolver[context]
+ field :deployment_count,
+ Types::Analytics::CycleAnalytics::MetricType,
+ null: true,
+ description: 'Number of production deployments in the given period.',
+ resolver: Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver[context]
+ end
+ end
+ end
+ end
+ end
+end
+
+mod = Types::Analytics::CycleAnalytics::FlowMetrics
+mod.prepend_mod_with('Types::Analytics::CycleAnalytics::FlowMetrics')
diff --git a/app/graphql/types/analytics/cycle_analytics/link_type.rb b/app/graphql/types/analytics/cycle_analytics/link_type.rb
new file mode 100644
index 00000000000..3db6b58ac55
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/link_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Analytics
+ module CycleAnalytics
+ # rubocop: disable Graphql/AuthorizeTypes
+ class LinkType < BaseObject
+ graphql_name 'ValueStreamMetricLinkType'
+
+ field :name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Name of the link group.'
+
+ field :label,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Label for the link.'
+
+ field :url,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Drill-down URL.'
+
+ field :docs_link,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Link to the metric documentation.'
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/analytics/cycle_analytics/metric_type.rb b/app/graphql/types/analytics/cycle_analytics/metric_type.rb
new file mode 100644
index 00000000000..3f1a239019f
--- /dev/null
+++ b/app/graphql/types/analytics/cycle_analytics/metric_type.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Types
+ module Analytics
+ module CycleAnalytics
+ # rubocop: disable Graphql/AuthorizeTypes
+ class MetricType < BaseObject
+ graphql_name 'ValueStreamAnalyticsMetric'
+ description ''
+
+ field :value,
+ GraphQL::Types::Float,
+ null: true,
+ description: 'Value for the metric.'
+
+ field :identifier,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Identifier for the metric.'
+
+ field :unit,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Unit of measurement.'
+
+ field :title,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Title for the metric.'
+
+ field :links,
+ [LinkType],
+ null: false,
+ description: 'Optional links for drilling down.'
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 2352a21bd87..20661da8d94 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -55,7 +55,7 @@ module Types
# board lists have a data dependency on label - so we batch load them here
def title
BatchLoader::GraphQL.for(object).batch do |lists, callback|
- ActiveRecord::Associations::Preloader.new.preload(lists, :label) # rubocop: disable CodeReuse/ActiveRecord
+ ActiveRecord::Associations::Preloader.new(records: lists, associations: :label).call # rubocop: disable CodeReuse/ActiveRecord
# all list titles are preloaded at this point
lists.each { |list| callback.call(list, list.title) }
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/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
index dd6647b749d..f7ef94f58c0 100644
--- a/app/graphql/types/ci/ci_cd_setting_type.rb
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -30,11 +30,7 @@ module Types
field :merge_trains_enabled, GraphQL::Types::Boolean, null: true,
description: 'Whether merge trains are enabled.',
method: :merge_trains_enabled?
- field :opt_in_jwt,
- GraphQL::Types::Boolean,
- null: true,
- description: 'When disabled, the JSON Web Token is always available in all jobs in the pipeline.',
- method: :opt_in_jwt?
+
field :project, Types::ProjectType, null: true,
description: 'Project the CI/CD settings belong to.'
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/inherited_ci_variable_type.rb b/app/graphql/types/ci/inherited_ci_variable_type.rb
new file mode 100644
index 00000000000..2d8dcdaeefe
--- /dev/null
+++ b/app/graphql/types/ci/inherited_ci_variable_type.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class InheritedCiVariableType < BaseObject
+ graphql_name 'InheritedCiVariable'
+ description 'CI/CD variables a project inherites from its parent group and ancestors.'
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'ID of the variable.'
+
+ field :key, GraphQL::Types::String,
+ null: true,
+ description: 'Name of the variable.'
+
+ field :raw, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is raw.'
+
+ field :variable_type, ::Types::Ci::VariableTypeEnum,
+ null: true,
+ description: 'Type of the variable.'
+
+ field :environment_scope, GraphQL::Types::String,
+ null: true,
+ description: 'Scope defining the environments that can use the variable.'
+
+ field :protected, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is protected.'
+
+ field :masked, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the variable is masked.'
+
+ field :group_name, GraphQL::Types::String,
+ null: true,
+ description: 'Indicates group the variable belongs to.'
+
+ field :group_ci_cd_settings_path, GraphQL::Types::String,
+ null: true,
+ description: 'Indicates the path to the CI/CD settings of the group the variable belongs to.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ 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 a97e9cee4b1..e77c2a38608 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -7,6 +7,8 @@ module Types
class JobType < BaseObject
graphql_name 'CiJob'
+ present_using ::Ci::BuildPresenter
+
connection_type_class(Types::LimitedCountableConnectionType)
expose_permissions Types::PermissionTypes::Ci::Job
@@ -25,6 +27,11 @@ 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, Types::Ci::RunnerType, null: true, description: 'Runner assigned to execute 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.'
field :status,
@@ -76,6 +83,8 @@ module Types
description: 'Whether the job has a manual action.'
field :manual_variables, ManualVariableType.connection_type, null: true,
description: 'Variables added to a manual job when the job is triggered.'
+ field :play_path, GraphQL::Types::String, null: true,
+ description: 'Play path of the job.'
field :playable, GraphQL::Types::Boolean, null: false, method: :playable?,
description: 'Indicates the job can be played.'
field :previous_stage_jobs_or_needs, Types::Ci::JobNeedUnion.connection_type, null: true,
@@ -86,14 +95,18 @@ module Types
description: 'Path to the ref.'
field :retried, GraphQL::Types::Boolean, null: true,
description: 'Indicates that the job has been retried.'
- field :retryable, GraphQL::Types::Boolean, null: false, method: :retryable?,
+ field :retryable, GraphQL::Types::Boolean, null: false,
description: 'Indicates the job can be retried.'
+ field :scheduled, GraphQL::Types::Boolean, null: false, method: :scheduled?,
+ description: 'Indicates the job is scheduled.'
field :scheduling_type, GraphQL::Types::String, null: true,
description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
field :short_sha, type: GraphQL::Types::String, null: false,
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,
@@ -101,10 +114,25 @@ module Types
field :project, Types::ProjectType, null: true, description: 'Project that the job belongs to.'
+ field :can_play_job, GraphQL::Types::Boolean,
+ null: false, resolver_method: :can_play_job?,
+ description: 'Indicates whether the current user can play the job.'
+
+ field :failure_message, GraphQL::Types::String, null: true,
+ description: 'Message on why the job failed.'
+
+ def can_play_job?
+ object.playable? && Ability.allowed?(current_user, :play_job, object)
+ end
+
def kind
- return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class)
+ return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.build.class)
+
+ object.build.class
+ end
- object.class
+ def retryable
+ object.build.retryable?
end
def pipeline
@@ -129,6 +157,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|
@@ -157,6 +189,24 @@ module Types
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Stage, object.stage_id).find
end
+ def runner
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Runner, object.runner_id).find
+ end
+
+ 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|
+ loader.call(build_id, runner_managers_by_id[plucked_build_to_runner_manager_ids[build_id]])
+ end
+ end
+ end
+
# This class is a secret union!
# TODO: turn this into an actual union, so that fields can be referenced safely!
def id
@@ -183,6 +233,10 @@ module Types
::Gitlab::Routing.url_helpers.project_job_path(object.project, object)
end
+ def play_path
+ ::Gitlab::Routing.url_helpers.play_project_job_path(object.project, object)
+ end
+
def browse_artifacts_path
::Gitlab::Routing.url_helpers.browse_project_job_artifacts_path(object.project, object)
end
diff --git a/app/graphql/types/ci/runner_manager_type.rb b/app/graphql/types/ci/runner_manager_type.rb
new file mode 100644
index 00000000000..2a5053f8f07
--- /dev/null
+++ b/app/graphql/types/ci/runner_manager_type.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class RunnerManagerType < BaseObject
+ graphql_name 'CiRunnerManager'
+
+ connection_type_class(::Types::CountableConnectionType)
+
+ authorize :read_runner_manager
+
+ alias_method :runner_manager, :object
+
+ field :architecture_name, GraphQL::Types::String, null: true,
+ description: 'Architecture provided by the runner manager.',
+ method: :architecture
+ field :contacted_at, Types::TimeType, null: true,
+ 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 manager.'
+ field :executor_name, GraphQL::Types::String, null: true,
+ description: 'Executor last advertised by the runner.',
+ method: :executor_name
+ 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 manager.'
+ field :platform_name, GraphQL::Types::String, null: true,
+ 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 manager.'
+ field :status,
+ Types::Ci::RunnerStatusEnum,
+ null: false,
+ description: 'Status of the runner manager.'
+ field :system_id, GraphQL::Types::String,
+ null: false,
+ 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_manager.executor_type&.to_sym]
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 10d18f9ad2a..8e509cc8493 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -14,9 +14,6 @@ module Types
JOB_COUNT_LIMIT = 1000
- # Only allow ephemeral_authentication_token to be visible for a short while
- RUNNER_EPHEMERAL_TOKEN_AVAILABILITY_TIME = 3.hours
-
alias_method :runner, :object
field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false,
@@ -34,14 +31,20 @@ module Types
method: :contacted_at
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of creation of this runner.'
+ field :created_by, Types::UserType, null: true,
+ description: 'User that created this runner.',
+ method: :creator
field :description, GraphQL::Types::String, null: true,
description: 'Description of the runner.'
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.',
+ 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
@@ -58,7 +61,7 @@ module Types
Types::Ci::RunnerJobExecutionStatusEnum,
null: true,
description: 'Job execution status of the runner.',
- deprecated: { milestone: '15.7', reason: :alpha }
+ alpha: { milestone: '15.7' }
field :jobs, ::Types::Ci::JobType.connection_type, null: true,
description: 'Jobs assigned to the runner. This field can only be resolved for one runner in any single request.',
authorize: :read_builds,
@@ -67,6 +70,10 @@ module Types
description: 'Indicates the runner is locked.'
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,
@@ -84,6 +91,8 @@ module Types
null: true,
resolver: ::Resolvers::Ci::RunnerProjectsResolver,
description: 'Find projects the runner is associated with. For project runners only.'
+ field :register_admin_url, GraphQL::Types::String, null: true,
+ description: 'URL of the temporary registration page of the runner. Only available before the runner is registered. Only available for administrators.'
field :revision, GraphQL::Types::String, null: true,
description: 'Revision of the runner.'
field :run_untagged, GraphQL::Types::Boolean, null: false,
@@ -141,12 +150,27 @@ module Types
Gitlab::Routing.url_helpers.edit_admin_runner_url(runner) if can_admin_runners?
end
- def ephemeral_authentication_token
- return unless runner.authenticated_user_registration_type?
- return unless runner.created_at > RUNNER_EPHEMERAL_TOKEN_AVAILABILITY_TIME.ago
- return if runner.runner_machines.any?
+ def ephemeral_register_url
+ return unless context[:current_user]&.can?(:read_ephemeral_token, runner) && runner.registration_available?
+
+ 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)
+ when 'project_type'
+ Gitlab::Routing.url_helpers.register_project_runner_url(runner.projects[0], runner)
+ end
+ end
- runner.token
+ def register_admin_url
+ return unless can_admin_runners? && runner.registration_available?
+
+ Gitlab::Routing.url_helpers.register_admin_runner_url(runner)
+ end
+
+ def ephemeral_authentication_token
+ runner.token if runner.registration_available?
end
def project_count
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/clusters/agents/authorizations/ci_access_type.rb b/app/graphql/types/clusters/agents/authorizations/ci_access_type.rb
new file mode 100644
index 00000000000..a60f32b8b0b
--- /dev/null
+++ b/app/graphql/types/clusters/agents/authorizations/ci_access_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Clusters
+ module Agents
+ module Authorizations
+ class CiAccessType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'ClusterAgentAuthorizationCiAccess'
+
+ field :agent, Types::Clusters::AgentType,
+ description: 'Authorized cluster agent.',
+ null: true
+
+ field :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType
+ description: 'Configuration for the authorized project.',
+ null: true
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/clusters/agents/authorizations/user_access_type.rb b/app/graphql/types/clusters/agents/authorizations/user_access_type.rb
new file mode 100644
index 00000000000..8c5a466cde2
--- /dev/null
+++ b/app/graphql/types/clusters/agents/authorizations/user_access_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Clusters
+ module Agents
+ module Authorizations
+ class UserAccessType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'ClusterAgentAuthorizationUserAccess'
+
+ field :agent, Types::Clusters::AgentType,
+ description: 'Authorized cluster agent.',
+ null: true
+
+ field :config, GraphQL::Types::JSON, # rubocop:disable Graphql/JSONType
+ description: 'Configuration for the authorized project.',
+ null: true
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/commit_references_type.rb b/app/graphql/types/commit_references_type.rb
new file mode 100644
index 00000000000..2844a552f3e
--- /dev/null
+++ b/app/graphql/types/commit_references_type.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Types
+ class CommitReferencesType < BaseObject
+ graphql_name 'CommitReferences'
+
+ authorize :read_commit
+
+ def self.field_for_tipping_refs(field_name, field_description)
+ field field_name, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ calls_gitaly: true,
+ description: field_description do
+ argument :limit, GraphQL::Types::Int,
+ required: true,
+ default_value: 100,
+ description: 'Number of ref names to return.',
+ validates: { numericality: { within: 1..1000 } }
+ end
+ end
+
+ def self.field_for_containing_refs(field_name, field_description)
+ field field_name, ::Types::Projects::CommitParentNamesType,
+ null: true,
+ calls_gitaly: true,
+ description: field_description do
+ argument :exclude_tipped, GraphQL::Types::Boolean,
+ required: true,
+ default_value: false,
+ description: 'Exclude tipping refs. WARNING: This argument can be confusing, if there is a limit.
+ for example set the limit to 5 and in the 5 out a total of 25 refs there is 2 tipped refs,
+ then the method will only 3 refs, even though there is more.'
+ # rubocop: disable GraphQL/ArgumentUniqueness
+ argument :limit, GraphQL::Types::Int,
+ required: true,
+ default_value: 100,
+ description: 'Number of ref names to return.',
+ validates: { numericality: { within: 1..1000 } }
+ # rubocop: enable GraphQL/ArgumentUniqueness
+ end
+ end
+
+ field_for_tipping_refs :tipping_tags, "Get tag names tipping at a given commit."
+
+ field_for_tipping_refs :tipping_branches, "Get branch names tipping at a given commit."
+
+ field_for_containing_refs :containing_tags, "Get tag names containing a given commit."
+
+ field_for_containing_refs :containing_branches, "Get branch names containing a given commit."
+
+ def tipping_tags(limit:)
+ { names: object.tipping_tags(limit: limit) }
+ end
+
+ def tipping_branches(limit:)
+ { names: object.tipping_branches(limit: limit) }
+ end
+
+ def containing_tags(limit:, exclude_tipped:)
+ { names: object.tags_containing(limit: limit, exclude_tipped: exclude_tipped) }
+ end
+
+ def containing_branches(limit:, exclude_tipped:)
+ { names: object.branches_containing(limit: limit, exclude_tipped: exclude_tipped) }
+ end
+ end
+end
diff --git a/app/graphql/types/commit_signatures/ssh_signature_type.rb b/app/graphql/types/commit_signatures/ssh_signature_type.rb
index 92eb4f7949a..d5db98c39a0 100644
--- a/app/graphql/types/commit_signatures/ssh_signature_type.rb
+++ b/app/graphql/types/commit_signatures/ssh_signature_type.rb
@@ -10,14 +10,19 @@ module Types
authorize :download_code
- field :user, Types::UserType, null: true,
- method: :signed_by_user,
- calls_gitaly: true,
- description: 'User associated with the key.'
+ field :user, Types::UserType,
+ null: true,
+ method: :signed_by_user,
+ calls_gitaly: true,
+ description: 'User associated with the key.'
field :key, Types::KeyType,
- null: true,
- description: 'SSH key used for the signature.'
+ null: true,
+ description: 'SSH key used for the signature.'
+
+ field :key_fingerprint_sha256, String,
+ null: true,
+ description: 'Fingerprint of the key.'
end
end
end
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/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
index cc4c0e19ec7..be5edd17643 100644
--- a/app/graphql/types/design_management/design_type.rb
+++ b/app/graphql/types/design_management/design_type.rb
@@ -15,6 +15,11 @@ module Types
implements(Types::CurrentUserTodos)
implements(Types::TodoableInterface)
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description of the design.'
+
field :web_url,
GraphQL::Types::String,
null: false,
@@ -25,6 +30,8 @@ module Types
resolver: Resolvers::DesignManagement::VersionsResolver,
description: "All versions related to this design ordered newest first."
+ markdown_field :description_html, null: true
+
# Returns a `DesignManagement::Version` for this query based on the
# `atVersion` argument passed to a parent node if present, or otherwise
# the most recent `Version` for the issue.
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index 5f58fc38540..a3737cbcd0d 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -53,7 +53,8 @@ module Types
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment.',
- resolver: Resolvers::Metrics::DashboardResolver
+ resolver: Resolvers::Metrics::DashboardResolver,
+ deprecated: { reason: 'Returns no data. Underlying feature was removed in 16.0', milestone: '16.0' }
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 3543ac29c17..da2c06d04b7 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -167,6 +167,11 @@ module Types
null: false,
description: 'Total size of the dependency proxy cached images.'
+ field :dependency_proxy_total_size_in_bytes,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Total size of the dependency proxy cached images in bytes.'
+
field :dependency_proxy_image_prefix,
GraphQL::Types::String,
null: false,
@@ -241,7 +246,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:)
@@ -279,10 +284,14 @@ module Types
def dependency_proxy_total_size
ActiveSupport::NumberHelper.number_to_human_size(
- group.dependency_proxy_manifests.sum(:size) + group.dependency_proxy_blobs.sum(:size)
+ dependency_proxy_total_size_in_bytes
)
end
+ def dependency_proxy_total_size_in_bytes
+ group.dependency_proxy_manifests.sum(:size) + group.dependency_proxy_blobs.sum(:size)
+ end
+
def dependency_proxy_setting
group.dependency_proxy_setting || group.create_dependency_proxy_setting
end
diff --git a/app/graphql/types/issuable_subscription_event_enum.rb b/app/graphql/types/issuable_subscription_event_enum.rb
new file mode 100644
index 00000000000..0f56fab8b46
--- /dev/null
+++ b/app/graphql/types/issuable_subscription_event_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class IssuableSubscriptionEventEnum < BaseEnum
+ graphql_name 'IssuableSubscriptionEvent'
+ description 'Values for subscribing and unsubscribing from issuables'
+
+ value 'SUBSCRIBE', 'Subscribe to an issuable.', value: 'subscribe'
+ value 'UNSUBSCRIBE', 'Unsubscribe from an issuable.', value: 'unsubscribe'
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 1e5833a5cf0..488e4d10cbc 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -122,6 +122,7 @@ module Types
description: 'Collection of design images associated with this issue.'
field :type, Types::IssueTypeEnum, null: true,
+ method: :issue_type,
description: 'Type of the issue.'
field :alert_management_alert,
@@ -209,14 +210,6 @@ module Types
def escalation_status
object.supports_escalation? ? object.escalation_status&.status_name : nil
end
-
- def type
- if Feature.enabled?(:issue_type_uses_work_item_types_table)
- object.work_item_type.base_type
- else
- object.issue_type
- end
- end
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 3c288c1d496..f32dfc0dbcf 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -219,6 +219,13 @@ 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.'
+
+ field :prepared_at, Types::TimeType, null: true,
+ description: 'Timestamp of when the merge request was prepared.'
+
markdown_field :title_html, null: true
markdown_field :description_html, null: true
@@ -295,6 +302,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/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
index 1ba72ae33b5..e7246068a05 100644
--- a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
+++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb
@@ -45,6 +45,9 @@ module Types
value 'EXTERNAL_STATUS_CHECKS',
value: :status_checks_must_pass,
description: 'Status checks must pass.'
+ value 'PREPARING',
+ value: :preparing,
+ description: 'Merge request diff is being created.'
end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index e48e9deae96..7e436d74dcf 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -6,7 +6,11 @@ module Types
include Gitlab::Graphql::MountMutation
- mount_mutation Mutations::Achievements::Create
+ 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
@@ -48,6 +52,7 @@ module Types
mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
mount_mutation Mutations::DependencyProxy::GroupSettings::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
+ mount_mutation Mutations::Environments::Stop
mount_mutation Mutations::IncidentManagement::TimelineEvent::Create, alpha: { milestone: '15.6' }
mount_mutation Mutations::IncidentManagement::TimelineEvent::PromoteFromNote
mount_mutation Mutations::IncidentManagement::TimelineEvent::Update
@@ -69,6 +74,7 @@ module Types
mount_mutation Mutations::Issues::BulkUpdate, alpha: { milestone: '15.9' }
mount_mutation Mutations::Labels::Create
mount_mutation Mutations::Members::Groups::BulkUpdate
+ mount_mutation Mutations::Members::Projects::BulkUpdate
mount_mutation Mutations::MergeRequests::Accept
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
@@ -80,8 +86,14 @@ module Types
mount_mutation Mutations::MergeRequests::SetAssignees
mount_mutation Mutations::MergeRequests::SetReviewers
mount_mutation Mutations::MergeRequests::ReviewerRereview
- mount_mutation Mutations::Metrics::Dashboard::Annotations::Create
- mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete
+ mount_mutation Mutations::Metrics::Dashboard::Annotations::Create, deprecated: {
+ reason: 'Underlying feature was removed in 16.0',
+ milestone: '16.0'
+ }
+ mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete, deprecated: {
+ reason: 'Underlying feature was removed in 16.0',
+ milestone: '16.0'
+ }
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
@@ -89,6 +101,7 @@ module Types
mount_mutation Mutations::Notes::Update::ImageDiffNote
mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy
+ mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' }
mount_mutation Mutations::Releases::Create
mount_mutation Mutations::Releases::Update
mount_mutation Mutations::Releases::Delete
@@ -114,6 +127,7 @@ module Types
mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true
mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
mount_mutation Mutations::DesignManagement::Move
+ mount_mutation Mutations::DesignManagement::Update
mount_mutation Mutations::ContainerExpirationPolicies::Update
mount_mutation Mutations::ContainerRepositories::Destroy
mount_mutation Mutations::ContainerRepositories::DestroyTags
@@ -137,8 +151,10 @@ module Types
mount_mutation Mutations::Ci::Job::Cancel
mount_mutation Mutations::Ci::Job::Unschedule
mount_mutation Mutations::Ci::JobArtifact::Destroy
+ mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, alpha: { milestone: '15.10' }
mount_mutation Mutations::Ci::JobTokenScope::AddProject
mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
+ mount_mutation Mutations::Ci::Runner::Create, alpha: { milestone: '15.10' }
mount_mutation Mutations::Ci::Runner::Update
mount_mutation Mutations::Ci::Runner::Delete
mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' }
@@ -160,6 +176,8 @@ module Types
mount_mutation Mutations::WorkItems::DeleteTask, alpha: { milestone: '15.1' }
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/namespace_type.rb b/app/graphql/types/namespace_type.rb
index fc55ff512b6..3420f16213f 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -68,7 +68,9 @@ module Types
null: true,
alpha: { milestone: '15.8' },
description: "Achievements for the namespace. " \
- "Returns `null` if the `achievements` feature flag is disabled."
+ "Returns `null` if the `achievements` feature flag is disabled.",
+ extras: [:lookahead],
+ resolver: ::Resolvers::Achievements::AchievementsResolver
markdown_field :description_html, null: true
@@ -83,10 +85,6 @@ module Types
def root_storage_statistics
Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
end
-
- def achievements
- object.achievements if Feature.enabled?(:achievements, object)
- end
end
end
diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb
index 9ec4bb73c47..8dd2a4467d6 100644
--- a/app/graphql/types/packages/package_base_type.rb
+++ b/app/graphql/types/packages/package_base_type.rb
@@ -52,8 +52,6 @@ module Types
object.nuget_metadatum
when 'pypi'
object.pypi_metadatum
- else
- nil
end
end
# rubocop: enable GraphQL/ResolverMethodLength
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index f63b41b3c92..e00d6eac72f 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -63,11 +63,11 @@ module Types
end
def pypi_url
- pypi_registry_url(object.project.id)
+ pypi_registry_url(object.project)
end
def public_package
- object.project.public? || object.project.project_feature.package_registry_access_level == ProjectFeature::PUBLIC
+ object.project.project_feature.public_packages?
end
end
end
diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb
index 3ee0d983745..4f1173a9e0b 100644
--- a/app/graphql/types/packages/package_file_type.rb
+++ b/app/graphql/types/packages/package_file_type.rb
@@ -29,8 +29,6 @@ module Types
object.conan_file_metadatum
when 'helm'
object.helm_file_metadatum
- else
- nil
end
end
end
diff --git a/app/graphql/types/permission_types/ci/pipeline_schedules.rb b/app/graphql/types/permission_types/ci/pipeline_schedules.rb
index 268ac6096d0..dd9d94aa578 100644
--- a/app/graphql/types/permission_types/ci/pipeline_schedules.rb
+++ b/app/graphql/types/permission_types/ci/pipeline_schedules.rb
@@ -6,11 +6,16 @@ module Types
class PipelineSchedules < BasePermissionType
graphql_name 'PipelineSchedulePermissions'
- abilities :take_ownership_pipeline_schedule,
- :update_pipeline_schedule,
+ abilities :update_pipeline_schedule,
:admin_pipeline_schedule
ability_field :play_pipeline_schedule, calls_gitaly: true
+ ability_field :take_ownership_pipeline_schedule,
+ deprecated: {
+ reason: 'Use admin_pipeline_schedule permission to determine if the user can take ownership ' \
+ 'of a pipeline schedule',
+ milestone: '15.9'
+ }
end
end
end
diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb
index f636d43790f..6d51d94a70d 100644
--- a/app/graphql/types/permission_types/group_enum.rb
+++ b/app/graphql/types/permission_types/group_enum.rb
@@ -10,6 +10,9 @@ module Types
value 'TRANSFER_PROJECTS',
value: :transfer_projects,
description: 'Groups where the user can transfer projects to.'
+ value 'IMPORT_PROJECTS',
+ value: :import_projects,
+ description: 'Groups where the user can import projects to.'
end
end
end
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
index b38971b64cd..a76dc88adfc 100644
--- a/app/graphql/types/permission_types/issue.rb
+++ b/app/graphql/types/permission_types/issue.rb
@@ -8,7 +8,7 @@ module Types
abilities :read_issue, :admin_issue, :update_issue, :reopen_issue,
:read_design, :create_design, :destroy_design,
- :create_note
+ :create_note, :update_design
end
end
end
diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb
index f35f42001e0..0b6a384ec0e 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, :set_work_item_metadata
end
end
end
diff --git a/app/graphql/types/project_statistics_redirect_type.rb b/app/graphql/types/project_statistics_redirect_type.rb
new file mode 100644
index 00000000000..c8fec0a54c4
--- /dev/null
+++ b/app/graphql/types/project_statistics_redirect_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ProjectStatisticsRedirectType < BaseObject
+ graphql_name 'ProjectStatisticsRedirect'
+
+ field :repository, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for repository.'
+
+ field :wiki, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for wiki.'
+
+ field :build_artifacts, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for job_artifacts.'
+
+ field :packages, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for packages.'
+
+ field :snippets, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for snippets.'
+
+ field :container_registry, GraphQL::Types::String, null: false,
+ description: 'Redirection Route for container_registry.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index c105ab9814c..f8a516501c3 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, ' \
@@ -209,6 +214,11 @@ module Types
null: true,
description: 'Statistics of the project.'
+ field :statistics_details_paths, Types::ProjectStatisticsRedirectType,
+ null: true,
+ description: 'Redirects for Statistics of the project.',
+ calls_gitaly: true
+
field :repository, Types::RepositoryType,
null: true,
description: 'Git repository of the project.'
@@ -347,6 +357,12 @@ module Types
authorize: :admin_build,
resolver: Resolvers::Ci::VariablesResolver
+ field :inherited_ci_variables, Types::Ci::InheritedCiVariableType.connection_type,
+ null: true,
+ description: "List of CI/CD variables the project inherited from its parent group and ancestors.",
+ authorize: :admin_build,
+ resolver: Resolvers::Ci::InheritedVariablesResolver
+
field :ci_cd_settings, Types::Ci::CiCdSettingType,
null: true,
description: 'CI/CD settings for the project.'
@@ -517,6 +533,18 @@ module Types
description: 'Cluster agents associated with the project.',
resolver: ::Resolvers::Clusters::AgentsResolver
+ field :ci_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::CiAccessType.connection_type,
+ null: true,
+ description: 'Authorized cluster agents for the project through ci_access keyword.',
+ resolver: ::Resolvers::Clusters::Agents::Authorizations::CiAccessResolver,
+ authorize: :read_cluster_agent
+
+ field :user_access_authorized_agents, ::Types::Clusters::Agents::Authorizations::UserAccessType.connection_type,
+ null: true,
+ description: 'Authorized cluster agents for the project through user_access keyword.',
+ resolver: ::Resolvers::Clusters::Agents::Authorizations::UserAccessResolver,
+ authorize: :read_cluster_agent
+
field :merge_commit_template, GraphQL::Types::String,
null: true,
description: 'Template used to create merge commit message in merge requests.'
@@ -567,8 +595,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,
@@ -581,6 +609,20 @@ module Types
description: 'Minimum access level.'
end
+ field :flow_metrics,
+ ::Types::Analytics::CycleAnalytics::FlowMetrics[:project],
+ null: true,
+ description: 'Flow metrics for value stream analytics.',
+ method: :project_namespace,
+ authorize: :read_cycle_analytics,
+ alpha: { milestone: '15.10' }
+
+ field :commit_references, ::Types::CommitReferencesType,
+ null: true,
+ resolver: Resolvers::Projects::CommitReferencesResolver,
+ alpha: { milestone: '16.0' },
+ description: "Get tag names containing a given commit."
+
def timelog_categories
object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories)
end
@@ -627,6 +669,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
@@ -635,10 +687,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?
@@ -657,7 +707,7 @@ module Types
if project.repository.empty?
raise Gitlab::Graphql::Errors::MutationError,
- _(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe
+ Gitlab::Utils::ErrorMessage.to_user_facing(_(format('You must %s before using Security features.', add_file_docs_link.html_safe)).html_safe)
end
::Security::CiConfiguration::SastParserService.new(object).configuration
@@ -681,6 +731,19 @@ module Types
end
end
+ def statistics_details_paths
+ root_ref = project.repository.root_ref || project.default_branch_or_main
+
+ {
+ repository: Gitlab::Routing.url_helpers.project_tree_url(project, root_ref),
+ wiki: Gitlab::Routing.url_helpers.project_wikis_pages_url(project),
+ build_artifacts: Gitlab::Routing.url_helpers.project_artifacts_url(project),
+ packages: Gitlab::Routing.url_helpers.project_packages_url(project),
+ snippets: Gitlab::Routing.url_helpers.project_snippets_url(project),
+ container_registry: Gitlab::Routing.url_helpers.project_container_registry_index_url(project)
+ }
+ end
+
private
def project
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..39f8f1cdd07
--- /dev/null
+++ b/app/graphql/types/projects/commit_parent_names_type.rb
@@ -0,0 +1,13 @@
+# 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).'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/projects/fork_details_type.rb b/app/graphql/types/projects/fork_details_type.rb
index 88c17d89620..6157dc47255 100644
--- a/app/graphql/types/projects/fork_details_type.rb
+++ b/app/graphql/types/projects/fork_details_type.rb
@@ -9,11 +9,37 @@ module Types
field :ahead, GraphQL::Types::Int,
null: true,
+ calls_gitaly: true,
+ method: :ahead,
description: 'Number of commits ahead of upstream.'
field :behind, GraphQL::Types::Int,
null: true,
+ calls_gitaly: true,
+ method: :behind,
description: 'Number of commits behind upstream.'
+
+ field :is_syncing, GraphQL::Types::Boolean,
+ null: true,
+ method: :syncing?,
+ description: 'Indicates if there is a synchronization in progress.'
+
+ field :has_conflicts, GraphQL::Types::Boolean,
+ null: true,
+ method: :has_conflicts?,
+ description: 'Indicates if the fork conflicts with its upstream project.'
+
+ def ahead
+ counts[:ahead]
+ end
+
+ def behind
+ counts[:behind]
+ end
+
+ def counts
+ @counts ||= object.counts
+ end
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/projects/namespace_project_sort_enum.rb b/app/graphql/types/projects/namespace_project_sort_enum.rb
index 7c7b54226d3..14a315781ef 100644
--- a/app/graphql/types/projects/namespace_project_sort_enum.rb
+++ b/app/graphql/types/projects/namespace_project_sort_enum.rb
@@ -7,8 +7,9 @@ module Types
description 'Values for sorting projects'
value 'SIMILARITY', 'Most similar to the search query.', value: :similarity
- value 'STORAGE', 'Sort by storage size.', value: :storage
- value 'ACTIVITY_DESC', 'Sort by latest activity, in descending order.', value: :latest_activity_desc
+ value 'ACTIVITY_DESC', 'Sort by latest activity, descending order.', value: :latest_activity_desc
end
end
end
+
+Types::Projects::NamespaceProjectSortEnum.prepend_mod
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index fb906759ba4..20dce54d740 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -14,6 +14,13 @@ module Types
null: true,
description: 'CI related settings that apply to the entire instance.'
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
+
+ field :ci_pipeline_stage, ::Types::Ci::StageType,
+ null: true, description: 'Stage belonging to a CI pipeline.' do
+ argument :id, type: ::Types::GlobalIDType[::Ci::Stage],
+ required: true, description: 'Global ID of the CI stage.'
+ end
+
field :ci_variables,
Types::Ci::InstanceVariableType.connection_type,
null: true,
@@ -202,6 +209,15 @@ module Types
def query_complexity
context.query
end
+
+ def ci_pipeline_stage(id:)
+ stage = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id))
+ authorized = Ability.allowed?(current_user, :read_build, stage.project)
+
+ return unless authorized
+
+ stage
+ end
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/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index b2bc52c7745..c1669f67666 100644
--- a/app/graphql/types/release_asset_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -9,12 +9,6 @@ module Types
present_using Releases::LinkPresenter
- field :external, GraphQL::Types::Boolean,
- null: true,
- method: :external?,
- description: 'Indicates the link points to an external resource.',
- deprecated: { reason: 'No longer used', milestone: '15.9' }
-
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the link.'
field :link_type,
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/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 64aaf3e73a0..67ee0589882 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -12,6 +12,7 @@ module Types
field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.'
field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.'
field :pipeline_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI pipeline artifacts size in bytes.'
+ field :registry_size_estimated, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the deduplicated Container Registry size for the namespace is an estimated value or not.'
field :repository_size, GraphQL::Types::Float, null: false, description: 'Git repository size in bytes.'
field :snippets_size, GraphQL::Types::Float, null: false, description: 'Snippets size in bytes.'
field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.'
diff --git a/app/graphql/types/time_tracking/timelog_connection_type.rb b/app/graphql/types/time_tracking/timelog_connection_type.rb
index 43e6955c2a3..6ea7a3478c7 100644
--- a/app/graphql/types/time_tracking/timelog_connection_type.rb
+++ b/app/graphql/types/time_tracking/timelog_connection_type.rb
@@ -5,7 +5,7 @@ module Types
# rubocop: disable Graphql/AuthorizeTypes
class TimelogConnectionType < CountableConnectionType
field :total_spent_time,
- GraphQL::Types::Int,
+ GraphQL::Types::BigInt,
null: false,
description: 'Total time spent in seconds.'
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/user_interface.rb b/app/graphql/types/user_interface.rb
index 9115b5a4760..b4950cc60e3 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -153,9 +153,18 @@ module Types
field :profile_enable_gitpod_path, GraphQL::Types::String, null: true,
description: 'Web path to enable Gitpod for the user.'
+ field :user_achievements,
+ Types::Achievements::UserAchievementType.connection_type,
+ null: true,
+ alpha: { milestone: '15.10' },
+ description: "Achievements for the user. " \
+ "Only returns for namespaces where the `achievements` feature flag is enabled.",
+ extras: [:lookahead],
+ resolver: ::Resolvers::Achievements::UserAchievementsResolver
+
definition_methods do
def resolve_type(object, context)
- # in the absense of other information, we cannot tell - just default to
+ # in the absence of other information, we cannot tell - just default to
# the core user type.
::Types::UserType
end
@@ -166,3 +175,5 @@ module Types
end
end
end
+
+Types::UserInterface.prepend_mod
diff --git a/app/graphql/types/user_preferences_type.rb b/app/graphql/types/user_preferences_type.rb
index 9a1ea4a2e4f..094c7352c96 100644
--- a/app/graphql/types/user_preferences_type.rb
+++ b/app/graphql/types/user_preferences_type.rb
@@ -10,6 +10,10 @@ module Types
description: 'Sort order for issue lists.',
null: true
+ field :visibility_pipeline_id_type, Types::VisibilityPipelineIdTypeEnum,
+ description: 'Determines whether the pipeline list shows ID or IID.',
+ null: true
+
def issues_sort
object.issues_sort.to_sym
end
diff --git a/app/graphql/types/visibility_pipeline_id_type_enum.rb b/app/graphql/types/visibility_pipeline_id_type_enum.rb
new file mode 100644
index 00000000000..8f0ae7d0c2f
--- /dev/null
+++ b/app/graphql/types/visibility_pipeline_id_type_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class VisibilityPipelineIdTypeEnum < BaseEnum
+ graphql_name 'VisibilityPipelineIdType'
+ description 'Determines whether the pipeline list shows ID or IID'
+
+ UserPreference.visibility_pipeline_id_types.each_key do |field|
+ value field.upcase, value: field, description: "Display pipeline #{field.upcase}."
+ end
+ end
+end
diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb
index b46362f66b8..1e58781dbb9 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,
@@ -36,6 +39,18 @@ module Types
description: 'Title of the work item.'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of when the work item was last updated.'
+
+ field :create_note_email, GraphQL::Types::String,
+ null: true,
+ description: 'User specific email address for the work item.'
+
+ field :reference, GraphQL::Types::String, null: false,
+ description: 'Internal reference of the work item. Returned in shortened format by default.',
+ method: :to_reference do
+ argument :full, GraphQL::Types::Boolean, required: false, default_value: false,
+ description: 'Boolean option specifying whether the reference should be returned in full.'
+ end
+
field :widgets,
[Types::WorkItems::WidgetInterface],
null: true,
@@ -51,5 +66,9 @@ module Types
def web_url
Gitlab::UrlBuilder.build(object)
end
+
+ def create_note_email
+ object.creatable_note_email_address(context[:current_user])
+ end
end
end
diff --git a/app/graphql/types/work_items/available_export_fields_enum.rb b/app/graphql/types/work_items/available_export_fields_enum.rb
new file mode 100644
index 00000000000..f5b26d9818d
--- /dev/null
+++ b/app/graphql/types/work_items/available_export_fields_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ class AvailableExportFieldsEnum < BaseEnum
+ graphql_name 'AvailableExportFields'
+ description 'Available fields to be exported as CSV'
+
+ 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.'
+ value 'CREATED_AT', value: 'created_at', description: 'Date of creation.'
+ end
+ end
+end
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 672a78f12e1..53ea901ea10 100644
--- a/app/graphql/types/work_items/widget_interface.rb
+++ b/app/graphql/types/work_items/widget_interface.rb
@@ -18,7 +18,10 @@ module Types
::Types::WorkItems::Widgets::AssigneesType,
::Types::WorkItems::Widgets::StartAndDueDateType,
::Types::WorkItems::Widgets::MilestoneType,
- ::Types::WorkItems::Widgets::NotesType
+ ::Types::WorkItems::Widgets::NotesType,
+ ::Types::WorkItems::Widgets::NotificationsType,
+ ::Types::WorkItems::Widgets::CurrentUserTodosType,
+ ::Types::WorkItems::Widgets::AwardEmojiType
].freeze
def self.ce_orphan_types
@@ -44,6 +47,12 @@ module Types
::Types::WorkItems::Widgets::MilestoneType
when ::WorkItems::Widgets::Notes
::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/graphql/types/work_items/widgets/notifications_type.rb b/app/graphql/types/work_items/widgets/notifications_type.rb
new file mode 100644
index 00000000000..85928817d07
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/notifications_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 NotificationsType < BaseObject
+ graphql_name 'WorkItemWidgetNotifications'
+ description 'Represents the notifications widget'
+
+ implements Types::WorkItems::WidgetInterface
+
+ field :subscribed, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Whether the current user is subscribed to notifications on the work item.'
+
+ def subscribed
+ object.work_item.subscribed?(current_user, object.work_item.project)
+ end
+ end
+ # rubocop:enable Graphql/AuthorizeTypes
+ end
+ end
+end
diff --git a/app/graphql/types/work_items/widgets/notifications_update_input_type.rb b/app/graphql/types/work_items/widgets/notifications_update_input_type.rb
new file mode 100644
index 00000000000..2f3b46c3f45
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/notifications_update_input_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class NotificationsUpdateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetNotificationsUpdateInput'
+
+ argument :subscribed,
+ GraphQL::Types::Boolean,
+ required: true,
+ description: 'Desired state of the subscription.'
+ end
+ 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/abuse_reports_helper.rb b/app/helpers/admin/abuse_reports_helper.rb
new file mode 100644
index 00000000000..275bed406f1
--- /dev/null
+++ b/app/helpers/admin/abuse_reports_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Admin
+ module AbuseReportsHelper
+ def abuse_reports_list_data(reports)
+ {
+ abuse_reports_data: {
+ categories: AbuseReport.categories.keys,
+ reports: Admin::AbuseReportSerializer.new.represent(reports),
+ pagination: {
+ current_page: reports.current_page,
+ per_page: reports.limit_value,
+ total_items: reports.total_count
+ }
+ }.to_json
+ }
+ end
+
+ def abuse_report_data(report)
+ {
+ abuse_report_data: Admin::AbuseReportDetailsSerializer.new.represent(report).to_json
+ }
+ end
+ 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/analytics/cycle_analytics_helper.rb b/app/helpers/analytics/cycle_analytics_helper.rb
deleted file mode 100644
index 35a5d4f469d..00000000000
--- a/app/helpers/analytics/cycle_analytics_helper.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Analytics
- module CycleAnalyticsHelper
- def cycle_analytics_default_stage_config
- Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |stage_params|
- Analytics::CycleAnalytics::StagePresenter.new(stage_params)
- end
- end
-
- def cycle_analytics_initial_data(project, group = nil)
- base_data = { project_id: project.id, group_path: project.group&.path, request_path: project_cycle_analytics_path(project), full_path: project.full_path }
- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
- api_paths = group.present? ? cycle_analytics_group_api_paths(group) : cycle_analytics_project_api_paths(project)
-
- base_data.merge(svgs, api_paths)
- end
-
- private
-
- def cycle_analytics_group_api_paths(group)
- { milestones_path: group_milestones_path(group, format: :json), labels_path: group_labels_path(group, format: :json), group_path: group_path(group), group_id: group&.id }
- end
-
- def cycle_analytics_project_api_paths(project)
- { milestones_path: project_milestones_path(project, format: :json), labels_path: project_labels_path(project, format: :json), group_path: project.parent&.path, group_id: project.parent&.id }
- end
- end
-end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 93b7c8c0b94..71f8478544b 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -181,14 +181,14 @@ module ApplicationHelper
css_classes << html_class unless html_class.blank?
content_tag :time, l(time, format: "%b %d, %Y"),
- class: css_classes.join(' '),
- title: l(time.to_time.in_time_zone, format: :timeago_tooltip),
- datetime: time.to_time.getutc.iso8601,
- data: {
- toggle: 'tooltip',
- placement: placement,
- container: 'body'
- }
+ class: css_classes.join(' '),
+ title: l(time.to_time.in_time_zone, format: :timeago_tooltip),
+ datetime: time.to_time.getutc.iso8601,
+ data: {
+ toggle: 'tooltip',
+ placement: placement,
+ container: 'body'
+ }
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
@@ -200,7 +200,7 @@ module ApplicationHelper
if !exclude_author && object.last_edited_by
output << content_tag(:span, ' by ')
- output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil)
+ output << link_to_member(object.project, object.last_edited_by, avatar: false, extra_class: 'gl-hover-text-decoration-underline', author_class: nil)
end
output
@@ -276,12 +276,16 @@ module ApplicationHelper
if startup_css_enabled?
stylesheet_link_tag(path, media: "print", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
else
- stylesheet_link_tag(path, crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
+ stylesheet_link_tag(path, media: "all", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil)
end
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?
@@ -374,6 +379,12 @@ module ApplicationHelper
cookies["sidebar_collapsed"] == "true"
end
+ def collapsed_super_sidebar?
+ return false if @force_desktop_expanded_sidebar
+
+ cookies["super_sidebar_collapsed"] == "true"
+ end
+
def locale_path
asset_path("locale/#{Gitlab::I18n.locale}/app.js")
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 3abaae98c29..dab682d88e0 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -4,11 +4,11 @@ module ApplicationSettingsHelper
extend self
delegate :allow_signup?,
- :gravatar_enabled?,
- :password_authentication_enabled_for_web?,
- :akismet_enabled?,
- :spam_check_endpoint_enabled?,
- to: :'Gitlab::CurrentSettings.current_application_settings'
+ :gravatar_enabled?,
+ :password_authentication_enabled_for_web?,
+ :akismet_enabled?,
+ :spam_check_endpoint_enabled?,
+ to: :'Gitlab::CurrentSettings.current_application_settings'
def user_oauth_applications?
Gitlab::CurrentSettings.user_oauth_applications
@@ -248,7 +248,9 @@ module ApplicationSettingsHelper
:default_project_visibility,
:default_projects_limit,
:default_snippet_visibility,
+ :default_syntax_highlighting_theme,
:delete_inactive_projects,
+ :deny_all_requests_except_allowed,
:disable_admin_oauth_scopes,
:disable_feed_token,
:disabled_oauth_sign_in_sources,
@@ -347,6 +349,7 @@ module ApplicationSettingsHelper
:repository_storages_weighted,
:require_admin_approval_after_user_signup,
:require_two_factor_authentication,
+ :remember_me_enabled,
:restricted_visibility_levels,
:rsa_key_restriction,
:session_expire_delay,
@@ -354,6 +357,12 @@ module ApplicationSettingsHelper
:shared_runners_text,
:sign_in_text,
:signup_enabled,
+ :silent_mode_enabled,
+ :slack_app_enabled,
+ :slack_app_id,
+ :slack_app_secret,
+ :slack_app_signing_secret,
+ :slack_app_verification_token,
:sourcegraph_enabled,
:sourcegraph_url,
:sourcegraph_public_only,
@@ -401,6 +410,7 @@ module ApplicationSettingsHelper
:protected_paths_raw,
:time_tracking_limit_to_hours,
:two_factor_grace_period,
+ :update_runner_versions_enabled,
:unique_ips_limit_enabled,
:unique_ips_limit_per_user,
:unique_ips_limit_time_window,
@@ -478,7 +488,10 @@ module ApplicationSettingsHelper
:bulk_import_enabled,
:allow_runner_registration_token,
:user_defaults_to_private_profile,
- :deactivation_email_additional_text
+ :deactivation_email_additional_text,
+ :projects_api_rate_limit_unauthenticated,
+ :gitlab_dedicated_instance,
+ :ci_max_includes
].tap do |settings|
next if Gitlab.com?
@@ -535,28 +548,6 @@ module ApplicationSettingsHelper
Rack::Attack.throttles.key?('protected paths')
end
- def self_monitoring_project_data
- {
- 'create_self_monitoring_project_path' =>
- create_self_monitoring_project_admin_application_settings_path,
-
- 'status_create_self_monitoring_project_path' =>
- status_create_self_monitoring_project_admin_application_settings_path,
-
- 'delete_self_monitoring_project_path' =>
- delete_self_monitoring_project_admin_application_settings_path,
-
- 'status_delete_self_monitoring_project_path' =>
- status_delete_self_monitoring_project_admin_application_settings_path,
-
- 'self_monitoring_project_exists' =>
- Gitlab::CurrentSettings.self_monitoring_project.present?.to_s,
-
- 'self_monitoring_project_full_path' =>
- Gitlab::CurrentSettings.self_monitoring_project&.full_path
- }
- end
-
def valid_runner_registrars
Gitlab::CurrentSettings.valid_runner_registrars
end
@@ -595,9 +586,7 @@ module ApplicationSettingsHelper
supported_syntax_link_url: 'https://github.com/google/re2/wiki/Syntax',
email_restrictions: @application_setting.email_restrictions.to_s,
after_sign_up_text: @application_setting[:after_sign_up_text].to_s,
- pending_user_count: pending_user_count,
- project_sharing_help_link: help_page_path('user/group/access_and_permissions', anchor: 'prevent-a-project-from-being-shared-with-groups'),
- group_sharing_help_link: help_page_path('user/group/access_and_permissions', anchor: 'prevent-group-sharing-outside-the-group-hierarchy')
+ pending_user_count: pending_user_count
}
end
end
diff --git a/app/helpers/artifacts_helper.rb b/app/helpers/artifacts_helper.rb
index df0432105d5..f90d59409ed 100644
--- a/app/helpers/artifacts_helper.rb
+++ b/app/helpers/artifacts_helper.rb
@@ -4,6 +4,7 @@ module ArtifactsHelper
def artifacts_app_data(project)
{
project_path: project.full_path,
+ project_id: project.id,
can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s,
artifacts_management_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg')
}
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index e2e89c9abca..0ee08ba1820 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -21,6 +21,8 @@ module AuthHelper
LDAP_PROVIDER = /\Aldap/.freeze
POPULAR_PROVIDERS = %w(google_oauth2 github).freeze
+ delegate :slack_app_id, to: :'Gitlab::CurrentSettings.current_application_settings'
+
def ldap_enabled?
Gitlab::Auth::Ldap::Config.enabled?
end
@@ -45,9 +47,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/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 57075a44d0f..17f995ec0ad 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -28,7 +28,7 @@ module AvatarsHelper
end
def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
- return gravatar_icon(email, size, scale) if email.nil?
+ return default_avatar if email.blank?
Gitlab::AvatarCache.by_email(email, size, scale, only_path) do
avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb
index 5117f7c6d9c..56d651a8b65 100644
--- a/app/helpers/blame_helper.rb
+++ b/app/helpers/blame_helper.rb
@@ -39,4 +39,12 @@ 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)
+ namespace_project_blame_streaming_path(namespace_id: project.namespace, project_id: project, id: id)
+ end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 281d5c923d0..02f69327dff 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -2,9 +2,7 @@
module BlobHelper
def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
- project_edit_blob_path(project,
- tree_join(ref, path),
- options[:link_opts])
+ project_edit_blob_path(project, tree_join(ref, path), options[:link_opts])
end
def ide_edit_path(project = @project, ref = @ref, path = @path)
@@ -52,9 +50,11 @@ module BlobHelper
def fork_path_for_current_user(project, path, with_notice: true)
return unless current_user
- project_forks_path(project,
- namespace_key: current_user.namespace&.id,
- continue: edit_blob_fork_params(path, with_notice: with_notice))
+ project_forks_path(
+ project,
+ namespace_key: current_user.namespace&.id,
+ continue: edit_blob_fork_params(path, with_notice: with_notice)
+ )
end
def encode_ide_path(path)
@@ -66,12 +66,14 @@ module BlobHelper
common_classes = "btn gl-button btn-confirm js-edit-blob gl-ml-3 #{options[:extra_class]}"
- edit_button_tag(blob,
- common_classes,
- _('Edit'),
- edit_blob_path(project, ref, path, options),
- project,
- ref)
+ edit_button_tag(
+ blob,
+ common_classes,
+ _('Edit'),
+ edit_blob_path(project, ref, path, options),
+ project,
+ ref
+ )
end
def can_modify_blob?(blob, project = @project, ref = @ref)
@@ -282,8 +284,8 @@ module BlobHelper
fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: params)
button_tag label,
- class: "#{common_classes} js-edit-blob-link-fork-toggler",
- data: { action: action, fork_path: fork_path }
+ class: "#{common_classes} js-edit-blob-link-fork-toggler",
+ data: { action: action, fork_path: fork_path }
end
def edit_disabled_button_tag(button_text, common_classes)
@@ -328,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/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 1c5a601de25..2f14c907b12 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -67,7 +67,10 @@ module BroadcastMessagesHelper
{
id: message.id,
status: broadcast_message_status(message),
- preview: broadcast_message(message, preview: true),
+ message: message.message,
+ theme: message.theme,
+ broadcast_type: message.broadcast_type,
+ dismissable: message.dismissable,
starts_at: message.starts_at.iso8601,
ends_at: message.ends_at.iso8601,
target_roles: target_access_levels_display(message.target_access_levels),
@@ -79,10 +82,26 @@ module BroadcastMessagesHelper
end.to_json
end
+ def broadcast_message_data(broadcast_message)
+ {
+ id: broadcast_message.id,
+ message: broadcast_message.message,
+ broadcast_type: broadcast_message.broadcast_type,
+ theme: broadcast_message.theme,
+ dismissable: broadcast_message.dismissable.to_s,
+ target_access_levels: broadcast_message.target_access_levels,
+ messages_path: admin_broadcast_messages_path,
+ preview_path: preview_admin_broadcast_messages_path,
+ target_path: broadcast_message.target_path,
+ starts_at: broadcast_message.starts_at.iso8601,
+ ends_at: broadcast_message.ends_at.iso8601,
+ target_access_level_options: target_access_level_options.to_json
+ }
+ end
+
private
def current_user_access_level_for_project_or_group
- return if Feature.disabled?(:role_targeted_broadcast_messages)
return unless current_user.present?
strong_memoize(:current_user_access_level_for_project_or_group) do
diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb
index afd0af18ba7..8a00c0f3eb0 100644
--- a/app/helpers/ci/builds_helper.rb
+++ b/app/helpers/ci/builds_helper.rb
@@ -2,18 +2,6 @@
module Ci
module BuildsHelper
- def build_summary(build, skip: false)
- if build.has_trace?
- if skip
- link_to _('View job log'), pipeline_job_url(build.pipeline, build)
- else
- build.trace.html(last_lines: 10).html_safe
- end
- else
- _('No job log')
- end
- end
-
def sidebar_build_class(build, current_build)
build_class = []
build_class << 'active' if build.id === current_build.id
@@ -36,15 +24,5 @@ module Ci
description: project_job_url(@project, @build)
}
end
-
- def prepare_failed_jobs_summary_data(failed_builds)
- failed_builds.map do |build|
- {
- id: build.id,
- failure: build.present.callout_failure_message,
- failure_summary: build_summary(build)
- }
- end.to_json
- end
end
end
diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb
new file mode 100644
index 00000000000..9f70410f17f
--- /dev/null
+++ b/app/helpers/ci/catalog/resources_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ module ResourcesHelper
+ def can_view_namespace_catalog?(_project)
+ false
+ end
+
+ 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/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 424e5920fed..a7e1de173bd 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -18,24 +18,6 @@ module Ci
}
end
- def bridge_data(build, project)
- {
- "build_id" => build.id,
- "empty-state-illustration-path" => image_path('illustrations/job-trigger-md.svg'),
- "pipeline_iid" => build.pipeline.iid,
- "project_full_path" => project.full_path
- }
- end
-
- def job_counts
- {
- "all" => limited_counter_with_delimiter(@all_builds),
- "pending" => limited_counter_with_delimiter(@all_builds.pending),
- "running" => limited_counter_with_delimiter(@all_builds.running),
- "finished" => limited_counter_with_delimiter(@all_builds.finished)
- }
- end
-
def job_statuses
statuses = Ci::HasStatus::AVAILABLE_STATUSES
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index 99a92ba9b59..4d1bdf5fa7f 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -18,12 +18,12 @@ module Ci
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
"ci-lint-path" => project_ci_lint_path(project),
+ "ci-troubleshooting-path" => help_page_path('ci/troubleshooting', anchor: 'common-cicd-issues'),
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
"initial-branch-name" => initial_branch,
"includes-help-page-path" => help_page_path('ci/yaml/includes'),
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
- "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
"new-merge-request-path" => namespace_project_new_merge_request_path,
"pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(latest_commit.sha) : '',
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 823332c3d1d..6b15f0c9e20 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -91,7 +91,7 @@ module Ci
artifacts_endpoint: downloadable_artifacts_project_pipeline_path(project, artifacts_endpoint_placeholder, format: :json),
artifacts_endpoint_placeholder: artifacts_endpoint_placeholder,
pipeline_schedule_url: pipeline_schedules_path(project),
- empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'),
error_state_svg_path: image_path('illustrations/pipelines_failed.svg'),
no_pipelines_svg_path: image_path('illustrations/pipelines_pending.svg'),
can_create_pipeline: can?(current_user, :create_pipeline, project).to_s,
@@ -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/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 41ef0bd20a8..7177ddd3f31 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -35,6 +35,10 @@ module Ci
end
end
+ def runner_short_name(runner)
+ "##{runner.id} (#{runner.short_sha})"
+ end
+
def runner_link(runner)
display_name = truncate(runner.display_name, length: 15)
id = "\##{runner.id}"
@@ -66,15 +70,15 @@ module Ci
new_runner_path: new_admin_runner_path,
registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
- stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i,
- empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
- empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg')
+ stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
}
end
def group_shared_runners_settings_data(group)
{
group_id: group.id,
+ group_name: group.name,
+ group_is_empty: (group.projects.empty? && group.children.empty?).to_s,
shared_runners_setting: group.shared_runners_setting,
parent_shared_runners_setting: group.parent&.shared_runners_setting,
runner_enabled_value: Namespace::SR_ENABLED,
@@ -89,9 +93,7 @@ module Ci
group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
- stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i,
- empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
- empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg')
+ stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
}
end
diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb
index bca49324a19..ea5b613cb78 100644
--- a/app/helpers/ci/status_helper.rb
+++ b/app/helpers/ci/status_helper.rb
@@ -131,10 +131,10 @@ module Ci
if path
link_to ci_icon_for_status(status, size: icon_size), path,
- class: klass, title: title, data: data
+ class: klass, title: title, data: data
else
content_tag :span, ci_icon_for_status(status, size: icon_size),
- class: klass, title: title, data: data
+ class: klass, title: title, data: data
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 8449bccd285..b1d61474700 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')
}
@@ -57,11 +56,19 @@ module ClustersHelper
when 'environments'
render_if_exists 'clusters/clusters/environments'
when 'health'
- render_if_exists 'clusters/clusters/health'
+ if Feature.enabled?(:remove_monitor_metrics)
+ render('details', expanded: expanded)
+ else
+ render_if_exists 'clusters/clusters/health'
+ end
when 'apps'
render 'applications'
when 'integrations'
- render 'integrations'
+ if Feature.enabled?(:remove_monitor_metrics)
+ render('details', expanded: expanded)
+ else
+ render 'integrations'
+ end
when 'settings'
render 'advanced_settings_container'
else
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index f75d3657986..ee86553d75d 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -27,12 +27,11 @@ module CommitsHelper
end
def commit_to_html(commit, ref, project)
- render partial: 'projects/commits/commit', formats: :html,
- locals: {
- commit: commit,
- ref: ref,
- project: project
- }
+ render partial: 'projects/commits/commit', formats: :html, locals: {
+ commit: commit,
+ ref: ref,
+ project: project
+ }
end
# Breadcrumb links for a Project and, if applicable, a tree path
@@ -170,7 +169,8 @@ module CommitsHelper
pipeline_status: commit.detailed_status_for(ref)&.cache_key,
xhr: request.xhr?,
controller: controller.controller_path,
- path: @path # referred to in #link_to_browse_code
+ path: @path, # referred to in #link_to_browse_code
+ referenced_by: tag_checksum(commit.referenced_by)
}
]
end
@@ -188,16 +188,22 @@ module CommitsHelper
entity = mode == 'raw' ? 'rawButton' : 'renderedButton'
title = "Display #{mode} diff"
- link_to("##{mode}-diff-#{file_hash}",
- class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff",
- title: title,
- data: { file_hash: file_hash, diff_toggle_entity: entity }) do
+ link_to(
+ "##{mode}-diff-#{file_hash}",
+ class: "btn gl-button btn-default btn-file-option has-tooltip btn-show-#{mode}-diff",
+ title: title,
+ data: { file_hash: file_hash, diff_toggle_entity: entity }
+ ) do
sprite_icon(icon)
end
end
protected
+ def tag_checksum(tags_array)
+ ::Zlib.crc32(tags_array.sort.join)
+ end
+
# Private: Returns a link to a person. If the person has a matching user and
# is a member of the current @project it will link to the team member page.
# Otherwise it will link to the person email as specified in the commit.
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index f0e1f252917..7c239f78088 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -40,9 +40,14 @@ module DashboardHelper
end)
if doc_href.present?
- link_to_doc = link_to(sprite_icon('question'), doc_href,
- class: 'gl-ml-2', title: _('Documentation'),
- target: '_blank', rel: 'noopener noreferrer')
+ link_to_doc = link_to(
+ sprite_icon('question-o'),
+ doc_href,
+ class: 'gl-ml-2',
+ title: _('Documentation'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
concat(link_to_doc)
end
@@ -52,7 +57,7 @@ module DashboardHelper
private
def get_dashboard_nav_links
- links = [:projects, :groups, :snippets]
+ links = [:projects, :groups, :snippets, :your_work, :explore]
if can?(current_user, :read_cross_project)
links += [:activity, :milestones]
diff --git a/app/helpers/device_registration_helper.rb b/app/helpers/device_registration_helper.rb
new file mode 100644
index 00000000000..bbdcab76bf5
--- /dev/null
+++ b/app/helpers/device_registration_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module DeviceRegistrationHelper
+ def device_registration_data(current_password_required:, target_path:, webauthn_error:)
+ {
+ initial_error: webauthn_error && webauthn_error[:message],
+ target_path: target_path,
+ password_required: current_password_required.to_s
+ }
+ end
+end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index e0a1697cfa9..c5df53ec606 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -34,6 +34,12 @@ module DiffHelper
options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
options[:use_extra_viewer_as_main] = false
+
+ if Feature.enabled?(:large_ipynb_diffs, @project) && params[:file_identifier]&.include?('.ipynb')
+ options[:max_patch_bytes_for_file_extension] = {
+ '.ipynb' => 1.megabyte
+ }
+ end
end
options
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 427cbe18fbf..475ba3dcba8 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -54,7 +54,7 @@ module DropdownsHelper
default_label = data_attr[:default_label]
content_tag(:button, disabled: options[:disabled], class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
- output << sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
+ output << sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon")
output.html_safe
end
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 2b3700a9f21..00109212934 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -79,7 +79,7 @@ module EnvironmentHelper
can_destroy_environment: can_destroy_environment?(environment),
can_stop_environment: can?(current_user, :stop_environment, environment),
can_admin_environment: can?(current_user, :admin_environment, project),
- environment_metrics_path: project_metrics_dashboard_path(project, environment: environment),
+ **environment_metrics_path(project, environment),
environments_fetch_path: project_environments_path(project, format: :json),
environment_edit_path: edit_project_environment_path(project, environment),
environment_stop_path: stop_project_environment_path(project, environment),
@@ -88,11 +88,18 @@ module EnvironmentHelper
environment_terminal_path: terminal_project_environment_path(project, environment),
has_terminals: environment.has_terminals?,
is_environment_available: environment.available?,
- auto_stop_at: environment.auto_stop_at
+ auto_stop_at: environment.auto_stop_at,
+ graphql_etag_key: environment.etag_cache_key
}
end
def environments_detail_data_json(user, project, environment)
environments_detail_data(user, project, environment).to_json
end
+
+ def environment_metrics_path(project, environment)
+ return {} if Feature.enabled?(:remove_monitor_metrics)
+
+ { environment_metrics_path: project_metrics_dashboard_path(project, environment: environment) }
+ end
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 5bf4fa2ffcc..525fdd3e9f6 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -22,6 +22,8 @@ module EnvironmentsHelper
end
def metrics_data(project, environment)
+ return {} if Feature.enabled?(:remove_monitor_metrics)
+
metrics_data = {}
metrics_data.merge!(project_metrics_data(project)) if project
metrics_data.merge!(environment_metrics_data(environment, project)) if environment
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index bef2da495b0..795d35ec81f 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -29,10 +29,11 @@ module EventsHelper
opened: s_('Event|opened'),
updated: s_('Event|updated'),
'removed due to membership expiration from': s_('Event|removed due to membership expiration from')
- }.merge(localized_push_action_name_map,
- localized_created_project_action_name_map,
- localized_design_action_names
- ).freeze
+ }.merge(
+ localized_push_action_name_map,
+ localized_created_project_action_name_map,
+ localized_design_action_names
+ ).freeze
end
def localized_push_action_name_map
@@ -183,13 +184,11 @@ module EventsHelper
def event_feed_url(event)
if event.issue?
- project_issue_url(event.project,
- event.issue)
+ project_issue_url(event.project, event.issue)
elsif event.merge_request?
project_merge_request_url(event.project, event.merge_request)
elsif event.commit_note?
- project_commit_url(event.project,
- event.note_target)
+ project_commit_url(event.project, event.note_target)
elsif event.note?
if event.note_target
event_note_target_url(event)
@@ -204,16 +203,12 @@ module EventsHelper
def push_event_feed_url(event)
if event.push_with_commits? && event.md_ref?
if event.commits_count > 1
- project_compare_url(event.project,
- from: event.commit_from, to:
- event.commit_to)
+ project_compare_url(event.project, from: event.commit_from, to: event.commit_to)
else
- project_commit_url(event.project,
- id: event.commit_to)
+ project_commit_url(event.project, id: event.commit_to)
end
elsif event.ref_name
- project_commits_url(event.project,
- event.ref_name)
+ project_commits_url(event.project, event.ref_name)
end
end
@@ -241,26 +236,31 @@ module EventsHelper
elsif event.design_note?
design_url(event.note_target, anchor: dom_id(event.note))
else
- polymorphic_url([event.project, event.note_target],
- anchor: dom_id(event.target))
+ polymorphic_url([event.project, event.note_target], anchor: dom_id(event.target))
end
end
def event_wiki_title_html(event)
capture do
concat content_tag(:span, _('wiki page'), class: "event-target-type gl-mr-2")
- concat link_to(event.target_title, event_wiki_page_target_url(event),
- title: event.target_title,
- class: 'has-tooltip event-target-link gl-mr-2')
+ concat link_to(
+ event.target_title,
+ event_wiki_page_target_url(event),
+ title: event.target_title,
+ class: 'has-tooltip event-target-link gl-mr-2'
+ )
end
end
def event_design_title_html(event)
capture do
concat content_tag(:span, _('design'), class: "event-target-type gl-mr-2")
- concat link_to(event.design.reference_link_text, design_url(event.design),
- title: event.target_title,
- class: 'has-tooltip event-design event-target-link gl-mr-2')
+ concat link_to(
+ event.design.reference_link_text,
+ design_url(event.design),
+ title: event.target_title,
+ class: 'has-tooltip event-design event-target-link gl-mr-2'
+ )
end
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 2967501f628..ed24f2509e8 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -57,7 +57,7 @@ module ExploreHelper
private
def get_explore_nav_links
- [:projects, :groups, :snippets]
+ [:projects, :groups, :topics, :snippets]
end
def request_path_with_options(options = {})
diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb
index 3dde29dce91..fe8d8e6b5d9 100644
--- a/app/helpers/feature_flags_helper.rb
+++ b/app/helpers/feature_flags_helper.rb
@@ -18,8 +18,10 @@ module FeatureFlagsHelper
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
- environments_scope_docs_path: help_page_path('ci/environments/index.md',
- anchor: 'limit-the-environment-scope-of-a-cicd-variable')
+ environments_scope_docs_path: help_page_path(
+ 'ci/environments/index.md',
+ anchor: 'limit-the-environment-scope-of-a-cicd-variable'
+ )
}
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index a4d90716129..ed8cca20241 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -72,7 +72,8 @@ module FormHelper
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
- current_user_info: UserSerializer.new.represent(current_user)
+ current_user_info: UserSerializer.new.represent(current_user),
+ testid: 'assignee-ids-dropdown-toggle'
}
}
diff --git a/app/helpers/groups/observability_helper.rb b/app/helpers/groups/observability_helper.rb
index 6cd6566cee1..7661817da7b 100644
--- a/app/helpers/groups/observability_helper.rb
+++ b/app/helpers/groups/observability_helper.rb
@@ -22,19 +22,8 @@ module Groups
}.freeze
def observability_iframe_src(group)
- # Format: https://observe.gitlab.com/GROUP_ID
-
- # When running Observability UI in standalone mode (i.e. not backed by Observability Backend)
- # the group-id is not required. This is mostly used for local dev
- base_url = ENV['STANDALONE_OBSERVABILITY_UI'] == 'true' ? observability_url : "#{observability_url}/-/#{group.id}"
-
- sanitized_path = if params[:observability_path] && sanitize(params[:observability_path]) != ''
- CGI.unescapeHTML(sanitize(params[:observability_path]))
- else
- observability_config_for(params).fetch(:path)
- end
-
- "#{base_url}#{sanitized_path}"
+ Gitlab::Observability.build_full_url(group, params[:observability_path],
+ observability_config_for(params).fetch(:path))
end
def observability_page_title
@@ -43,10 +32,6 @@ module Groups
private
- def observability_url
- Gitlab::Observability.observability_url
- end
-
def observability_config_for(params)
ACTION_TO_PATH.fetch(params[:action], ACTION_TO_PATH['dashboards'])
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 129871ca3fd..2ced1bec5e9 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -126,21 +126,12 @@ module GroupsHelper
def subgroup_creation_data(group)
{
+ parent_group_url: group.parent && group_url(group.parent),
parent_group_name: group.parent&.name,
import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane')
}
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,
@@ -160,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,
@@ -167,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 c5be044a27b..448909543c4 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -1,28 +1,30 @@
# frozen_string_literal: true
module IdeHelper
- def ide_data(project:, branch:, path:, merge_request:, fork_info:)
- {
- 'can-use-new-web-ide' => can_use_new_web_ide?.to_s,
+ # Overridden in EE
+ def ide_data(project:, fork_info:, params:)
+ base_data = {
'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,
- 'branch-name' => branch,
- 'file-path' => path,
- 'fork-info' => fork_info&.to_json,
'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'),
'editor-font-family' => 'JetBrains Mono',
- 'editor-font-format' => 'woff2',
- 'merge-request' => merge_request
+ 'editor-font-format' => 'woff2'
}.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project))
- end
- def can_use_new_web_ide?
- Feature.enabled?(:vscode_web_ide, current_user)
+ return base_data unless project
+
+ base_data.merge(
+ 'fork-info' => fork_info&.to_json,
+ 'branch-name' => params[:branch],
+ 'file-path' => params[:path],
+ 'merge-request' => params[:merge_request_id]
+ )
end
def use_new_web_ide?
- can_use_new_web_ide? && !current_user.use_legacy_web_ide
+ Feature.enabled?(:vscode_web_ide, current_user)
end
private
@@ -41,7 +43,7 @@ module IdeHelper
'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'),
'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'),
'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
- 'pipelines-empty-state-svg-path': image_path('illustrations/pipelines_empty.svg'),
+ 'pipelines-empty-state-svg-path': image_path('illustrations/empty-state/empty-pipeline-md.svg'),
'switch-editor-svg-path': image_path('illustrations/rocket-launch-md.svg'),
'promotion-svg-path': image_path('illustrations/web-ide_promotion.svg'),
'ci-help-page-path' => help_page_path('ci/quick_start/index'),
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 46d2d2c42d9..3796d8f0210 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -12,8 +12,8 @@ module IssuablesHelper
end
end
- def sidebar_gutter_collapsed_class
- return "right-sidebar-expanded" if moved_mr_sidebar_enabled?
+ def sidebar_gutter_collapsed_class(is_merge_request_with_flag)
+ return "right-sidebar-expanded" if is_merge_request_with_flag
"right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}"
end
@@ -144,7 +144,7 @@ module IssuablesHelper
def issuable_meta(issuable, project)
output = []
- if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type)
+ if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.issue_type)
output << content_tag(:span, sprite_icon(issuable.work_item_type.icon_name.to_s, css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' })
output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: IntegrationsHelper.integration_issue_type(issuable.issue_type), created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2')
else
@@ -156,7 +156,7 @@ module IssuablesHelper
end
output << content_tag(:strong) do
- author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
+ author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline-block")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1')
@@ -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))
@@ -277,11 +274,13 @@ module IssuablesHelper
end
def incident_only_initial_data(issue)
- return {} unless issue.incident?
+ return {} unless issue.incident_type_issue?
{
hasLinkedAlerts: issue.alert_management_alerts.any?,
- canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issue)
+ canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issue),
+ currentPath: url_for(safe_params),
+ currentTab: safe_params[:incident_tab]
}
end
@@ -378,13 +377,54 @@ module IssuablesHelper
end
def hidden_issuable_icon(issuable)
- title = format(_('This %{issuable} is hidden because its author has been banned'),
- issuable: issuable.is_a?(Issue) ? _('issue') : _('merge request'))
+ title = format(
+ _('This %{issuable} is hidden because its author has been banned'),
+ issuable: issuable.is_a?(Issue) ? _('issue') : _('merge request')
+ )
content_tag(:span, class: 'has-tooltip', title: title) do
sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
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
+
+ def issuable_label_selector_data(project, issuable)
+ initial_labels = issuable.labels.map do |label|
+ {
+ __typename: "Label",
+ id: label.id,
+ title: label.title,
+ description: label.description,
+ color: label.color,
+ text_color: label.text_color
+ }
+ end
+
+ filter_base_path =
+ if issuable.issuable_type == "merge_request"
+ project_merge_requests_path(project)
+ else
+ project_issues_path(project)
+ end
+
+ {
+ field_name: "#{issuable.class.model_name.param_key}[label_ids][]",
+ full_path: project.full_path,
+ initial_labels: initial_labels.to_json,
+ issuable_type: issuable.issuable_type,
+ labels_filter_base_path: filter_base_path,
+ labels_manage_path: project_labels_path(project)
+ }
+ end
+
private
def sidebar_gutter_collapsed?
@@ -434,7 +474,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..fae8d86098e 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
@@ -161,9 +153,9 @@ module IssuesHelper
issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
- def issue_header_actions_data(project, issuable, current_user)
+ def issue_header_actions_data(project, issuable, current_user, issuable_sidebar)
new_issuable_params = { issue: {}, add_related_issue: issuable.iid }
- if issuable.incident?
+ if issuable.work_item_type&.incident?
new_issuable_params[:issuable_template] = 'incident'
new_issuable_params[:issue][:issue_type] = 'incident'
end
@@ -176,6 +168,7 @@ module IssuesHelper
can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issuable).to_s,
iid: issuable.iid,
+ issuable_id: issuable.id,
is_issue_author: (issuable.author == current_user).to_s,
issue_path: issuable_path(issuable),
issue_type: issuable_display_type(issuable),
@@ -184,7 +177,8 @@ module IssuesHelper
report_abuse_path: add_category_abuse_reports_path,
reported_user_id: issuable.author.id,
reported_from_url: issue_url(issuable),
- submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable)
+ submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable),
+ issuable_email_address: issuable_sidebar.nil? ? '' : issuable_sidebar[:create_note_email]
}
end
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 50e3c3cc5fe..5cf68db0611 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -7,12 +7,10 @@ module JiraConnectHelper
{
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
- add_subscriptions_path: jira_connect_subscriptions_path,
subscriptions_path: jira_connect_subscriptions_path(format: :json),
- users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
gitlab_user_path: current_user ? user_path(current_user) : nil,
- oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data(installation).to_json : nil,
- public_key_storage_enabled: Gitlab.config.jira_connect.enable_public_keys_storage || Gitlab::CurrentSettings.jira_connect_public_key_storage_enabled?
+ oauth_metadata: jira_connect_oauth_data(installation).to_json,
+ public_key_storage_enabled: Gitlab::CurrentSettings.jira_connect_public_key_storage_enabled?
}
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/markup_helper.rb b/app/helpers/markup_helper.rb
index 4a5720e757d..91fce6d6820 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -80,9 +80,7 @@ module MarkupHelper
)
)
- # since <img> tags are stripped, this can leave empty <a> tags hanging around
- # (as our markdown wraps images in links)
- strip_empty_link_tags(text).html_safe
+ render_links(text)
end
def markdown(text, context = {})
@@ -171,9 +169,22 @@ module MarkupHelper
{ project: wiki.container }
end
- def strip_empty_link_tags(text)
+ # Sanitize and style user references links
+ #
+ # @param String text the string to be sanitized
+ #
+ # 1. Remove empty <a> tags which are caused by the <img> tags being stripped
+ # (as our markdown wraps images in links)
+ # 2. Strip all link tags, except user references, leaving just the link text
+ # 3. Add a highlight class for current user's references
+ #
+ # @return sanitized HTML string
+ def render_links(text)
scrubber = Loofah::Scrubber.new do |node|
- node.remove if node.name == 'a' && node.children.empty?
+ next unless node.name == 'a'
+ next node.remove if node.children.empty?
+ next node.replace(node.children) if node['data-reference-type'] != 'user'
+ next node.append_class('current-user') if current_user && node['data-user'] == current_user.id.to_s
end
sanitize text, scrubber: scrubber
@@ -181,7 +192,7 @@ module MarkupHelper
def markdown_toolbar_button(options = {})
data = options[:data].merge({ container: 'body' })
- css_classes = %w[gl-button btn btn-default-tertiary btn-icon js-md has-tooltip] << options[:css_class].to_s
+ css_classes = %w[gl-button btn btn-default-tertiary btn-icon btn-sm js-md has-tooltip] << options[:css_class].to_s
content_tag :button,
type: 'button',
class: css_classes.join(' '),
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index ec395baef9e..15901e13c1a 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -2,6 +2,7 @@
module MergeRequestsHelper
include Gitlab::Utils::StrongMemoize
+ include CompareHelper
def create_mr_button_from_event?(event)
create_mr_button?(from: event.branch_name, source_project: event.project)
@@ -178,6 +179,10 @@ module MergeRequestsHelper
end
end
+ def moved_mr_sidebar_enabled?
+ Feature.enabled?(:moved_mr_sidebar, @project)
+ end
+
def diffs_tab_pane_data(project, merge_request, params)
{
"is-locked": merge_request.discussion_locked?,
@@ -185,6 +190,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.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,
@@ -195,10 +201,11 @@ module MergeRequestsHelper
show_suggest_popover: show_suggest_popover?.to_s,
show_whitespace_default: @show_whitespace_default.to_s,
file_by_file_default: @file_by_file_default.to_s,
- default_suggestion_commit_message: default_suggestion_commit_message,
- 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
+ default_suggestion_commit_message: default_suggestion_commit_message(project),
+ 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,
+ new_comment_template_path: profile_comment_templates_path
}
end
@@ -225,36 +232,52 @@ module MergeRequestsHelper
current_user.review_requested_open_merge_requests_count
end
- def default_suggestion_commit_message
- @project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ def default_suggestion_commit_message(project)
+ project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
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 }
end
- def moved_mr_sidebar_enabled?
- Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request)
+ def single_file_file_by_file?
+ Feature.enabled?(:single_file_file_by_file, @project)
end
def sticky_header_data
@@ -282,6 +305,10 @@ module MergeRequestsHelper
hidden_issuable_icon(merge_request)
end
+
+ def tab_count_display(merge_request, count)
+ merge_request.preparing? ? "-" : count
+ end
end
MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper')
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 3dfd30f07db..06deaeb5e9e 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -13,7 +13,7 @@ module MirrorHelper
docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) %
- { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
end
end
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index ddd6469a9e4..201007863b2 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -6,19 +6,19 @@ module Nav
return unless current_user
menu_sections = []
+ data = { title: _('Create new...') }
- if group&.persisted?
- menu_sections.push(group_menu_section(group))
- elsif project&.persisted?
+ if project&.persisted?
menu_sections.push(project_menu_section(project))
+ elsif group&.persisted?
+ menu_sections.push(group_menu_section(group))
end
menu_sections.push(general_menu_section)
- {
- title: _("Create new..."),
- menu_sections: menu_sections.select { |x| x.fetch(:menu_items).any? }
- }
+ data[:menu_sections] = menu_sections.select { |x| x.fetch(:menu_items).any? }
+
+ data
end
private
@@ -51,11 +51,7 @@ module Nav
menu_items.push(create_epic_menu_item(group))
if can?(current_user, :admin_group_member, group)
- menu_items.push(
- invite_members_menu_item(
- href: group_group_members_path(group)
- )
- )
+ menu_items.push(invite_members_menu_item(partial: 'groups/invite_members_top_nav_link'))
end
{
@@ -102,11 +98,7 @@ module Nav
end
if can_admin_project_member?(project)
- menu_items.push(
- invite_members_menu_item(
- href: project_project_members_path(project)
- )
- )
+ menu_items.push(invite_members_menu_item(partial: 'projects/invite_members_top_nav_link'))
end
{
@@ -157,16 +149,16 @@ module Nav
}
end
- def invite_members_menu_item(href:)
+ def invite_members_menu_item(partial:)
::Gitlab::Nav::TopNavMenuItem.build(
id: 'invite',
title: s_('InviteMember|Invite members'),
- emoji: 'shaking_hands',
- href: href,
+ icon: 'shaking_hands',
+ partial: partial,
+ component: 'invite_members',
data: {
- track_action: 'click_link_invite_members',
- track_label: 'plus_menu_dropdown',
- track_property: 'navigation_top'
+ trigger_source: 'top-nav',
+ trigger_element: 'text-emoji'
}
)
end
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index fb11c183aeb..c41cf7f500f 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -64,7 +64,6 @@ module Nav
end
def build_anonymous_view_model(builder:)
- # These come from `app/views/layouts/nav/_explore.html.ham`
if explore_nav_link?(:projects)
builder.add_primary_menu_item_with_shortcut(
header: top_nav_localized_headers[:explore],
@@ -83,6 +82,15 @@ module Nav
)
end
+ if explore_nav_link?(:topics)
+ builder.add_primary_menu_item_with_shortcut(
+ header: top_nav_localized_headers[:explore],
+ active: active_nav_link?(page: topics_explore_projects_path, path: 'projects#topic'),
+ href: topics_explore_projects_path,
+ **topics_menu_item_attrs
+ )
+ end
+
if explore_nav_link?(:snippets)
builder.add_primary_menu_item_with_shortcut(
header: top_nav_localized_headers[:explore],
@@ -123,39 +131,54 @@ module Nav
builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu))
end
+ if dashboard_nav_link?(:your_work)
+ builder.add_primary_menu_item(
+ id: 'your-work',
+ header: top_nav_localized_headers[:switch_to],
+ title: _('Your work'),
+ href: dashboard_projects_path,
+ active: active_nav_link?(controller: []),
+ icon: 'work',
+ data: { **menu_data_tracking_attrs('your-work') }
+ )
+ end
+
+ if dashboard_nav_link?(:explore)
+ builder.add_primary_menu_item(
+ id: 'explore',
+ header: top_nav_localized_headers[:switch_to],
+ title: _('Explore'),
+ href: explore_projects_path,
+ active: active_nav_link?(controller: ["explore/groups", "explore/snippets"], page: ["/explore/projects", "/explore", "/explore/projects/topics"], path: ["projects#topic"]),
+ icon: 'compass',
+ data: { **menu_data_tracking_attrs('explore') }
+ )
+ end
+
if dashboard_nav_link?(:milestones)
- builder.add_primary_menu_item_with_shortcut(
- id: 'milestones',
- header: top_nav_localized_headers[:explore],
+ builder.add_shortcut(
+ id: 'milestones-shortcut',
title: _('Milestones'),
href: dashboard_milestones_path,
- active: active_nav_link?(controller: 'dashboard/milestones'),
- icon: 'clock',
- data: { **menu_data_tracking_attrs('milestones') },
- shortcut_class: 'dashboard-shortcuts-milestones'
+ css_class: 'dashboard-shortcuts-milestones'
)
end
if dashboard_nav_link?(:snippets)
- builder.add_primary_menu_item_with_shortcut(
- header: top_nav_localized_headers[:explore],
- active: active_nav_link?(controller: 'dashboard/snippets'),
- data: { qa_selector: 'snippets_link', **menu_data_tracking_attrs('snippets') },
+ builder.add_shortcut(
+ id: 'snippets-shortcut',
+ title: _('Snippets'),
href: dashboard_snippets_path,
- **snippets_menu_item_attrs
+ css_class: 'dashboard-shortcuts-snippets'
)
end
if dashboard_nav_link?(:activity)
- builder.add_primary_menu_item_with_shortcut(
- id: 'activity',
- header: top_nav_localized_headers[:explore],
+ builder.add_shortcut(
+ id: 'activity-shortcut',
title: _('Activity'),
href: activity_dashboard_path,
- active: active_nav_link?(path: 'dashboard#activity'),
- icon: 'history',
- data: { **menu_data_tracking_attrs('activity') },
- shortcut_class: 'dashboard-shortcuts-activity'
+ css_class: 'dashboard-shortcuts-activity'
)
end
@@ -180,14 +203,14 @@ module Nav
if header_link?(:admin_mode)
builder.add_secondary_menu_item(
id: 'leave_admin_mode',
- title: _('Leave Admin Mode'),
+ title: _('Leave admin mode'),
active: active_nav_link?(controller: 'admin/sessions'),
icon: 'lock-open',
href: destroy_admin_session_path,
data: { method: 'post', **menu_data_tracking_attrs('leave_admin_mode') }
)
elsif current_user.admin?
- title = _('Enter Admin Mode')
+ title = _('Enter admin mode')
builder.add_secondary_menu_item(
id: 'enter_admin_mode',
@@ -220,6 +243,15 @@ module Nav
}
end
+ def topics_menu_item_attrs
+ {
+ id: 'topics',
+ title: _('Topics'),
+ icon: 'labels',
+ shortcut_class: 'dashboard-shortcuts-topics'
+ }
+ end
+
def snippets_menu_item_attrs
{
id: 'snippets',
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index d0421cd5184..4f30b555ba0 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module NavHelper
+ extend self
+
def header_links
@header_links ||= get_header_links
end
@@ -9,10 +11,29 @@ module NavHelper
header_links.include?(link)
end
+ def page_has_sidebar?
+ defined?(@left_sidebar) && @left_sidebar
+ end
+
+ def page_has_collapsed_sidebar?
+ page_has_sidebar? && collapsed_sidebar?
+ end
+
+ def page_has_collapsed_super_sidebar?
+ page_has_sidebar? && collapsed_super_sidebar?
+ end
+
def page_with_sidebar_class
class_name = page_gutter_class
- class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar
- class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar
+
+ if show_super_sidebar?
+ class_name << 'page-with-super-sidebar' if page_has_sidebar?
+ class_name << 'page-with-super-sidebar-collapsed' if page_has_collapsed_super_sidebar?
+ else
+ class_name << 'page-with-contextual-sidebar' if page_has_sidebar?
+ class_name << 'page-with-icon-sidebar' if page_has_collapsed_sidebar?
+ end
+
class_name -= ['right-sidebar-expanded'] if defined?(@right_sidebar) && !@right_sidebar
class_name
@@ -65,8 +86,21 @@ module NavHelper
%w(dev_ops_report usage_trends)
end
- def show_super_sidebar?
- Feature.enabled?(:super_sidebar_nav, current_user) && current_user&.use_new_navigation
+ def show_super_sidebar?(user = current_user)
+ return false unless Feature.enabled?(:super_sidebar_nav, user)
+
+ # The new sidebar is not enabled for anonymous use
+ # Once we enable the new sidebar by default, this
+ # should return true
+ return false unless user
+
+ # Users who got the special `super_sidebar_nav_enrolled` enabled,
+ # see the new nav as long as they don't explicitly opt-out via the toggle
+ if user.use_new_navigation.nil? && Feature.enabled?(:super_sidebar_nav_enrolled, user)
+ true
+ else
+ !!user.use_new_navigation
+ end
end
private
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index b47f4633348..3e8872dc199 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -77,8 +77,10 @@ module NotesHelper
line_type: line_type
}
- button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
- data: data, title: 'Add a reply'
+ button_tag 'Reply...',
+ class: 'btn btn-text-field js-discussion-reply-button',
+ data: data,
+ title: 'Add a reply'
end
def note_max_access_for_user(note)
@@ -151,7 +153,6 @@ module NotesHelper
def initial_notes_data(autocomplete)
{
notesUrl: notes_url,
- notesIds: @noteable.notes.pluck(:id), # rubocop: disable CodeReuse/ActiveRecord
now: Time.now.to_i,
diffView: diff_view,
enableGFM: {
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index baeb9a477c3..8528f5f04f7 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -12,7 +12,7 @@ module OperationsHelper
def alerts_settings_data(disabled: false)
setting = project_incident_management_setting
- templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } }
+ templates = setting.available_issue_templates.map { |t| { value: t.key, text: t.name } }
{
'prometheus_activated' => prometheus_integration.manual_configuration?.to_s,
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index f9ec20bdd01..8861f1ffe9a 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -27,9 +27,14 @@ module PackagesHelper
presenter.detail_view.to_json
end
- def pypi_registry_url(project_id)
- full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project_id, package_name: '' }, true))
- full_url.sub!('://', '://__token__:<your_personal_token>@')
+ def pypi_registry_url(project)
+ full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project.id, package_name: '' }, true))
+
+ if project.project_feature.public_packages?
+ full_url
+ else
+ full_url.sub!('://', '://__token__:<your_personal_token>@')
+ end
end
def composer_registry_url(group_id)
@@ -64,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/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 4a218984af1..9bcabd7d9c6 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -175,7 +175,7 @@ module PageLayoutHelper
current_emoji: user.status.emoji.to_s,
current_message: user.status.message.to_s,
current_availability: user.status.availability.to_s,
- current_clear_status_after: user.status.clear_status_at&.to_s(:iso8601)
+ current_clear_status_after: user_clear_status_at(user)
})
end
diff --git a/app/helpers/plan_limits_helper.rb b/app/helpers/plan_limits_helper.rb
new file mode 100644
index 00000000000..74653ad3511
--- /dev/null
+++ b/app/helpers/plan_limits_helper.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module PlanLimitsHelper
+ def plan_limit_setting_description(limit_name)
+ case limit_name
+ when :ci_pipeline_size
+ s_('AdminSettings|Maximum number of jobs in a single pipeline')
+ when :ci_active_jobs
+ s_('AdminSettings|Total number of jobs in currently active pipelines')
+ when :ci_project_subscriptions
+ s_('AdminSettings|Maximum number of pipeline subscriptions to and from a project')
+ when :ci_pipeline_schedules
+ s_('AdminSettings|Maximum number of pipeline schedules')
+ when :ci_needs_size_limit
+ s_('AdminSettings|Maximum number of DAG dependencies that a job can have')
+ when :ci_registered_group_runners
+ s_('AdminSettings|Maximum number of runners registered per group')
+ when :ci_registered_project_runners
+ s_('AdminSettings|Maximum number of runners registered per project')
+ when :pipeline_hierarchy_size
+ s_("AdminSettings|Maximum number of downstream pipelines in a pipeline's hierarchy tree")
+ else
+ raise ArgumentError, "No description available for plan limit #{limit_name}"
+ end
+ end
+end
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/product_analytics_helper.rb b/app/helpers/product_analytics_helper.rb
deleted file mode 100644
index b040a8581b2..00000000000
--- a/app/helpers/product_analytics_helper.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module ProductAnalyticsHelper
- def product_analytics_tracker_url
- ProductAnalytics::Tracker::URL
- end
-
- def product_analytics_tracker_collector_url
- ProductAnalytics::Tracker::COLLECTOR_URL
- end
-end
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index 471565d162c..fc4ad10db21 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -5,8 +5,7 @@ module Projects::ErrorTrackingHelper
error_tracking_enabled = !!project.error_tracking_setting&.enabled?
{
- 'index-path' => project_error_tracking_index_path(project,
- format: :json),
+ 'index-path' => project_error_tracking_index_path(project, format: :json),
'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s,
'enable-error-tracking-link' => project_settings_operations_path(project),
'error-tracking-enabled' => error_tracking_enabled.to_s,
diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb
index 55216d412a5..7aef208447a 100644
--- a/app/helpers/projects/ml/experiments_helper.rb
+++ b/app/helpers/projects/ml/experiments_helper.rb
@@ -5,20 +5,10 @@ module Projects
require 'json'
include ActionView::Helpers::NumberHelper
- def show_candidate_view_model(candidate)
+ def experiment_as_data(experiment)
data = {
- candidate: {
- params: candidate.params,
- metrics: candidate.latest_metrics,
- info: {
- iid: candidate.iid,
- path_to_artifact: link_to_artifact(candidate),
- experiment_name: candidate.experiment.name,
- path_to_experiment: link_to_experiment(candidate.project, candidate.experiment),
- status: candidate.status
- },
- metadata: candidate.metadata
- }
+ name: experiment.name,
+ path: link_to_experiment(experiment.project, experiment)
}
Gitlab::Json.generate(data)
@@ -29,6 +19,7 @@ module Projects
{
**candidate.params.to_h { |p| [p.name, p.value] },
**candidate.latest_metrics.to_h { |m| [m.name, number_with_precision(m.value, precision: 4)] },
+ ci_job: job_info(candidate),
artifact: link_to_artifact(candidate),
details: link_to_details(candidate),
name: candidate.name,
@@ -81,6 +72,17 @@ module Projects
project_ml_candidate_path(candidate.project, candidate.iid)
end
+ def job_info(candidate)
+ return unless candidate.from_ci?
+
+ build = candidate.ci_build
+
+ {
+ path: project_job_path(build.project, build),
+ name: build.name
+ }
+ end
+
def link_to_experiment(project, experiment)
project_ml_experiment_path(project, experiment.iid)
end
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index 5c62920cd89..0239253d8f0 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -7,8 +7,7 @@ module Projects
def js_pipeline_tabs_data(project, pipeline, _user)
{
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/settings/branch_rules_helper.rb b/app/helpers/projects/settings/branch_rules_helper.rb
new file mode 100644
index 00000000000..e53275d8183
--- /dev/null
+++ b/app/helpers/projects/settings/branch_rules_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Projects
+ module Settings
+ module BranchRulesHelper
+ def branch_rules_data(project)
+ {
+ project_path: project.full_path,
+ protected_branches_path: project_settings_repository_path(project, anchor: 'js-protected-branches-settings'),
+ approval_rules_path: project_settings_merge_requests_path(project,
+ anchor: 'js-merge-request-approval-settings'),
+ status_checks_path: project_settings_merge_requests_path(project, anchor: 'js-merge-request-settings'),
+ branches_path: project_branches_path(project),
+ show_status_checks: 'false',
+ show_approvers: 'false',
+ show_code_owners: 'false'
+ }
+ end
+ end
+ end
+end
+
+Projects::Settings::BranchRulesHelper.prepend_mod
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 04190bc442b..1e87d2861d4 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -2,6 +2,8 @@
module ProjectsHelper
include Gitlab::Utils::StrongMemoize
+ include CompareHelper
+ include Gitlab::Allowable
def project_incident_management_setting
@project_incident_management_setting ||= @project.incident_management_setting ||
@@ -130,12 +132,22 @@ module ProjectsHelper
source_default_branch = source_project.default_branch
+ merge_request =
+ MergeRequest.opened
+ .from_project(project).of_projects(source_project.id).from_source_branches(ref).first
+
{
+ 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: can_sync_branch?(project, 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_merge_request_path(project, source_project, ref, merge_request),
+ view_mr_path: merge_request && project_merge_request_path(source_project, merge_request),
behind_compare_path: project_compare_path(
source_project, from: ref, to: source_default_branch, from_project_id: project.id
)
@@ -217,6 +229,18 @@ module ProjectsHelper
.load_in_batch_for_projects(projects)
end
+ def last_pipeline_from_status_cache(project)
+ if Feature.enabled?(:last_pipeline_from_pipeline_status, project)
+ pipeline_status = project.pipeline_status
+ return unless pipeline_status.has_status?
+
+ # commits have far more attributes than id, but last_pipeline only requires sha
+ return Commit.from_hash({ id: pipeline_status.sha }, project).last_pipeline
+ end
+
+ project.last_pipeline
+ end
+
def show_no_ssh_key_message?
Gitlab::CurrentSettings.user_show_add_ssh_key_message? &&
cookies[:hide_no_ssh_message].blank? &&
@@ -235,6 +259,14 @@ module ProjectsHelper
cookies["hide_auto_devops_implicitly_enabled_banner_#{project.id}".to_sym].blank?
end
+ def show_mobile_devops_project_promo?(project)
+ return false unless ::Feature.enabled?(:mobile_devops_projects_promo, project)
+
+ return false unless (project.project_setting.target_platforms & ::ProjectSetting::ALLOWED_TARGET_PLATFORMS).any?
+
+ cookies["hide_mobile_devops_promo_#{project.id}".to_sym].blank?
+ end
+
def no_password_message
push_pull_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('topics/git/terminology', anchor: 'pull-and-push') }
clone_with_https_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('gitlab-basics/start-using-git', anchor: 'clone-with-https') }
@@ -474,7 +506,7 @@ module ProjectsHelper
def clusters_deprecation_alert_message
if has_active_license?
- s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.')
+ s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}. Contact GitLab Support if you have any additional questions.')
else
s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.')
end
@@ -498,8 +530,38 @@ module ProjectsHelper
format_cached_count(1000, number)
end
+ def remote_mirror_setting_enabled?
+ false
+ end
+
+ def http_clone_url_to_repo(project)
+ project.http_url_to_repo
+ end
+
+ def ssh_clone_url_to_repo(project)
+ project.ssh_url_to_repo
+ end
+
private
+ def create_merge_request_path(project, source_project, ref, merge_request)
+ return if merge_request.present?
+ return unless can?(current_user, :create_merge_request_from, project)
+ return unless can?(current_user, :create_merge_request_in, source_project)
+
+ create_mr_path(
+ from: ref,
+ source_project: project,
+ to: source_project.default_branch,
+ target_project: source_project)
+ end
+
+ def can_sync_branch?(project, ref)
+ return false unless project.repository.branch_exists?(ref)
+
+ ::Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(ref)
+ end
+
def localized_access_names
{
Gitlab::Access::NO_ACCESS => _('No access'),
@@ -753,7 +815,7 @@ module ProjectsHelper
end
def show_visibility_confirm_modal?(project)
- project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
+ project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
def confirm_reduce_visibility_message(project)
@@ -824,4 +886,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/protected_refs_helper.rb b/app/helpers/protected_refs_helper.rb
new file mode 100644
index 00000000000..60f298e0e8d
--- /dev/null
+++ b/app/helpers/protected_refs_helper.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ProtectedRefsHelper
+ include Gitlab::Utils::StrongMemoize
+
+ def protected_access_levels_for_dropdowns
+ {
+ create_access_levels: protected_access_level_dropdown_roles,
+ push_access_levels: protected_access_level_dropdown_roles,
+ merge_access_levels: protected_access_level_dropdown_roles
+ }
+ end
+
+ def protected_access_level_dropdown_roles
+ roles = ProtectedRef::AccessLevel.human_access_levels.map do |id, text|
+ { id: id, text: text, before_divider: true }
+ end
+
+ { roles: roles }
+ end
+ strong_memoize_attr(:protected_access_level_dropdown_roles)
+end
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
index 1724e11a6f1..fcd560dbe8c 100644
--- a/app/helpers/registrations_helper.rb
+++ b/app/helpers/registrations_helper.rb
@@ -11,8 +11,8 @@ module RegistrationsHelper
}
end
- def arkose_labs_challenge_enabled?
- false
+ def signup_box_template
+ 'devise/shared/signup_box'
end
end
diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb
index f4732e398f0..9b4aafe49b4 100644
--- a/app/helpers/routing/projects_helper.rb
+++ b/app/helpers/routing/projects_helper.rb
@@ -43,14 +43,12 @@ module Routing
end
def work_item_url(entity, *args)
- unless Feature.enabled?(:use_iid_in_work_items_path, entity.project.group)
- return project_work_items_url(entity.project, entity.id, *args)
- end
-
- options = args.first || {}
- options[:iid_path] = true
+ # TODO: we do not have a route to access group level work items yet.
+ # That is to be done as part of view group level work item issue:
+ # see https://gitlab.com/gitlab-org/gitlab/-/work_items/393987
+ return unless entity.project.present?
- project_work_items_url(entity.project, entity.iid, **options)
+ project_work_items_url(entity.project, entity.iid, *args)
end
def merge_request_url(entity, *args)
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/safe_format_helper.rb b/app/helpers/safe_format_helper.rb
new file mode 100644
index 00000000000..c79e8b50a1a
--- /dev/null
+++ b/app/helpers/safe_format_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module SafeFormatHelper
+ # Returns a HTML-safe string where +format+ and +args+ are escaped via
+ # `html_escape` if they are not marked as HTML-safe.
+ #
+ # Argument +format+ must not be marked as HTML-safe via `.html_safe`.
+ #
+ # Example:
+ # safe_format('Some %{open}bold%{close} text.', open: '<strong>'.html_safe, close: '</strong>'.html_safe)
+ # # => 'Some <strong>bold</strong>'
+ # safe_format('See %{user_input}', user_input: '<b>bold</b>')
+ # # => 'See &lt;b&gt;bold&lt;/b&gt;
+ #
+ def safe_format(format, **args)
+ raise ArgumentError, 'Argument `format` must not be marked as html_safe!' if format.html_safe?
+
+ format(
+ html_escape(format),
+ args.transform_values { |value| html_escape(value) }
+ ).html_safe
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index ca5436ff019..2187126272d 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -204,7 +204,9 @@ module SearchHelper
if search_has_project?
hash[:project] = { id: @project.id, name: @project.name }
- hash[:project_metadata] = { issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project) }
+ hash[:project_metadata] = { mr_path: project_merge_requests_path(@project) }
+ hash[:project_metadata][:issues_path] = project_issues_path(@project) if @project.feature_available?(:issues, current_user)
+
hash[:code_search] = search_scope.nil?
hash[:ref] = @ref if @ref && can?(current_user, :read_code, @project)
end
@@ -244,7 +246,7 @@ module SearchHelper
# Autocomplete results for settings pages, for admins
def default_autocomplete_admin
[
- { category: "Settings", label: _("Admin Section"), url: admin_root_path }
+ { category: "Jump to", label: _("Admin Area / Dashboard"), url: admin_root_path }
]
end
@@ -339,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/sessions_helper.rb b/app/helpers/sessions_helper.rb
index 8251e1cba8a..9ef347fff16 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -48,4 +48,8 @@ module SessionsHelper
# Moved to Gitlab::Utils::Email in 15.9
Gitlab::Utils::Email.obfuscated_email(email)
end
+
+ def remember_me_enabled?
+ Gitlab::CurrentSettings.remember_me_enabled?
+ end
end
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 27020738515..02a912d0227 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -23,40 +23,130 @@ module SidebarsHelper
end
end
- def project_sidebar_context(project, user, current_ref, ref_type: nil)
+ def project_sidebar_context(project, user, current_ref, ref_type: nil, **args)
context_data = project_sidebar_context_data(project, user, current_ref, ref_type: ref_type)
- Sidebars::Projects::Context.new(**context_data)
+ Sidebars::Projects::Context.new(**context_data, **args)
end
- def group_sidebar_context(group, user)
+ def group_sidebar_context(group, user, **args)
context_data = group_sidebar_context_data(group, user)
- Sidebars::Groups::Context.new(**context_data)
+ Sidebars::Groups::Context.new(**context_data, **args)
end
- def super_sidebar_context(user, group:, project:)
+ def your_work_sidebar_context(user, **args)
+ context_data = your_work_context_data(user)
+
+ Sidebars::Context.new(**context_data, **args)
+ end
+
+ 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,
name: user.name,
username: user.username,
avatar_url: user.avatar_url,
- assigned_open_issues_count: user.assigned_open_issues_count,
- todos_pending_count: user.todos_pending_count,
+ has_link_to_profile: current_user_menu?(:profile),
+ link_to_profile: user_url(user),
+ logo_url: current_appearance&.header_logo_path,
+ status: user_status_menu_data(user),
+ settings: {
+ has_settings: current_user_menu?(:settings),
+ profile_path: profile_path,
+ profile_preferences_path: profile_preferences_path
+ },
+ user_counts: {
+ assigned_issues: user.assigned_open_issues_count,
+ assigned_merge_requests: user.assigned_open_merge_requests_count,
+ review_requested_merge_requests: user.review_requested_open_merge_requests_count,
+ todos: user.todos_pending_count,
+ last_update: time_in_milliseconds
+ },
+ can_sign_out: current_user_menu?(:sign_out),
+ sign_out_link: destroy_user_session_path,
issues_dashboard_path: issues_dashboard_path(assignee_username: user.username),
- total_merge_requests_count: user_merge_requests_counts[:total],
+ todos_dashboard_path: dashboard_todos_path,
create_new_menu_groups: create_new_menu_groups(group: group, project: project),
merge_request_menu: create_merge_request_menu(user),
+ 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,
whats_new_version_digest: whats_new_version_digest,
show_version_check: show_version_check?,
gitlab_version: Gitlab.version_info,
- gitlab_version_check: gitlab_version_check
+ gitlab_version_check: gitlab_version_check,
+ 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),
+ context_switcher_links: context_switcher_links,
+ search: search_data,
+ pinned_items: user.pinned_nav_items[panel_type] || super_sidebar_default_pins(panel_type),
+ panel_type: panel_type,
+ update_pins_url: pins_url,
+ is_impersonating: impersonating?,
+ stop_impersonation_path: admin_impersonation_path,
+ shortcut_links: shortcut_links(user, project: project)
}
end
+ def super_sidebar_nav_panel(
+ nav: nil, project: nil, user: nil, group: nil, current_ref: nil, ref_type: nil,
+ viewed_user: nil)
+ context_adds = { route_is_active: method(:active_nav_link?), is_super_sidebar: true }
+ case nav
+ when 'project'
+ context = project_sidebar_context(project, user, current_ref, ref_type: ref_type, **context_adds)
+ Sidebars::Projects::SuperSidebarPanel.new(context)
+ when 'group'
+ context = group_sidebar_context(group, user, **context_adds)
+ Sidebars::Groups::SuperSidebarPanel.new(context)
+ when 'profile'
+ context = Sidebars::Context.new(current_user: user, container: user, **context_adds)
+ Sidebars::UserSettings::Panel.new(context)
+ when 'user_profile'
+ context = Sidebars::Context.new(current_user: user, container: viewed_user, **context_adds)
+ 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)
+ end
+ end
+
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
@@ -64,11 +154,19 @@ module SidebarsHelper
{
name: show_headers ? section[:title] : '',
items: section[:menu_items].map do |item|
- {
- text: item[:title],
- href: item[:href]
- }
- end
+ {
+ text: item[:title],
+ href: item[:href].presence,
+ component: item[:component].presence,
+ 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
}
end
end
@@ -81,12 +179,26 @@ module SidebarsHelper
{
text: _('Assigned'),
href: merge_requests_dashboard_path(assignee_username: user.username),
- count: user_merge_requests_counts[:assigned]
+ count: user.assigned_open_merge_requests_count,
+ userCount: 'assigned_merge_requests',
+ 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.review_requested_open_merge_requests_count,
+ userCount: 'review_requested_merge_requests',
+ extraAttrs: {
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'merge_requests_to_review',
+ 'data-track-property': 'nav_core_menu',
+ class: 'dashboard-shortcuts-review_requests'
+ }
}
]
}
@@ -160,6 +272,131 @@ module SidebarsHelper
container: group
}
end
+
+ def your_work_context_data(user)
+ {
+ current_user: user,
+ container: user,
+ show_security_dashboard: false
+ }
+ end
+
+ def super_sidebar_current_context(project: nil, group: nil)
+ if project&.persisted?
+ return {
+ namespace: 'projects',
+ item: {
+ id: project.id,
+ name: project.name,
+ namespace: project.full_name,
+ webUrl: project_path(project),
+ avatarUrl: project.avatar_url
+ }
+ }
+ end
+
+ if group&.persisted?
+ return {
+ namespace: 'groups',
+ item: {
+ id: group.id,
+ name: group.name,
+ namespace: group.full_name,
+ webUrl: group_path(group),
+ avatarUrl: group.avatar_url
+ }
+ }
+ end
+
+ {}
+ 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' }
+ ]
+
+ # Usually, using current_user.admin? is discouraged because it does not
+ # check for admin mode, but since here we want to check admin? and admin mode
+ # separately, we'll have to ignore the cop rule.
+ # rubocop: disable Cop/UserAdmin
+ if current_user&.can_admin_all_resources?
+ links.append(
+ { title: s_('Navigation|Admin Area'), link: admin_root_path, icon: 'admin' }
+ )
+ end
+
+ if Gitlab::CurrentSettings.admin_mode
+ if header_link?(:admin_mode)
+ links.append(
+ {
+ title: s_('Navigation|Leave admin mode'),
+ link: destroy_admin_session_path,
+ icon: 'lock-open',
+ data_method: 'post'
+ }
+ )
+ elsif current_user&.admin?
+ links.append(
+ {
+ title: s_('Navigation|Enter admin mode'),
+ link: new_admin_session_path,
+ icon: 'lock'
+ }
+ )
+ end
+ end
+ # rubocop: enable Cop/UserAdmin
+
+ links
+ end
+
+ def impersonating?
+ !!session[:impersonator_id]
+ end
+
+ def shortcut_links(user, project: nil)
+ 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'
+ }
+ ]
+
+ if project&.persisted? && can?(user, :create_issue, project)
+ shortcut_links << {
+ title: _('Create a new issue'),
+ href: new_project_issue_path(project),
+ css_class: 'shortcuts-new-issue'
+ }
+ end
+
+ shortcut_links
+ end
+
+ def super_sidebar_default_pins(panel_type)
+ case panel_type
+ when 'project'
+ [:project_issue_list, :project_merge_request_list]
+ when 'group'
+ [:group_issue_list, :group_merge_request_list]
+ else
+ []
+ end
+ end
end
SidebarsHelper.prepend_mod_with('SidebarsHelper')
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 8558c664977..2f9117a74be 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -45,30 +45,35 @@ module SnippetsHelper
def embedded_raw_snippet_button(snippet, blob)
return if blob.empty? || blob.binary? || blob.stored_externally?
- link_to(external_snippet_icon('doc-code'),
- gitlab_raw_snippet_blob_url(snippet, blob.path),
- class: 'gl-button btn btn-default',
- target: '_blank',
- rel: 'noopener noreferrer',
- title: 'Open raw')
+ link_to(
+ external_snippet_icon('doc-code'),
+ gitlab_raw_snippet_blob_url(snippet, blob.path),
+ class: 'gl-button btn btn-default',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ title: 'Open raw'
+ )
end
def embedded_snippet_download_button(snippet, blob)
- link_to(external_snippet_icon('download'),
- gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false),
- class: 'gl-button btn btn-default',
- target: '_blank',
- title: 'Download',
- rel: 'noopener noreferrer')
+ link_to(
+ external_snippet_icon('download'),
+ gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false),
+ class: 'gl-button btn btn-default',
+ target: '_blank',
+ title: 'Download',
+ rel: 'noopener noreferrer'
+ )
end
def embedded_copy_snippet_button(blob)
return unless blob.rendered_as_text?(ignore_errors: false)
- content_tag(:button,
- class: 'gl-button btn btn-default copy-to-clipboard-btn',
- title: 'Copy snippet contents',
- onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')"
+ content_tag(
+ :button,
+ class: 'gl-button btn btn-default copy-to-clipboard-btn',
+ title: 'Copy snippet contents',
+ onclick: "copyToClipboard('.blob-content[data-blob-id=\"#{blob.id}\"] > pre')"
) do
external_snippet_icon('copy-to-clipboard')
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 4a9596a1347..9038d972f65 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -227,7 +227,7 @@ module SortingHelper
options.concat([due_date_option]) if viewing_issues
options.concat([popularity_option, label_priority_option])
- options.concat([merged_option]) if viewing_merge_requests
+ options.concat([merged_option]) if can_sort_by_merged_date?(viewing_merge_requests)
options.concat([relative_position_option]) if viewing_issues
options.concat([title_option])
@@ -237,6 +237,10 @@ module SortingHelper
false
end
+ def can_sort_by_merged_date?(viewing_merge_requests)
+ viewing_merge_requests && %w[all merged].include?(params[:state])
+ end
+
def due_date_option
{ value: sort_value_due_date, text: sort_title_due_date, href: page_filter_path(sort: sort_value_due_date) }
end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index c38d69df8e4..c8dd7f59b43 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -151,8 +151,6 @@ module SubmoduleHelper
if uri.scheme.in?(VALID_SUBMODULE_PROTOCOLS)
uri.to_s
- else
- nil
end
rescue URI::InvalidURIError
nil
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 3e5f63796b2..d7ca76f6a8a 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',
+ 'merged' => 'merge',
+ 'merge' => 'merge',
'opened' => 'issues',
'closed' => 'issue-close',
'time_tracking' => 'timer',
@@ -42,8 +42,6 @@ module SystemNoteHelper
'severity' => 'information-o',
'cloned' => 'documents',
'issue_type' => 'pencil',
- 'attention_requested' => 'user',
- 'attention_request_removed' => 'user',
'contact' => 'users',
'timeline_event' => 'clock',
'relate_to_child' => 'link',
@@ -53,7 +51,13 @@ 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'
+ elsif note.system_note_metadata&.action == 'merge' && note.for_merge_request?
+ 'mr-system-note-empty'
+ else
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
end
def icon_for_system_note(note)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 4a9dd30a5a2..9b0810f3d17 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -232,13 +232,15 @@ module TodosHelper
''
end
+ due_date =
+ if is_due_today
+ _("today")
+ else
+ l(todo.target.due_date, format: Date::DATE_FORMATS[:medium])
+ end
+
content = content_tag(:span, class: css_class) do
- format(s_("Todos|Due %{due_date}"), due_date: if is_due_today
- _("today")
- else
- l(todo.target.due_date,
- format: Date::DATE_FORMATS[:medium])
- end)
+ format(s_("Todos|Due %{due_date}"), due_date: due_date)
end
"#{content} &middot;".html_safe
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index 2b8368dd29f..af3ac495164 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -11,9 +11,11 @@ module Users
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout'
MERGE_REQUEST_SETTINGS_MOVED_CALLOUT = 'merge_request_settings_moved_callout'
+ PAGES_MOVED_CALLOUT = 'pages_moved_callout'
REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze
WEB_HOOK_DISABLED = 'web_hook_disabled'
ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner'
+ BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -59,42 +61,47 @@ module Users
!user_dismissed?(SECURITY_NEWSLETTER_CALLOUT)
end
- def web_hook_disabled_dismissed?(project)
- return false unless project
-
- last_failure = Gitlab::Redis::SharedState.with do |redis|
- key = "web_hooks:last_failure:project-#{project.id}"
- redis.get(key)
- end
+ def web_hook_disabled_dismissed?(object)
+ return false unless object.is_a?(::WebHooks::HasWebHooks)
- last_failure = DateTime.parse(last_failure) if last_failure
-
- user_dismissed?(WEB_HOOK_DISABLED, last_failure, project: project)
+ user_dismissed?(WEB_HOOK_DISABLED, object.last_webhook_failure, object: object)
end
def show_merge_request_settings_callout?(project)
!user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT) && project.merge_requests_enabled?
end
+ def show_pages_menu_callout?
+ !user_dismissed?(PAGES_MOVED_CALLOUT)
+ end
+
+ def show_branch_rules_info?
+ !user_dismissed?(BRANCH_RULES_INFO_CALLOUT)
+ end
+
def ultimate_feature_removal_banner_dismissed?(project)
return false unless project
- user_dismissed?(ULTIMATE_FEATURE_REMOVAL_BANNER, project: project)
+ user_dismissed?(ULTIMATE_FEATURE_REMOVAL_BANNER, object: project)
end
private
- def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, project: nil)
+ def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, object: nil)
return false unless current_user
query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than }
- if project
- current_user.dismissed_callout_for_project?(project: project, **query)
+ if object
+ dismissed_callout?(object, query)
else
current_user.dismissed_callout?(**query)
end
end
+
+ def dismissed_callout?(object, query)
+ current_user.dismissed_callout_for_project?(project: object, **query)
+ end
end
end
diff --git a/app/helpers/users/group_callouts_helper.rb b/app/helpers/users/group_callouts_helper.rb
index 0aa4eb89499..92cf41400e7 100644
--- a/app/helpers/users/group_callouts_helper.rb
+++ b/app/helpers/users/group_callouts_helper.rb
@@ -17,9 +17,11 @@ module Users
def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil)
return false unless current_user
- current_user.dismissed_callout_for_group?(feature_name: feature_name,
- group: group,
- ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
+ current_user.dismissed_callout_for_group?(
+ feature_name: feature_name,
+ group: group,
+ ignore_dismissal_earlier_than: ignore_dismissal_earlier_than
+ )
end
def just_created?
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 62b9eb2b506..60230d58e30 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -8,10 +8,15 @@ module UsersHelper
}
end
+ def user_clear_status_at(user)
+ # The user.status can be nil when the user has no status, so we need to protect against that case.
+ # iso8601 is the official RFC supported format for frontend parsing of date:
+ # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date
+ user.status&.clear_status_at&.to_s(:iso8601)
+ end
+
def user_link(user)
- link_to(user.name, user_path(user),
- title: user.email,
- class: 'has-tooltip commit-committer-link')
+ link_to(user.name, user_path(user), title: user.email, class: 'has-tooltip commit-committer-link')
end
def user_email_help_text(user)
@@ -53,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
@@ -79,9 +93,9 @@ module UsersHelper
return unless user.status
content_tag :span,
- class: 'user-status-emoji has-tooltip',
- title: user.status.message_html,
- data: { html: true, placement: 'top' } do
+ class: 'user-status-emoji has-tooltip',
+ title: user.status.message_html,
+ data: { html: true, placement: 'top' } do
emoji_icon user.status.emoji
end
end
@@ -168,6 +182,16 @@ module UsersHelper
user.public_email.present?
end
+ def user_profile_tabs_app_data(user)
+ {
+ 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,
+ user_id: user.id
+ }
+ end
+
private
def admin_users_paths
@@ -211,14 +235,6 @@ module UsersHelper
tabs
end
- def trials_link_url
- 'https://about.gitlab.com/free-trial/'
- end
-
- def trials_allowed?(user)
- false
- end
-
def get_current_user_menu_items
items = []
@@ -229,7 +245,6 @@ module UsersHelper
items << :help
items << :profile if can?(current_user, :read_user, current_user)
items << :settings if can?(current_user, :update_user, current_user)
- items << :start_trial if trials_allowed?(current_user)
items
end
diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb
index 1fec0a916b8..dc8ef4e44be 100644
--- a/app/helpers/version_check_helper.rb
+++ b/app/helpers/version_check_helper.rb
@@ -22,25 +22,12 @@ module VersionCheckHelper
end
def link_to_version
+ link = link_to(Gitlab::Source.ref, Gitlab::Source.release_url)
+
if Gitlab.pre_release?
- commit_link = link_to(Gitlab.revision, source_host_url + namespace_project_commits_path(source_code_group, source_code_project, Gitlab.revision))
- [Gitlab::VERSION, content_tag(:small, commit_link)].join(' ').html_safe
+ [Gitlab::VERSION, content_tag(:small, link)].join(' ').html_safe
else
- link_to Gitlab::VERSION, source_host_url + namespace_project_tag_path(source_code_group, source_code_project, "v#{Gitlab::VERSION}")
+ link
end
end
-
- def source_host_url
- Gitlab::Saas.com_url
- end
-
- def source_code_group
- 'gitlab-org'
- end
-
- def source_code_project
- 'gitlab-foss'
- end
end
-
-VersionCheckHelper.prepend_mod
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 5ed341ee5e5..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
@@ -44,9 +44,8 @@ module VisibilityLevelHelper
Gitlab::CurrentSettings.restricted_visibility_levels || []
end
- delegate :default_project_visibility,
- :default_group_visibility,
- to: :'Gitlab::CurrentSettings.current_application_settings'
+ delegate :default_project_visibility, :default_group_visibility,
+ to: :'Gitlab::CurrentSettings.current_application_settings'
def disallowed_visibility_level?(form_model, level)
return false unless form_model.respond_to?(:visibility_level_allowed?)
@@ -126,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/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb
index 514db6ba8a2..ad792f761f8 100644
--- a/app/helpers/web_hooks/web_hooks_helper.rb
+++ b/app/helpers/web_hooks/web_hooks_helper.rb
@@ -4,19 +4,31 @@ module WebHooks
module WebHooksHelper
def show_project_hook_failed_callout?(project:)
return false if project_hook_page?
+
+ show_hook_failed_callout?(project)
+ end
+
+ private
+
+ def show_hook_failed_callout?(object)
return false unless current_user
- return false unless Ability.allowed?(current_user, :read_web_hooks, project)
+
+ return false unless can_access_web_hooks?(object)
# Assumes include of Users::CalloutsHelper
- return false if web_hook_disabled_dismissed?(project)
+ return false if web_hook_disabled_dismissed?(object)
- project.fetch_web_hook_failure
+ object.fetch_web_hook_failure
end
- private
-
def project_hook_page?
current_controller?('projects/hooks') || current_controller?('projects/hook_logs')
end
+
+ def can_access_web_hooks?(object)
+ Ability.allowed?(current_user, :admin_project, object)
+ end
end
end
+
+WebHooks::WebHooksHelper.prepend_mod
diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb
index efa9a2bd463..9036c7c8347 100644
--- a/app/helpers/work_items_helper.rb
+++ b/app/helpers/work_items_helper.rb
@@ -6,7 +6,9 @@ module WorkItemsHelper
full_path: project.full_path,
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')
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
+ new_comment_template_path: profile_comment_templates_path,
+ report_abuse_path: add_category_abuse_reports_path
}
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 58843435fa0..0328d262dc7 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -5,18 +5,32 @@ module Emails
def new_issue_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_new_thread(@issue, issue_thread_options(@issue.author_id, reason))
+ mail_new_thread(
+ @issue,
+ issue_thread_options(
+ @issue.author_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def issue_due_email(recipient_id, issue_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(@issue.author_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(@issue.author_id, reason, confidentiality: @issue.confidential?))
end
def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -26,7 +40,14 @@ module Emails
@previous_assignees = []
@previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -35,7 +56,14 @@ module Emails
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id, reason = nil)
@@ -43,13 +71,27 @@ module Emails
@label_names = label_names
@labels_url = project_labels_url(@project)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def removed_milestone_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def changed_milestone_issue_email(recipient_id, issue_id, milestone, updated_by_user_id, reason = nil)
@@ -57,9 +99,14 @@ module Emails
@milestone = milestone
@milestone_url = milestone_url(@milestone)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason).merge({
- template_name: 'changed_milestone_email'
- }))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ ).merge({ template_name: 'changed_milestone_email' })
+ )
end
def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil)
@@ -67,7 +114,14 @@ module Emails
@issue_status = status
@updated_by = User.find(updated_by_user_id)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, reason))
+ mail_answer_thread(
+ @issue,
+ issue_thread_options(
+ updated_by_user_id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def issue_moved_email(recipient, issue, new_issue, updated_by_user, reason = nil)
@@ -76,7 +130,14 @@ module Emails
@new_issue = new_issue
@new_project = new_issue.project
@can_access_project = recipient.can?(:read_project, @new_project)
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason))
+ mail_answer_thread(
+ issue,
+ issue_thread_options(
+ updated_by_user.id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def issue_cloned_email(recipient, issue, new_issue, updated_by_user, reason = nil)
@@ -86,7 +147,14 @@ module Emails
@issue = issue
@new_issue = new_issue
@can_access_project = recipient.can?(:read_project, @new_issue.project)
- mail_answer_thread(issue, issue_thread_options(updated_by_user.id, reason))
+ mail_answer_thread(
+ issue,
+ issue_thread_options(
+ updated_by_user.id,
+ reason,
+ confidentiality: @issue.confidential?
+ )
+ )
end
def import_issues_csv_email(user_id, project_id, results)
@@ -100,18 +168,7 @@ module Emails
end
def issues_csv_email(user, project, csv_data, export_status)
- @project = project
- @count = export_status.fetch(:rows_expected)
- @written_count = export_status.fetch(:rows_written)
- @truncated = export_status.fetch(:truncated)
- @size_limit = ActiveSupport::NumberHelper
- .number_to_human_size(ExportCsv::BaseService::TARGET_FILESIZE)
-
- filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
- attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
- email_with_layout(
- to: user.notification_email_for(@project.group),
- subject: subject("Exported issues"))
+ csv_email(user, project, csv_data, export_status, 'issues')
end
private
@@ -126,12 +183,14 @@ module Emails
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
- def issue_thread_options(sender_id, reason)
+ def issue_thread_options(sender_id, reason, confidentiality: false)
+ confidentiality = false if confidentiality.nil?
{
from: sender(sender_id),
to: @recipient.notification_email_for(@project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
- 'X-GitLab-NotificationReason' => reason
+ 'X-GitLab-NotificationReason' => reason,
+ 'X-GitLab-ConfidentialIssue' => confidentiality
}
end
end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 5b1750400d8..54a4c4be6a8 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -153,7 +153,7 @@ module Emails
Gitlab::I18n.with_locale(@user.preferred_language) do
email_with_layout(
to: @user.notification_email_or_default,
- subject: subject(_("Attempted sign in to %{host} using a wrong two-factor authentication code") % { host: Gitlab.config.gitlab.host }))
+ subject: subject(_("Attempted sign in to %{host} using an incorrect verification code") % { host: Gitlab.config.gitlab.host }))
end
end
@@ -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/projects.rb b/app/mailers/emails/projects.rb
index 4bb624c27e9..63eb4eb8fd8 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -7,29 +7,37 @@ module Emails
@project = Project.find project_id
@target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
- mail_with_locale(to: @user.notification_email_for(@project.group),
- subject: subject("Project was moved"))
+ mail_with_locale(
+ to: @user.notification_email_for(@project.group),
+ subject: subject("Project was moved")
+ )
end
def project_was_exported_email(current_user, project)
@project = project
- mail_with_locale(to: current_user.notification_email_for(project.group),
- subject: subject("Project was exported"))
+ mail_with_locale(
+ to: current_user.notification_email_for(project.group),
+ subject: subject("Project was exported")
+ )
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
- mail_with_locale(to: current_user.notification_email_for(@project.group),
- subject: subject("Project export error"))
+ mail_with_locale(
+ to: current_user.notification_email_for(@project.group),
+ subject: subject("Project export error")
+ )
end
def repository_cleanup_success_email(project, user)
@project = project
@user = user
- mail_with_locale(to: user.notification_email_for(project.group),
- subject: subject("Project cleanup has completed"))
+ mail_with_locale(
+ to: user.notification_email_for(project.group),
+ subject: subject("Project cleanup has completed")
+ )
end
def repository_cleanup_failure_email(project, user, error)
@@ -52,9 +60,11 @@ module Emails
add_project_headers
headers['X-GitLab-Author'] = @message.author_username
- mail_with_locale(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
- reply_to: @message.reply_to,
- subject: @message.subject)
+ mail_with_locale(
+ from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
+ reply_to: @message.reply_to,
+ subject: @message.subject
+ )
end
def prometheus_alert_fired_email(project, user, alert)
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index 1295f978049..c627f4633e4 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -20,15 +20,24 @@ module Emails
sender_name: @service_desk_setting&.outgoing_name,
sender_email: service_desk_sender_email_address
)
- options = service_desk_options(email_sender, 'thank_you', @issue.external_author)
- .merge(subject: "Re: #{subject_base}")
- inject_service_desk_custom_email(mail_new_thread(@issue, options))
+ options = {
+ from: email_sender,
+ to: @issue.external_author,
+ subject: "Re: #{subject_base}",
+ **service_desk_template_content_options('thank_you')
+ }
+
+ mail_new_thread(@issue, options)
+ inject_service_desk_custom_email
end
def service_desk_new_note_email(issue_id, note_id, recipient)
@note = Note.find(note_id)
+
setup_service_desk_mail(issue_id)
+ # Prepare uploads for text replacement in markdown content
+ setup_service_desk_attachments
email_sender = sender(
@note.author_id,
@@ -36,11 +45,74 @@ module Emails
sender_email: service_desk_sender_email_address
)
- add_uploads_as_attachments if Feature.enabled?(:service_desk_new_note_email_native_attachments, @note.project)
- options = service_desk_options(email_sender, 'new_note', recipient)
- .merge(subject: subject_base)
+ options = {
+ from: email_sender,
+ to: recipient,
+ subject: subject_base,
+ **service_desk_template_content_options('new_note')
+ }
- inject_service_desk_custom_email(mail_answer_thread(@issue, options))
+ mail_answer_thread(@issue, options)
+ # Add attachments after email init to guide ActiveMailer
+ # to choose the correct multipart content types
+ add_uploads_as_attachments
+ inject_service_desk_custom_email
+ 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; charset=UTF-8"
+ }
+ # Outgoing emails from GitLab usually have this set to true.
+ # Service Desk email ingestion ignores auto generated emails.
+ headers["Auto-Submitted"] = "no"
+
+ mail_with_locale(options)
+ inject_service_desk_custom_email(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
@@ -55,22 +127,20 @@ module Emails
@sent_notification = SentNotification.record(@issue, @support_bot.id, reply_key)
end
- def service_desk_options(email_sender, email_type, recipient)
- {
- from: email_sender,
- to: recipient
- }.tap do |options|
- next unless template_body = template_content(email_type)
+ def service_desk_template_content_options(email_type)
+ return {} unless template_body = template_content(email_type)
- options[:body] = template_body
- options[:content_type] = 'text/html' unless attachments.present?
- end
+ {
+ body: template_body,
+ content_type: 'text/html; charset=UTF-8'
+ }
end
- def inject_service_desk_custom_email(mail)
- return mail unless service_desk_custom_email_enabled?
+ def inject_service_desk_custom_email(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?
@@ -101,6 +171,7 @@ module Emails
.gsub(/%\{\s*ISSUE_ID\s*\}/, issue_id)
.gsub(/%\{\s*ISSUE_PATH\s*\}/, issue_path)
.gsub(/%\{\s*NOTE_TEXT\s*\}/, note_text)
+ .gsub(/%\{\s*ISSUE_DESCRIPTION\s*\}/, issue_description)
.gsub(/%\{\s*SYSTEM_HEADER\s*\}/, text_header_message.to_s)
.gsub(/%\{\s*SYSTEM_FOOTER\s*\}/, text_footer_message.to_s)
.gsub(/%\{\s*UNSUBSCRIBE_URL\s*\}/, unsubscribe_sent_notification_url(@sent_notification))
@@ -119,24 +190,40 @@ module Emails
@note&.note.to_s
end
+ def issue_description
+ @issue.description_html.to_s
+ end
+
def subject_base
"#{@issue.title} (##{@issue.iid})"
end
- def add_uploads_as_attachments
+ def setup_service_desk_attachments
+ @uploads_to_attach = []
+ # Filepaths we should replace in markdown content
+ @uploads_as_attachments = []
+
+ return unless Feature.enabled?(:service_desk_new_note_email_native_attachments, @note.project)
+
uploaders = find_uploaders_for(@note)
- return unless uploaders.present?
+ return if uploaders.nil?
return if uploaders.sum(&:size) > EMAIL_ATTACHMENTS_SIZE_LIMIT
- @uploads_as_attachments = []
uploaders.each do |uploader|
- attachments[uploader.filename] = uploader.read
+ @uploads_to_attach << { filename: uploader.filename, content: uploader.read }
@uploads_as_attachments << "#{uploader.secret}/#{uploader.filename}"
rescue StandardError => e
Gitlab::ErrorTracking.track_exception(e, project_id: @note.project.id)
end
end
+ def add_uploads_as_attachments
+ # We read the uploads before in setup_service_desk_attachments, so let's just add them
+ @uploads_to_attach.each do |upload|
+ mail.add_file(filename: upload[:filename], content: upload[:content])
+ end
+ end
+
def find_uploaders_for(note)
uploads = FileUploader::MARKDOWN_PATTERN.scan(note.note)
return unless uploads.present?
diff --git a/app/mailers/emails/shared.rb b/app/mailers/emails/shared.rb
new file mode 100644
index 00000000000..09876c0960a
--- /dev/null
+++ b/app/mailers/emails/shared.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Emails
+ module Shared
+ def csv_email(user, project, csv_data, export_status, type)
+ @project = project
+ @count = export_status.fetch(:rows_expected)
+ @written_count = export_status.fetch(:rows_written)
+ @truncated = export_status.fetch(:truncated)
+ @size_limit = ActiveSupport::NumberHelper
+ .number_to_human_size(ExportCsv::BaseService::TARGET_FILESIZE)
+
+ filename = "#{project.full_path.parameterize}_#{type}_#{Date.today.iso8601}.csv"
+ attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
+ email_with_layout(
+ to: user.notification_email_for(@project.group),
+ subject: subject("Exported #{type.humanize.downcase}"))
+ end
+ end
+end
diff --git a/app/mailers/emails/work_items.rb b/app/mailers/emails/work_items.rb
new file mode 100644
index 00000000000..b14111c94eb
--- /dev/null
+++ b/app/mailers/emails/work_items.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Emails
+ module WorkItems
+ def import_work_items_csv_email(user_id, project_id, results)
+ @user = User.find(user_id)
+ @project = Project.find(project_id)
+ @results = results
+
+ email_with_layout(
+ to: @user.notification_email_for(@project.group),
+ subject: subject('Imported work items'))
+ end
+
+ def export_work_items_csv_email(user, project, csv_data, export_status)
+ csv_email(user, project, csv_data, export_status, 'work_items')
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 28ef6d8d6c6..036a0fc012e 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -7,6 +7,7 @@ class Notify < ApplicationMailer
include ReminderEmailsHelper
include IssuablesHelper
+ include Emails::Shared
include Emails::Issues
include Emails::MergeRequests
include Emails::Notes
@@ -25,6 +26,7 @@ class Notify < ApplicationMailer
include Emails::AdminNotification
include Emails::IdentityVerification
include Emails::Imports
+ include Emails::WorkItems
helper TimeboxesHelper
helper MergeRequestsHelper
@@ -39,11 +41,12 @@ class Notify < ApplicationMailer
helper InProductMarketingHelper
def test_email(recipient_email, subject, body)
- mail_with_locale(to: recipient_email,
- subject: subject,
- body: body.html_safe,
- content_type: 'text/html'
- )
+ mail_with_locale(
+ to: recipient_email,
+ subject: subject,
+ body: body.html_safe,
+ content_type: 'text/html'
+ )
end
# Splits "gitlab.corp.company.com" up into "gitlab.corp.company.com",
@@ -130,8 +133,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
@@ -219,8 +222,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 7ed594bf571..d91f69cdd4b 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -60,6 +60,14 @@ class NotifyPreview < ActionMailer::Preview
end
end
+ def access_token_created_email
+ Notify.access_token_created_email(user, 'token_name').message
+ end
+
+ def access_token_revoked_email
+ Notify.access_token_revoked_email(user, 'token_name').message
+ end
+
def new_mention_in_merge_request_email
Notify.new_mention_in_merge_request_email(user.id, merge_request.id, user.id).message
end
@@ -84,6 +92,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true })
end
+ def import_work_items_csv_email
+ Notify.import_work_items_csv_email(user.id, project.id, { success: 4, error_lines: [2, 3, 4], parse_error: false })
+ end
+
def issues_csv_email
Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
end
@@ -201,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.mark_as_finished!
+
+ 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
@@ -235,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.mark_as_failed!(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: 'started'
+ )
+ 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/ability.rb b/app/models/ability.rb
index eb645bcd653..4da4d113a7f 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -77,6 +77,8 @@ class Ability
policy = policy_for(user, subject)
+ before_check(policy, ability.to_sym, user, subject, opts)
+
case opts[:scope]
when :user
DeclarativePolicy.user_scope { policy.allowed?(ability) }
@@ -92,6 +94,11 @@ class Ability
forget_runner_result(policy.runner(ability)) if policy && ability_forgetting?
end
+ # Hook call right before ability check.
+ def before_check(policy, ability, user, subject, opts)
+ # See Support::AbilityCheck and Support::PermissionsCheck.
+ end
+
def policy_for(user, subject = :global)
DeclarativePolicy.policy_for(user, subject, cache: ::Gitlab::SafeRequestStore.storage)
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 dbcdfa5e946..55b1aff51da 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -3,14 +3,20 @@
class AbuseReport < ApplicationRecord
include CacheMarkdownField
include Sortable
+ include Gitlab::FileTypeDetection
+ include WithUploads
+ include Gitlab::Utils::StrongMemoize
MAX_CHAR_LIMIT_URL = 512
+ MAX_FILE_SIZE = 1.megabyte
cache_markdown_field :message, pipeline: :single_line
belongs_to :reporter, class_name: 'User'
belongs_to :user
+ has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report
+
validates :reporter, presence: true
validates :user, presence: true
validates :message, presence: true
@@ -24,25 +30,31 @@ class AbuseReport < ApplicationRecord
}
validates :reported_from_url,
- allow_blank: true,
- length: { maximum: MAX_CHAR_LIMIT_URL },
- addressable_url: {
- dns_rebind_protection: true,
- blocked_message: 'is an invalid URL. You can try reporting the abuse again, ' \
- 'or contact a GitLab administrator for help.'
- }
+ allow_blank: true,
+ length: { maximum: MAX_CHAR_LIMIT_URL },
+ addressable_url: {
+ dns_rebind_protection: true,
+ blocked_message: 'is an invalid URL. You can try reporting the abuse again, ' \
+ 'or contact a GitLab administrator for help.'
+ }
validates :links_to_spam,
- allow_blank: true,
- length: {
- maximum: 20,
- message: N_("exceeds the limit of %{count} links")
- }
+ allow_blank: true,
+ length: {
+ maximum: 20,
+ message: N_("exceeds the limit of %{count} links")
+ }
before_validation :filter_empty_strings_from_links_to_spam
validate :links_to_spam_contains_valid_urls
- scope :by_user, ->(user) { where(user_id: user) }
+ 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) }
scope :with_users, -> { includes(:reporter, :user) }
enum category: {
@@ -56,6 +68,11 @@ class AbuseReport < ApplicationRecord
other: 8
}
+ enum status: {
+ open: 1,
+ closed: 2
+ }
+
# For CacheMarkdownField
alias_method :author, :reporter
@@ -63,6 +80,12 @@ class AbuseReport < ApplicationRecord
reported_from_url: "Reported from"
}.freeze
+ CONTROLLER_TO_REPORT_TYPE = {
+ 'users' => :profile,
+ 'projects/issues' => :issue,
+ 'projects/merge_requests' => :merge_request
+ }.freeze
+
def self.human_attribute_name(attr, options = {})
HUMANIZED_ATTRIBUTES[attr.to_sym] || super
end
@@ -77,8 +100,66 @@ 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
+
+ def report_type
+ type = CONTROLLER_TO_REPORT_TYPE[route_hash[:controller]]
+ type = :comment if type.in?([:issue, :merge_request]) && note_id_from_url.present?
+
+ type
+ end
+
+ def reported_content
+ case report_type
+ when :issue
+ project.issues.iid_in(route_hash[:id]).pick(:description_html)
+ when :merge_request
+ project.merge_requests.iid_in(route_hash[:id]).pick(:description_html)
+ when :comment
+ project.notes.id_in(note_id_from_url).pick(:note_html)
+ end
+ end
+
+ def other_reports_for_user
+ user.abuse_reports.id_not_in(id)
+ end
+
private
+ def project
+ Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/'))
+ end
+
+ def route_hash
+ match = Rails.application.routes.recognize_path(reported_from_url)
+ return {} if match[:unmatched_route].present?
+
+ match
+ rescue ActionController::RoutingError
+ {}
+ end
+ strong_memoize_attr :route_hash
+
+ def note_id_from_url
+ fragment = URI(reported_from_url).fragment
+ Gitlab::UntrustedRegexp.new('^note_(\d+)$').match(fragment).to_a.second if fragment
+ rescue URI::InvalidURIError
+ nil
+ end
+ strong_memoize_attr :note_id_from_url
+
def filter_empty_strings_from_links_to_spam
return if links_to_spam.blank?
@@ -91,9 +172,9 @@ class AbuseReport < ApplicationRecord
links_to_spam.each do |link|
Gitlab::UrlBlocker.validate!(
link,
- schemes: %w[http https],
- allow_localhost: true,
- dns_rebind_protection: true
+ schemes: %w[http https],
+ allow_localhost: true,
+ dns_rebind_protection: true
)
next unless link.length > MAX_CHAR_LIMIT_URL
@@ -106,4 +187,26 @@ 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
+
+AbuseReport.prepend_mod
diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb
index 95606e50ad4..834c12fee5a 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
@@ -16,9 +13,9 @@ module Achievements
strip_attributes! :name, :description
validates :name,
- presence: true,
- length: { maximum: 255 },
- uniqueness: { case_sensitive: false, scope: [:namespace_id] }
+ presence: true,
+ length: { maximum: 255 },
+ uniqueness: { case_sensitive: false, scope: [:namespace_id] }
validates :description, length: { maximum: 1024 }
end
end
diff --git a/app/models/achievements/user_achievement.rb b/app/models/achievements/user_achievement.rb
index 885ec660cc9..08ebadaa6b0 100644
--- a/app/models/achievements/user_achievement.rb
+++ b/app/models/achievements/user_achievement.rb
@@ -6,12 +6,19 @@ module Achievements
belongs_to :user, inverse_of: :user_achievements, optional: false
belongs_to :awarded_by_user,
- class_name: 'User',
- inverse_of: :awarded_user_achievements,
- optional: true
+ class_name: 'User',
+ inverse_of: :awarded_user_achievements,
+ optional: false
belongs_to :revoked_by_user,
- class_name: 'User',
- inverse_of: :revoked_user_achievements,
- optional: true
+ class_name: 'User',
+ inverse_of: :revoked_user_achievements,
+ optional: true
+
+ scope :not_revoked, -> { where(revoked_by_user_id: nil) }
+ scope :order_by_id_asc, -> { order(id: :asc) }
+
+ def revoked?
+ revoked_by_user_id.present?
+ end
end
end
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 2d1dec1977d..7d025fb7738 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -26,8 +26,8 @@ class ActiveSession
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_accessor :ip_address, :browser, :os,
- :device_name, :device_type,
- :is_impersonated, :session_id, :session_private_id
+ :device_name, :device_type,
+ :is_impersonated, :session_id, :session_private_id
attr_reader :created_at, :updated_at
@@ -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/airflow/dags.rb b/app/models/airflow/dags.rb
deleted file mode 100644
index d17d4a4f3db..00000000000
--- a/app/models/airflow/dags.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module Airflow
- class Dags < ApplicationRecord
- belongs_to :project
-
- validates :project, presence: true
- validates :dag_name, length: { maximum: 255 }, presence: true
- validates :schedule, length: { maximum: 255 }
- validates :fileloc, length: { maximum: 255 }
-
- scope :by_project_id, ->(project_id) { where(project_id: project_id).order(id: :asc) }
- end
-end
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index a5a539eae75..74edcf12ac2 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -25,8 +25,9 @@ module AlertManagement
has_many :assignees, through: :alert_assignees
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
- has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
+ has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note', inverse_of: :noteable
+ has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id,
+ inverse_of: :alert
has_many :metric_images, class_name: '::AlertManagement::MetricImage'
has_internal_id :iid, scope: :project
@@ -139,7 +140,7 @@ module AlertManagement
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("alert_management", %r{(?<alert>\d+)/details(\#)?})
+ @link_reference_pattern ||= compose_link_reference_pattern('alert_management', %r{(?<alert>\d+)/details(\#)?})
end
def self.reference_valid?(reference)
diff --git a/app/models/alert_management/alert_assignee.rb b/app/models/alert_management/alert_assignee.rb
index c74b2699182..27e720c3262 100644
--- a/app/models/alert_management/alert_assignee.rb
+++ b/app/models/alert_management/alert_assignee.rb
@@ -3,7 +3,7 @@
module AlertManagement
class AlertAssignee < ApplicationRecord
belongs_to :alert, inverse_of: :alert_assignees
- belongs_to :assignee, class_name: 'User', foreign_key: :user_id
+ belongs_to :assignee, class_name: 'User', foreign_key: :user_id, inverse_of: :alert_assignees
validates :alert, presence: true
validates :assignee, presence: true, uniqueness: { scope: :alert_id }
diff --git a/app/models/alert_management/alert_user_mention.rb b/app/models/alert_management/alert_user_mention.rb
index d36aa80ee05..1ab71127677 100644
--- a/app/models/alert_management/alert_user_mention.rb
+++ b/app/models/alert_management/alert_user_mention.rb
@@ -2,7 +2,10 @@
module AlertManagement
class AlertUserMention < UserMention
- belongs_to :alert_management_alert, class_name: '::AlertManagement::Alert'
+ belongs_to :alert, class_name: '::AlertManagement::Alert',
+ foreign_key: :alert_management_alert_id,
+ inverse_of: :user_mentions
+
belongs_to :note
end
end
diff --git a/app/models/analytics/cycle_analytics/project_level.rb b/app/models/analytics/cycle_analytics/project_level.rb
index 813263fe833..a423ea35261 100644
--- a/app/models/analytics/cycle_analytics/project_level.rb
+++ b/app/models/analytics/cycle_analytics/project_level.rb
@@ -11,9 +11,11 @@ module Analytics
end
def summary
- @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(project,
- options: options,
- current_user: options[:current_user]).data
+ @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(
+ project,
+ options: options,
+ current_user: options[:current_user]
+ ).data
end
def permissions(user:)
diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb
index 7e9a89975a3..c7bff7c8d7f 100644
--- a/app/models/analytics/cycle_analytics/stage.rb
+++ b/app/models/analytics/cycle_analytics/stage.rb
@@ -11,7 +11,7 @@ module Analytics
validates :name, uniqueness: { scope: [:group_id, :group_value_stream_id] }
belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ValueStream',
-foreign_key: :group_value_stream_id, inverse_of: :stages
+ foreign_key: :group_value_stream_id, inverse_of: :stages
alias_attribute :parent, :namespace
alias_attribute :parent_id, :group_id
diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb
index 3d8a0a53f5e..59c68393d74 100644
--- a/app/models/analytics/cycle_analytics/value_stream.rb
+++ b/app/models/analytics/cycle_analytics/value_stream.rb
@@ -19,12 +19,7 @@ module Analytics
accepts_nested_attributes_for :stages, allow_destroy: true
scope :preload_associated_models, -> {
- includes(:namespace,
- stages: [
- :namespace,
- :end_event_label,
- :start_event_label
- ])
+ includes(:namespace, stages: [:namespace, :end_event_label, :start_event_label])
}
after_save :ensure_aggregation_record_presence
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index b926c6abedc..4d2baf13f52 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
@@ -27,22 +27,25 @@ class Appearance < ApplicationRecord
cache_markdown_field :footer_message, pipeline: :broadcast_message
validates :pwa_name,
- length: { maximum: 255, too_long: ->(object, data) {
- N_("is too long (maximum is %{count} characters)")
- } },
- allow_blank: true
+ length: {
+ maximum: 255,
+ too_long: ->(object, data) { N_("is too long (maximum is %{count} characters)") }
+ },
+ allow_blank: true
validates :pwa_short_name,
- length: { maximum: 255, too_long: ->(object, data) {
- N_("is too long (maximum is %{count} characters)")
- } },
- allow_blank: true
+ length: {
+ maximum: 255,
+ too_long: ->(object, data) { N_("is too long (maximum is %{count} characters)") }
+ },
+ allow_blank: true
validates :pwa_description,
- length: { maximum: 2048, too_long: ->(object, data) {
- N_("is too long (maximum is %{count} characters)")
- } },
- allow_blank: true
+ length: {
+ maximum: 2048,
+ too_long: ->(object, data) { N_("is too long (maximum is %{count} characters)") }
+ },
+ allow_blank: true
validates :logo, file_size: { maximum: 1.megabyte }
validates :pwa_icon, file_size: { maximum: 1.megabyte }
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 98adbd3ab06..d2ca88aae0e 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,7 +13,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18'
ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18'
ignore_column :web_ide_clientside_preview_enabled, remove_with: '15.11', remove_after: '2023-04-22'
- ignore_column :clickhouse_connection_string, remove_with: '15.11', remove_after: '2023-04-22'
+ ignore_column :clickhouse_connection_string, remove_with: '16.1', remove_after: '2023-05-22'
+ ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -22,21 +23,24 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
KROKI_URL_ERROR_MESSAGE = 'Please check your Kroki URL setting in ' \
'Admin Area > Settings > General > Kroki'
+ # Validate URIs in this model according to the current value of the `deny_all_requests_except_allowed` property,
+ # 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
- add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required }
+ add_authentication_token_field :runners_registration_token, encrypted: :required
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token, encrypted: :required
add_authentication_token_field :error_tracking_access_token, encrypted: :required
- belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id'
belongs_to :push_rule
- alias_attribute :self_monitoring_project_id, :instance_administration_project_id
- belongs_to :instance_group, class_name: "Group", foreign_key: 'instance_administrators_group_id'
- alias_attribute :instance_group_id, :instance_administrators_group_id
- alias_attribute :instance_administrators_group, :instance_group
alias_attribute :housekeeping_optimize_repository_period, :housekeeping_incremental_repack_period
sanitizes! :default_branch_name
@@ -90,336 +94,357 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval
validates :grafana_url,
- system_hook_url: {
- blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}"
- },
- if: :grafana_url_absolute?
+ system_hook_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({
+ blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}"
+ }),
+ if: :grafana_url_absolute?
validate :validate_grafana_url
validates :uuid, presence: true
validates :outbound_local_requests_whitelist,
- length: { maximum: 1_000, message: N_('is too long (maximum is 1000 entries)') },
- allow_nil: false,
- qualified_domain_array: true
+ length: { maximum: 1_000, message: N_('is too long (maximum is 1000 entries)') },
+ allow_nil: false,
+ qualified_domain_array: true
validates :session_expire_delay,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :minimum_password_length,
- presence: true,
- numericality: { only_integer: true,
- greater_than_or_equal_to: DEFAULT_MINIMUM_PASSWORD_LENGTH,
- less_than_or_equal_to: Devise.password_length.max }
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: DEFAULT_MINIMUM_PASSWORD_LENGTH,
+ less_than_or_equal_to: Devise.password_length.max
+ }
validates :home_page_url,
- allow_blank: true,
- addressable_url: true,
- if: :home_page_url_column_exists?
+ allow_blank: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS,
+ if: :home_page_url_column_exists?
validates :help_page_support_url,
- allow_blank: true,
- addressable_url: true,
- if: :help_page_support_url_column_exists?
+ allow_blank: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS,
+ if: :help_page_support_url_column_exists?
validates :help_page_documentation_base_url,
- length: { maximum: 255, message: N_("is too long (maximum is %{count} characters)") },
- allow_blank: true,
- addressable_url: true
+ length: { maximum: 255, message: N_("is too long (maximum is %{count} characters)") },
+ allow_blank: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS
validates :after_sign_out_path,
- allow_blank: true,
- addressable_url: true
+ allow_blank: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS
validates :abuse_notification_email,
- devise_email: true,
- allow_blank: true
+ devise_email: true,
+ allow_blank: true
validates :two_factor_grace_period,
- numericality: { greater_than_or_equal_to: 0 }
+ numericality: { greater_than_or_equal_to: 0 }
validates :recaptcha_site_key,
- presence: true,
- if: :recaptcha_or_login_protection_enabled
+ presence: true,
+ if: :recaptcha_or_login_protection_enabled
validates :recaptcha_private_key,
- presence: true,
- if: :recaptcha_or_login_protection_enabled
+ presence: true,
+ if: :recaptcha_or_login_protection_enabled
validates :akismet_api_key,
- presence: true,
- if: :akismet_enabled
+ presence: true,
+ if: :akismet_enabled
validates :spam_check_api_key,
- length: { maximum: 2000, message: N_('is too long (maximum is %{count} characters)') },
- allow_blank: true
+ length: { maximum: 2000, message: N_('is too long (maximum is %{count} characters)') },
+ allow_blank: true
validates :unique_ips_limit_per_user,
- numericality: { greater_than_or_equal_to: 1 },
- presence: true,
- if: :unique_ips_limit_enabled
+ numericality: { greater_than_or_equal_to: 1 },
+ presence: true,
+ if: :unique_ips_limit_enabled
validates :unique_ips_limit_time_window,
- numericality: { greater_than_or_equal_to: 0 },
- presence: true,
- if: :unique_ips_limit_enabled
+ numericality: { greater_than_or_equal_to: 0 },
+ presence: true,
+ if: :unique_ips_limit_enabled
- validates :kroki_url,
- presence: { if: :kroki_enabled }
+ validates :kroki_url, presence: { if: :kroki_enabled }
validate :validate_kroki_url, if: :kroki_enabled
validates :kroki_formats, json_schema: { filename: 'application_setting_kroki_formats' }
validates :metrics_method_call_threshold,
- numericality: { greater_than_or_equal_to: 0 },
- presence: true,
- if: :prometheus_metrics_enabled
+ numericality: { greater_than_or_equal_to: 0 },
+ presence: true,
+ if: :prometheus_metrics_enabled
- validates :plantuml_url,
- presence: true,
- if: :plantuml_enabled
+ validates :plantuml_url, presence: true, if: :plantuml_enabled
- validates :sourcegraph_url,
- presence: true,
- if: :sourcegraph_enabled
+ validates :sourcegraph_url, presence: true, if: :sourcegraph_enabled
validates :gitpod_url,
- presence: true,
- addressable_url: { enforce_sanitization: true },
- if: :gitpod_enabled
+ presence: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ enforce_sanitization: true }),
+ if: :gitpod_enabled
validates :mailgun_signing_key,
- presence: true,
- length: { maximum: 255 },
- if: :mailgun_events_enabled
+ presence: true,
+ length: { maximum: 255 },
+ if: :mailgun_events_enabled
validates :snowplow_collector_hostname,
- presence: true,
- hostname: true,
- if: :snowplow_enabled
+ presence: true,
+ hostname: true,
+ if: :snowplow_enabled
validates :max_attachment_size,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0 }
validates :max_artifacts_size,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0 }
validates :max_export_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :max_import_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :max_pages_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0,
- less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte }
+ presence: true,
+ numericality: {
+ only_integer: true, greater_than_or_equal_to: 0,
+ less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte
+ }
validates :max_pages_custom_domains_per_project,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :jobs_per_stage_page_size,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :max_terraform_state_size_bytes,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_expiration_policies_enable_historic_entries,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :container_registry_token_expire_delay,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0 }
validates :repository_storages, presence: true
validate :check_repository_storages
validate :check_repository_storages_weighted
validates :auto_devops_domain,
- allow_blank: true,
- hostname: { allow_numeric_hostname: true, require_valid_tld: true },
- if: :auto_devops_enabled?
+ allow_blank: true,
+ hostname: { allow_numeric_hostname: true, require_valid_tld: true },
+ if: :auto_devops_enabled?
validates :enabled_git_access_protocol,
- inclusion: { in: %w(ssh http), allow_blank: true }
+ inclusion: { in: %w(ssh http), allow_blank: true }
validates :domain_denylist,
- presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' },
- if: :domain_denylist_enabled?
+ presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' },
+ if: :domain_denylist_enabled?
validates :housekeeping_optimize_repository_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0 }
validates :terminal_max_session_time,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :polling_interval_multiplier,
- presence: true,
- numericality: { greater_than_or_equal_to: 0 }
+ presence: true,
+ numericality: { greater_than_or_equal_to: 0 }
validates :gitaly_timeout_default,
- presence: true,
- if: :gitaly_timeout_default_changed?,
- numericality: {
- only_integer: true,
- greater_than_or_equal_to: 0,
- less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds
- }
+ presence: true,
+ if: :gitaly_timeout_default_changed?,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: 0,
+ less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds
+ }
validates :gitaly_timeout_medium,
- presence: true,
- if: :gitaly_timeout_medium_changed?,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ if: :gitaly_timeout_medium_changed?,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gitaly_timeout_medium,
- numericality: { less_than_or_equal_to: :gitaly_timeout_default },
- if: :gitaly_timeout_default
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
validates :gitaly_timeout_medium,
- numericality: { greater_than_or_equal_to: :gitaly_timeout_fast },
- if: :gitaly_timeout_fast
+ numericality: { greater_than_or_equal_to: :gitaly_timeout_fast },
+ if: :gitaly_timeout_fast
validates :gitaly_timeout_fast,
- presence: true,
- if: :gitaly_timeout_fast_changed?,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ presence: true,
+ if: :gitaly_timeout_fast_changed?,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gitaly_timeout_fast,
- numericality: { less_than_or_equal_to: :gitaly_timeout_default },
- if: :gitaly_timeout_default
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
validates :diff_max_patch_bytes,
- presence: true,
- numericality: { only_integer: true,
- greater_than_or_equal_to: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
- less_than_or_equal_to: Gitlab::Git::Diff::MAX_PATCH_BYTES_UPPER_BOUND }
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
+ less_than_or_equal_to: Gitlab::Git::Diff::MAX_PATCH_BYTES_UPPER_BOUND
+ }
validates :diff_max_files,
- presence: true,
- numericality: { only_integer: true,
- greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_FILES_SETTING,
- less_than_or_equal_to: Commit::MAX_DIFF_FILES_SETTING_UPPER_BOUND }
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_FILES_SETTING,
+ less_than_or_equal_to: Commit::MAX_DIFF_FILES_SETTING_UPPER_BOUND
+ }
validates :diff_max_lines,
- presence: true,
- numericality: { only_integer: true,
- greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_LINES_SETTING,
- less_than_or_equal_to: Commit::MAX_DIFF_LINES_SETTING_UPPER_BOUND }
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_LINES_SETTING,
+ less_than_or_equal_to: Commit::MAX_DIFF_LINES_SETTING_UPPER_BOUND
+ }
validates :user_default_internal_regex, js_regex: true, allow_nil: true
validates :default_preferred_language, presence: true, inclusion: { in: Gitlab::I18n.available_locales }
validates :personal_access_token_prefix,
- format: { with: %r{\A[a-zA-Z0-9_+=/@:.-]+\z},
- message: N_("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") },
- length: { maximum: 20, message: N_('is too long (maximum is %{count} characters)') },
- allow_blank: true
+ format: { with: %r{\A[a-zA-Z0-9_+=/@:.-]+\z},
+ message: N_("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") },
+ length: { maximum: 20, message: N_('is too long (maximum is %{count} characters)') },
+ allow_blank: true
validates :commit_email_hostname, format: { with: /\A[^@]+\z/ }
validates :archive_builds_in_seconds,
- allow_nil: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds }
+ allow_nil: true,
+ 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,
- numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 }
+ allow_nil: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 }
validates :asset_proxy_url,
- presence: true,
- allow_blank: false,
- url: true,
- if: :asset_proxy_enabled?
+ presence: true,
+ allow_blank: false,
+ url: true,
+ if: :asset_proxy_enabled?
validates :asset_proxy_secret_key,
- presence: true,
- allow_blank: false,
- if: :asset_proxy_enabled?
+ presence: true,
+ allow_blank: false,
+ if: :asset_proxy_enabled?
validates :static_objects_external_storage_url,
- addressable_url: true, allow_blank: true
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true
validates :static_objects_external_storage_auth_token,
- presence: true,
- if: :static_objects_external_storage_url?
+ presence: true,
+ if: :static_objects_external_storage_url?
validates :protected_paths,
- length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
- allow_nil: false
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
validates :push_event_hooks_limit,
- numericality: { greater_than_or_equal_to: 0 }
+ numericality: { greater_than_or_equal_to: 0 }
validates :push_event_activities_limit,
- numericality: { greater_than_or_equal_to: 0 }
+ numericality: { greater_than_or_equal_to: 0 }
validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 }
validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes }
validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true
validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true
+ validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true
+
validates :email_restrictions, untrusted_regexp: true
validates :hashed_storage_enabled, inclusion: { in: [true], message: N_("Hashed storage can't be disabled anymore for new projects") }
validates :container_registry_delete_tags_service_timeout,
- :container_registry_cleanup_tags_service_max_list_size,
- :container_registry_expiration_policies_worker_capacity,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ :container_registry_cleanup_tags_service_max_list_size,
+ :container_registry_expiration_policies_worker_capacity,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :container_registry_expiration_policies_caching,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :container_registry_import_max_tags_count,
- :container_registry_import_max_retries,
- :container_registry_import_start_max_retries,
- :container_registry_import_max_step_duration,
- :container_registry_pre_import_timeout,
- :container_registry_import_timeout,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ :container_registry_import_max_retries,
+ :container_registry_import_start_max_retries,
+ :container_registry_import_max_step_duration,
+ :container_registry_pre_import_timeout,
+ :container_registry_import_timeout,
+ allow_nil: false,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :container_registry_pre_import_tags_rate,
- allow_nil: false,
- numericality: { greater_than_or_equal_to: 0 }
+ allow_nil: false,
+ numericality: { greater_than_or_equal_to: 0 }
validates :container_registry_import_target_plan, presence: true
validates :container_registry_import_created_before, presence: true
validates :dependency_proxy_ttl_group_policy_worker_capacity,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ allow_nil: false,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :packages_cleanup_package_file_worker_capacity,
- :package_registry_cleanup_policies_worker_capacity,
- allow_nil: false,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ :package_registry_cleanup_policies_worker_capacity,
+ allow_nil: false,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :invisible_captcha_enabled,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :invitation_flow_enforcement, :can_create_group, :user_defaults_to_private_profile,
- allow_nil: false,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :deactivate_dormant_users_period,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 90, message: N_("'%{value}' days of inactivity must be greater than or equal to 90") },
- if: :deactivate_dormant_users?
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 90, message: N_("'%{value}' days of inactivity must be greater than or equal to 90") },
+ if: :deactivate_dormant_users?
validates :allow_possible_spam,
- allow_nil: false,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
+ validates :deny_all_requests_except_allowed,
+ 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') }
+
+ validates :remember_me_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 }
@@ -448,93 +473,90 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validate :terms_exist, if: :enforce_terms?
validates :external_authorization_service_default_label,
- presence: true,
- if: :external_authorization_service_enabled
+ presence: true,
+ if: :external_authorization_service_enabled
validates :external_authorization_service_url,
- addressable_url: true, allow_blank: true,
- if: :external_authorization_service_enabled
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true,
+ if: :external_authorization_service_enabled
validates :external_authorization_service_timeout,
- numericality: { greater_than: 0, less_than_or_equal_to: 10 },
- if: :external_authorization_service_enabled
+ numericality: { greater_than: 0, less_than_or_equal_to: 10 },
+ if: :external_authorization_service_enabled
validates :spam_check_endpoint_url,
- addressable_url: { schemes: %w(tls grpc) }, allow_blank: true
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ schemes: %w(tls grpc) }), allow_blank: true
validates :spam_check_endpoint_url,
- presence: true,
- if: :spam_check_endpoint_enabled
+ presence: true,
+ if: :spam_check_endpoint_enabled
validates :external_auth_client_key,
- presence: true,
- if: ->(setting) { setting.external_auth_client_cert.present? }
+ presence: true,
+ if: ->(setting) { setting.external_auth_client_cert.present? }
validates :lets_encrypt_notification_email,
- devise_email: true,
- format: { without: /@example\.(com|org|net)\z/,
- message: N_("Let's Encrypt does not accept emails on example.com") },
- allow_blank: true
+ devise_email: true,
+ format: { without: /@example\.(com|org|net)\z/, message: N_("Let's Encrypt does not accept emails on example.com") },
+ allow_blank: true
validates :lets_encrypt_notification_email,
- presence: true,
- if: :lets_encrypt_terms_of_service_accepted?
+ presence: true,
+ if: :lets_encrypt_terms_of_service_accepted?
validates :eks_integration_enabled,
- inclusion: { in: [true, false] }
+ inclusion: { in: [true, false] }
validates :eks_account_id,
- format: { with: Gitlab::Regex.aws_account_id_regex,
- message: Gitlab::Regex.aws_account_id_message },
- if: :eks_integration_enabled?
+ format: { with: Gitlab::Regex.aws_account_id_regex, message: Gitlab::Regex.aws_account_id_message },
+ if: :eks_integration_enabled?
validates :eks_access_key_id,
- length: { in: 16..128 },
- if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
+ length: { in: 16..128 },
+ if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
validates :eks_secret_access_key,
- presence: true,
- if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
+ presence: true,
+ if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
validates_with X509CertificateCredentialsValidator,
- certificate: :external_auth_client_cert,
- pkey: :external_auth_client_key,
- pass: :external_auth_client_key_pass,
- if: ->(setting) { setting.external_auth_client_cert.present? }
+ certificate: :external_auth_client_cert,
+ pkey: :external_auth_client_key,
+ pass: :external_auth_client_key_pass,
+ if: ->(setting) { setting.external_auth_client_cert.present? }
validates :default_ci_config_path,
- format: { without: %r{(\.{2}|\A/)},
- message: N_('cannot include leading slash or directory traversal.') },
+ format: { without: %r{(\.{2}|\A/)}, message: N_('cannot include leading slash or directory traversal.') },
length: { maximum: 255 },
allow_blank: true
validates :issues_create_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :raw_blob_request_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :pipeline_limit_per_project_user_sha,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :ci_jwt_signing_key,
- rsa_key: true, allow_nil: true
+ rsa_key: true, allow_nil: true
validates :customers_dot_jwt_signing_key,
- rsa_key: true, allow_nil: true
+ rsa_key: true, allow_nil: true
validates :rate_limiting_response_text,
- length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
- allow_blank: true
+ length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
+ allow_blank: true
validates :jira_connect_application_key,
- length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
- allow_blank: true
+ length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
+ allow_blank: true
validates :jira_connect_proxy_url,
- length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
- allow_blank: true,
- public_url: true
+ length: { maximum: 255, message: N_('is too long (maximum is %{count} characters)') },
+ allow_blank: true,
+ public_url: ADDRESSABLE_URL_VALIDATION_OPTIONS
with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do
validates :throttle_unauthenticated_api_requests_per_period
@@ -563,54 +585,52 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :throttle_protected_paths_period_in_seconds
end
- validates :notes_create_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :search_rate_limit,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :search_rate_limit_unauthenticated,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ with_options(numericality: { only_integer: true, greater_than_or_equal_to: 0 }) do
+ validates :notes_create_limit
+ validates :search_rate_limit
+ validates :search_rate_limit_unauthenticated
+ validates :projects_api_rate_limit_unauthenticated
+ end
validates :notes_create_limit_allowlist,
- length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
- allow_nil: false
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
validates :admin_mode,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :external_pipeline_validation_service_url,
- addressable_url: true, allow_blank: true
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true
validates :external_pipeline_validation_service_timeout,
- allow_nil: true,
- numericality: { only_integer: true, greater_than: 0 }
+ allow_nil: true,
+ numericality: { only_integer: true, greater_than: 0 }
validates :whats_new_variant,
- inclusion: { in: ApplicationSetting.whats_new_variants.keys }
+ inclusion: { in: ApplicationSetting.whats_new_variants.keys }
validates :floc_enabled,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
enum sidekiq_job_limiter_mode: {
- Gitlab::SidekiqMiddleware::SizeLimiter::Validator::TRACK_MODE => 0,
- Gitlab::SidekiqMiddleware::SizeLimiter::Validator::COMPRESS_MODE => 1 # The default
- }
+ Gitlab::SidekiqMiddleware::SizeLimiter::Validator::TRACK_MODE => 0,
+ Gitlab::SidekiqMiddleware::SizeLimiter::Validator::COMPRESS_MODE => 1 # The default
+ }
validates :sidekiq_job_limiter_mode,
- inclusion: { in: self.sidekiq_job_limiter_modes }
+ inclusion: { in: self.sidekiq_job_limiter_modes }
validates :sidekiq_job_limiter_compression_threshold_bytes,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :sidekiq_job_limiter_limit_bytes,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :sentry_enabled,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :sentry_dsn,
- addressable_url: true, presence: true, length: { maximum: 255 },
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, presence: true, length: { maximum: 255 },
if: :sentry_enabled?
validates :sentry_clientside_dsn,
- addressable_url: true, allow_blank: true, length: { maximum: 255 },
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true, length: { maximum: 255 },
if: :sentry_enabled?
validates :sentry_environment,
presence: true, length: { maximum: 255 },
@@ -620,32 +640,39 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :error_tracking_api_url,
presence: true,
- addressable_url: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS,
length: { maximum: 255 },
if: :error_tracking_enabled?
validates :users_get_by_id_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :users_get_by_id_limit_allowlist,
- length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
- allow_nil: false
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
- validates :public_runner_releases_url, addressable_url: true, presence: true
+ validates :update_runner_versions_enabled,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ validates :public_runner_releases_url,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS,
+ presence: true,
+ if: :update_runner_versions_enabled?
validates :inactive_projects_min_size_mb,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :inactive_projects_delete_after_months,
- numericality: { only_integer: true, greater_than: 0 }
+ numericality: { only_integer: true, greater_than: 0 }
validates :inactive_projects_send_warning_email_after_months,
- numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_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,
- algorithm: 'aes-256-cbc',
- insecure_mode: true
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-cbc',
+ insecure_mode: true
private_class_method def self.encryption_options_base_32_aes_256_gcm
{
@@ -683,24 +710,49 @@ 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)
+ attr_encrypted :anthropic_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ # TOFA API integration settngs
+ attr_encrypted :tofa_client_library_args, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_client_library_class, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_client_library_create_credentials_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_client_library_fetch_access_token_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_host, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_request_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_request_payload, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_response_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_url, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :tofa_access_token_expires_in, 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') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :disable_admin_oauth_scopes,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :bulk_import_enabled,
- allow_nil: false,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :allow_runner_registration_token,
- allow_nil: false,
- inclusion: { in: [true, false], message: N_('must be a boolean value') }
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
+ validates :default_syntax_highlighting_theme,
+ allow_nil: false,
+ numericality: { only_integer: true, greater_than: 0 },
+ inclusion: { in: Gitlab::ColorSchemes.valid_ids, message: N_('must be a valid syntax highlighting theme ID') }
+
+ validates :gitlab_dedicated_instance,
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_validation :normalize_default_branch_name
+ before_validation :remove_old_import_sources
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -744,6 +796,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
users_count >= INSTANCE_REVIEW_MIN_USERS
end
+ def remove_old_import_sources
+ self.import_sources -= %w[phabricator gitlab] if self.import_sources
+ end
+
Recursion = Class.new(RuntimeError)
def self.create_from_defaults
@@ -824,6 +880,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 a5f262f2e1e..845d402f550 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -60,6 +60,7 @@ module ApplicationSettingImplementation
default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
default_projects_limit: Settings.gitlab['default_projects_limit'],
default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ deny_all_requests_except_allowed: false,
diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
diff_max_files: Commit::DEFAULT_MAX_DIFF_FILES_SETTING,
diff_max_lines: Commit::DEFAULT_MAX_DIFF_LINES_SETTING,
@@ -96,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,
@@ -249,7 +250,10 @@ module ApplicationSettingImplementation
can_create_group: true,
bulk_import_enabled: false,
allow_runner_registration_token: true,
- user_defaults_to_private_profile: false
+ user_defaults_to_private_profile: false,
+ projects_api_rate_limit_unauthenticated: 400,
+ gitlab_dedicated_instance: false,
+ ci_max_includes: 150
}.tap do |hsh|
hsh.merge!(non_production_defaults) unless Rails.env.production?
end
diff --git a/app/models/atlassian/identity.rb b/app/models/atlassian/identity.rb
index 02bbe007e1b..1ad7f657db1 100644
--- a/app/models/atlassian/identity.rb
+++ b/app/models/atlassian/identity.rb
@@ -10,17 +10,17 @@ module Atlassian
validates :user, presence: true, uniqueness: true
attr_encrypted :token,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- encode: false,
- encode_iv: false
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
attr_encrypted :refresh_token,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- encode: false,
- encode_iv: false
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
end
end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 3312216932b..163e741d990 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -21,7 +21,7 @@ class AuditEvent < ApplicationRecord
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
- belongs_to :user, foreign_key: :author_id
+ belongs_to :user, foreign_key: :author_id, inverse_of: :audit_events
validates :author_id, presence: true
validates :entity_id, presence: true
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index d5a5079acd6..a70ebb42008 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -30,4 +30,8 @@ class AuthenticationEvent < ApplicationRecord
!where(user_id: user).exists? ||
where(user_id: user, ip_address: ip_address).success.exists?
end
+
+ def self.most_used_ip_address_for_user(user)
+ select('mode() within group (order by ip_address) as ip_address').find_by(user: user).ip_address
+ end
end
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/badge.rb b/app/models/badge.rb
index 0676de10d02..23e6f305c32 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -42,7 +42,7 @@ class Badge < ApplicationRecord
private
def build_rendered_url(url, project = nil)
- return url unless valid? && project
+ return url unless project
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
replace_placeholder_action(PLACEHOLDERS[arg], project)
diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb
index 59638df6fad..8c51ebafb5e 100644
--- a/app/models/badges/project_badge.rb
+++ b/app/models/badges/project_badge.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectBadge < Badge
+ include EachBatch
+
belongs_to :project
validates :project, presence: true
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/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb
index 4b7a178566c..b63f3022198 100644
--- a/app/models/blob_viewer/metrics_dashboard_yml.rb
+++ b/app/models/blob_viewer/metrics_dashboard_yml.rb
@@ -11,6 +11,10 @@ module BlobViewer
self.file_types = %i(metrics_dashboard)
self.binary = false
+ def self.can_render?(blob, verify_binary: true)
+ super && !Feature.enabled?(:remove_monitor_metrics)
+ end
+
def valid?
errors.blank?
end
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/board.rb b/app/models/board.rb
index 2181b2f0545..da9cd1548e4 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,13 +1,15 @@
# frozen_string_literal: true
class Board < ApplicationRecord
+ include EachBatch
+
RECENT_BOARDS_SIZE = 4
belongs_to :group
belongs_to :project
- has_many :lists, -> { ordered }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- has_many :destroyable_lists, -> { destroyable.ordered }, class_name: "List"
+ has_many :lists, -> { ordered }, dependent: :delete_all, inverse_of: :board # rubocop:disable Cop/ActiveRecordDependent
+ has_many :destroyable_lists, -> { destroyable.ordered }, class_name: "List", inverse_of: :board
validates :name, presence: true
validates :project, presence: true, if: :project_needed?
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index c5a234ffa69..733018160cd 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
@@ -85,10 +88,8 @@ class BroadcastMessage < ApplicationRecord
private
- def fetch_messages(cache_key, current_path, user_access_level)
- messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do
- yield
- end
+ def fetch_messages(cache_key, current_path, user_access_level, &block)
+ messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in, &block)
now_or_future = messages.select(&:now_or_future?)
@@ -131,7 +132,6 @@ class BroadcastMessage < ApplicationRecord
end
def matches_current_user_access_level?(user_access_level)
- return false if target_access_levels.present? && Feature.disabled?(:role_targeted_broadcast_messages)
return true unless target_access_levels.present?
target_access_levels.include? user_access_level
@@ -145,9 +145,7 @@ class BroadcastMessage < ApplicationRecord
# This fixes a mismatch between requests in the GUI and CLI
#
# This has to be reassigned due to frozen strings being provided.
- unless current_path.start_with?("/")
- current_path = "/#{current_path}"
- end
+ current_path = "/#{current_path}" unless current_path.start_with?("/")
escaped = Regexp.escape(target_path).gsub('\\*', '.*')
regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index 2565ad5f2b8..c2d7529f468 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -42,6 +42,12 @@ class BulkImport < ApplicationRecord
event :fail_op do
transition any => :failed
end
+
+ # rubocop:disable Style/SymbolProc
+ after_transition any => [:finished, :failed, :timeout] do |bulk_import|
+ bulk_import.update_has_failures
+ end
+ # rubocop:enable Style/SymbolProc
end
def source_version_info
@@ -55,4 +61,11 @@ class BulkImport < ApplicationRecord
def self.all_human_statuses
state_machine.states.map(&:human_name)
end
+
+ def update_has_failures
+ return if has_failures
+ return unless entities.any?(&:has_failures)
+
+ update!(has_failures: true)
+ end
end
diff --git a/app/models/bulk_imports/batch_tracker.rb b/app/models/bulk_imports/batch_tracker.rb
new file mode 100644
index 00000000000..df1fab89ee6
--- /dev/null
+++ b/app/models/bulk_imports/batch_tracker.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class BatchTracker < ApplicationRecord
+ self.table_name = 'bulk_import_batch_trackers'
+
+ belongs_to :tracker, class_name: 'BulkImports::Tracker'
+
+ validates :batch_number, presence: true, uniqueness: { scope: :tracker_id }
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :timeout, value: 3
+ state :failed, value: -1
+ state :skipped, value: -2
+
+ event :start do
+ transition created: :started
+ end
+
+ event :retry do
+ transition started: :created
+ end
+
+ event :finish do
+ transition started: :finished
+ transition failed: :failed
+ transition skipped: :skipped
+ end
+
+ event :skip do
+ transition any => :skipped
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
+
+ event :cleanup_stale do
+ transition [:created, :started] => :timeout
+ end
+ end
+ end
+end
diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb
index 3b263ed0340..6d9f598583e 100644
--- a/app/models/bulk_imports/configuration.rb
+++ b/app/models/bulk_imports/configuration.rb
@@ -9,7 +9,7 @@ class BulkImports::Configuration < ApplicationRecord
validates :url, :access_token, length: { maximum: 255 }, presence: true
validates :url, public_url: { schemes: %w[http https], enforce_sanitization: true, ascii_only: true },
- allow_nil: true
+ allow_nil: true
attr_encrypted :url,
key: Settings.attr_encrypted_db_key_base_32,
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 6fc24c77f1d..94e4a8165eb 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -26,10 +26,11 @@ class BulkImports::Entity < ApplicationRecord
belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
belongs_to :project, optional: true
- belongs_to :group, foreign_key: :namespace_id, optional: true
+ belongs_to :group, foreign_key: :namespace_id, optional: true, inverse_of: :bulk_import_entities
has_many :trackers,
class_name: 'BulkImports::Tracker',
+ inverse_of: :entity,
foreign_key: :bulk_import_entity_id
has_many :failures,
@@ -40,27 +41,14 @@ class BulkImports::Entity < ApplicationRecord
validates :project, absence: true, if: :group
validates :group, absence: true, if: :project
validates :source_type, presence: true
- 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 }
-
- validates :destination_name,
- presence: true,
- format: { with: Gitlab::Regex.group_path_regex,
- message: Gitlab::Regex.group_path_regex_message }
-
- 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
+ validates :source_full_path, presence: true, format: {
+ with: Gitlab::Regex.bulk_import_source_full_path_regex,
+ message: Gitlab::Regex.bulk_import_source_full_path_regex_message
+ }
+
+ validates :destination_name, presence: true, if: -> { group || project }
+ validates :destination_namespace, exclusion: [nil], if: :group
+ validates :destination_namespace, presence: true, if: :project?
validate :validate_parent_is_a_group, if: :parent
validate :validate_imported_entity_type
@@ -76,9 +64,8 @@ class BulkImports::Entity < ApplicationRecord
alias_attribute :destination_slug, :destination_name
- delegate :default_project_visibility,
- :default_group_visibility,
- to: :'Gitlab::CurrentSettings.current_application_settings'
+ delegate :default_project_visibility, :default_group_visibility,
+ to: :'Gitlab::CurrentSettings.current_application_settings'
state_machine :status, initial: :created do
state :created, value: 0
@@ -104,6 +91,12 @@ class BulkImports::Entity < ApplicationRecord
transition created: :timeout
transition started: :timeout
end
+
+ # rubocop:disable Style/SymbolProc
+ after_transition any => [:finished, :failed, :timeout] do |entity|
+ entity.update_has_failures
+ end
+ # rubocop:enable Style/SymbolProc
end
def self.all_human_statuses
@@ -185,6 +178,13 @@ class BulkImports::Entity < ApplicationRecord
default_project_visibility
end
+ def update_has_failures
+ return if has_failures
+ return unless failures.any?
+
+ update!(has_failures: true)
+ end
+
private
def validate_parent_is_a_group
@@ -194,13 +194,6 @@ class BulkImports::Entity < ApplicationRecord
end
def validate_imported_entity_type
- if project_entity? && !BulkImports::Features.project_migration_enabled?(destination_namespace)
- errors.add(
- :base,
- s_('BulkImport|invalid entity source type')
- )
- end
-
if group.present? && project_entity?
errors.add(
:group,
diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb
index 8d4d31ee92d..93cf047c690 100644
--- a/app/models/bulk_imports/export.rb
+++ b/app/models/bulk_imports/export.rb
@@ -14,6 +14,7 @@ module BulkImports
belongs_to :group, optional: true
has_one :upload, class_name: 'BulkImports::ExportUpload'
+ has_many :batches, class_name: 'BulkImports::ExportBatch'
validates :project, presence: true, unless: :group
validates :group, presence: true, unless: :project
@@ -32,6 +33,7 @@ module BulkImports
event :finish do
transition started: :finished
+ transition finished: :finished
transition failed: :failed
end
diff --git a/app/models/bulk_imports/export_batch.rb b/app/models/bulk_imports/export_batch.rb
new file mode 100644
index 00000000000..9d34dae12d0
--- /dev/null
+++ b/app/models/bulk_imports/export_batch.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class ExportBatch < ApplicationRecord
+ self.table_name = 'bulk_import_export_batches'
+
+ BATCH_SIZE = 1000
+
+ belongs_to :export, class_name: 'BulkImports::Export'
+ has_one :upload, class_name: 'BulkImports::ExportUpload', foreign_key: :batch_id, inverse_of: :batch
+
+ validates :batch_number, presence: true, uniqueness: { scope: :export_id }
+
+ state_machine :status, initial: :started do
+ state :started, value: 0
+ state :finished, value: 1
+ state :failed, value: -1
+
+ event :start do
+ transition any => :started
+ end
+
+ event :finish do
+ transition started: :finished
+ transition failed: :failed
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
+ end
+ end
+end
diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb
index 4304032b28c..00f8e8f1304 100644
--- a/app/models/bulk_imports/export_upload.rb
+++ b/app/models/bulk_imports/export_upload.rb
@@ -7,6 +7,7 @@ module BulkImports
self.table_name = 'bulk_import_export_uploads'
belongs_to :export, class_name: 'BulkImports::Export'
+ belongs_to :batch, class_name: 'BulkImports::ExportBatch', optional: true
mount_uploader :export_file, ExportUploader
diff --git a/app/models/bulk_imports/file_transfer.rb b/app/models/bulk_imports/file_transfer.rb
index 5be954b98da..c6af4e0c833 100644
--- a/app/models/bulk_imports/file_transfer.rb
+++ b/app/models/bulk_imports/file_transfer.rb
@@ -9,9 +9,9 @@ module BulkImports
def config_for(portable)
case portable
when ::Project
- FileTransfer::ProjectConfig.new(portable)
+ ::BulkImports::FileTransfer::ProjectConfig.new(portable)
when ::Group
- FileTransfer::GroupConfig.new(portable)
+ ::BulkImports::FileTransfer::GroupConfig.new(portable)
else
raise(UnsupportedObjectType, "Unsupported object type: #{portable.class}")
end
diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb
index 036d511bc59..32fc794627c 100644
--- a/app/models/bulk_imports/file_transfer/base_config.rb
+++ b/app/models/bulk_imports/file_transfer/base_config.rb
@@ -32,6 +32,15 @@ module BulkImports
tree_relations + file_relations + self_relation - skipped_relations
end
+ def batchable_relations
+ portable_relations.select { |relation| portable_class.reflect_on_association(relation)&.collection? }
+ end
+ strong_memoize_attr :batchable_relations
+
+ def batchable_relation?(relation)
+ batchable_relations.include?(relation)
+ end
+
def self_relation?(relation)
relation == SELF_RELATION
end
@@ -51,7 +60,21 @@ module BulkImports
end
def portable_relations_tree
- @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys
+ @portable_relations_tree ||= attributes_finder
+ .find_relations_tree(portable_class_sym, include_import_only_tree: true).deep_stringify_keys
+ end
+
+ # Returns an export service class for the given relation.
+ # @return TreeExportService if a relation is serializable and is listed in import_export.yml
+ # @return FileExportService if a relation is a file (uploads, lfs objects, git repository, etc.)
+ def export_service_for(relation)
+ if tree_relation?(relation)
+ ::BulkImports::TreeExportService
+ elsif file_relation?(relation)
+ ::BulkImports::FileExportService
+ else
+ raise ::BulkImports::Error, 'Unsupported export relation'
+ end
end
private
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index b04ef1cb7ae..55502721a76 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -7,9 +7,12 @@ class BulkImports::Tracker < ApplicationRecord
belongs_to :entity,
class_name: 'BulkImports::Entity',
+ inverse_of: :trackers,
foreign_key: :bulk_import_entity_id,
optional: false
+ has_many :batches, class_name: 'BulkImports::BatchTracker', inverse_of: :tracker
+
validates :relation,
presence: true,
uniqueness: { scope: :bulk_import_entity_id }
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index 9bd618c1008..cda19273f52 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -3,7 +3,9 @@
class ChatName < ApplicationRecord
LAST_USED_AT_INTERVAL = 1.hour
- belongs_to :integration
+ include IgnorableColumns
+ ignore_column :integration_id, remove_with: '16.0', remove_after: '2023-04-22'
+
belongs_to :user
validates :user, presence: true
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 697f06fbffd..7cdd0d56a98 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
@@ -81,7 +79,9 @@ module Ci
case pipeline.status
when 'success'
success!
- when 'failed', 'canceled', 'skipped'
+ when 'canceled'
+ cancel!
+ when 'failed', 'skipped'
drop!
else
false
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1e70dd171ed..61585de4ff7 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -18,14 +18,15 @@ module Ci
belongs_to :runner
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
- belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
+ belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, inverse_of: :builds
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
refspecs: -> (build) { build.merge_request_ref? },
artifacts_exclude: -> (build) { build.supports_artifacts_exclude? },
multi_build_steps: -> (build) { build.multi_build_steps? },
- return_exit_code: -> (build) { build.exit_codes_defined? }
+ return_exit_code: -> (build) { build.exit_codes_defined? },
+ fallback_cache_keys: -> (build) { build.fallback_cache_keys_defined? }
}.freeze
DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
@@ -35,8 +36,8 @@ module Ci
has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable
has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build
- has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id
- has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id
+ has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build
+ has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build
has_many :report_results, class_name: 'Ci::BuildReportResult', foreign_key: :build_id, inverse_of: :build
has_one :namespace, through: :project
@@ -47,7 +48,7 @@ module Ci
# Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job
- has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id
+ has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :build
has_many :pages_deployments, foreign_key: :ci_build_id, inverse_of: :ci_build
@@ -55,7 +56,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, through: :metadata, class_name: 'Ci::RunnerMachine'
+ has_one :runner_manager_build, class_name: 'Ci::RunnerManagerBuild', foreign_key: :build_id, inverse_of: :build,
+ autosave: true
+ 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
@@ -71,6 +74,7 @@ module Ci
delegate :gitlab_deploy_token, to: :project
delegate :harbor_integration, to: :project
delegate :apple_app_store_integration, to: :project
+ delegate :google_play_integration, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
delegate :ensure_persistent_ref, to: :pipeline
delegate :enable_debug_trace!, to: :metadata
@@ -132,7 +136,7 @@ module Ci
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
scope :eager_load_tags, -> { includes(:tags) }
- scope :eager_load_for_archiving_trace, -> { includes(:project, :pending_state) }
+ scope :eager_load_for_archiving_trace, -> { preload(:project, :pending_state) }
scope :eager_load_everything, -> do
includes(
@@ -180,7 +184,9 @@ module Ci
acts_as_taggable
- add_authentication_token_field :token, encrypted: :required
+ add_authentication_token_field :token,
+ encrypted: :required,
+ format_with_prefix: :partition_id_prefix_in_16_bit_encode
after_save :stick_build_if_status_changed
@@ -592,14 +598,21 @@ 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)
.concat(deploy_token_variables)
.concat(harbor_variables)
.concat(apple_app_store_variables)
+ .concat(google_play_variables)
end
end
@@ -650,6 +663,13 @@ module Ci
Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables)
end
+ def google_play_variables
+ return [] unless google_play_integration.try(:activated?)
+ return [] unless pipeline.protected_ref?
+
+ Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables)
+ end
+
def features
{
trace_sections: true,
@@ -757,9 +777,7 @@ module Ci
end
def remove_token!
- if Feature.enabled?(:remove_job_token_on_completion, project)
- update!(token_encrypted: nil)
- end
+ update!(token_encrypted: nil)
end
# acts_as_taggable uses this method create/remove tags with contexts
@@ -802,7 +820,7 @@ module Ci
return unless project
return if user&.blocked?
- ActiveRecord::Associations::Preloader.new.preload([self], { runner: :tags })
+ ActiveRecord::Associations::Preloader.new(records: [self], associations: { runner: :tags }).call
project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks)
project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks)
@@ -902,9 +920,15 @@ module Ci
def cache
cache = Array.wrap(options[:cache])
+ cache.each do |single_cache|
+ single_cache[:fallback_keys] = [] unless single_cache.key?(:fallback_keys)
+ end
+
if project.jobs_cache_index
cache = cache.map do |single_cache|
- single_cache.merge(key: "#{single_cache[:key]}-#{project.jobs_cache_index}")
+ cache = single_cache.merge(key: "#{single_cache[:key]}-#{project.jobs_cache_index}")
+ fallback = cache.slice(:fallback_keys).transform_values { |keys| keys.map { |key| "#{key}-#{project.jobs_cache_index}" } }
+ cache.merge(fallback.compact)
end
end
@@ -913,10 +937,16 @@ module Ci
cache.map do |entry|
type_suffix = !entry[:unprotect] && pipeline.protected_ref? ? 'protected' : 'non_protected'
- entry.merge(key: "#{entry[:key]}-#{type_suffix}")
+ cache = entry.merge(key: "#{entry[:key]}-#{type_suffix}")
+ fallback = cache.slice(:fallback_keys).transform_values { |keys| keys.map { |key| "#{key}-#{type_suffix}" } }
+ cache.merge(fallback.compact)
end
end
+ def fallback_cache_keys_defined?
+ Array.wrap(options[:cache]).any? { |cache| cache[:fallback_keys].present? }
+ end
+
def credentials
Gitlab::Ci::Build::Credentials::Factory.new(self).create!
end
@@ -1091,10 +1121,6 @@ module Ci
::Ci::PendingBuild.upsert_from_build!(self)
end
- def create_runtime_metadata!
- ::Ci::RunningBuild.upsert_shared_runner_build!(self)
- end
-
##
# We can have only one queuing entry or running build tracking entry,
# because there is a unique index on `build_id` in each table, but we need
@@ -1161,11 +1187,6 @@ module Ci
end
end
- override :format_token
- def format_token(token)
- "#{partition_id.to_s(16)}_#{token}"
- end
-
protected
def run_status_commit_hooks!
@@ -1231,10 +1252,10 @@ module Ci
end
def job_jwt_variables
- if project.ci_cd_settings.opt_in_jwt?
+ if id_tokens?
id_tokens_variables
else
- predefined_jwt_variables.concat(id_tokens_variables)
+ predefined_jwt_variables
end
end
@@ -1251,8 +1272,6 @@ module Ci
end
def id_tokens_variables
- return [] unless id_tokens?
-
Gitlab::Ci::Variables::Collection.new.tap do |variables|
id_tokens.each do |var_name, token_data|
token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['aud'])
@@ -1308,6 +1327,10 @@ module Ci
).to_context]
)
end
+
+ def partition_id_prefix_in_16_bit_encode
+ "#{partition_id.to_s(16)}_"
+ end
end
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index b294afd405d..382f861a802 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -10,15 +10,16 @@ module Ci
include Presentable
include ChronicDurationAttribute
include Gitlab::Utils::StrongMemoize
+ include SafelyChangeColumnDefault
self.table_name = 'p_ci_builds_metadata'
self.primary_key = 'id'
+ columns_changing_default :partition_id
partitionable scope: :build
belongs_to :build, class_name: 'CommitStatus'
belongs_to :project
- belongs_to :runner_machine, class_name: 'Ci::RunnerMachine'
before_create :set_build_project
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index 03d1bd14bfb..940221619b3 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -6,8 +6,6 @@ module Ci
include BulkInsertSafe
include IgnorableColumns
- ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-04-22'
-
belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs
partitionable scope: :build
diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb
index 3684dac06c7..966884ae158 100644
--- a/app/models/ci/build_pending_state.rb
+++ b/app/models/ci/build_pending_state.rb
@@ -3,7 +3,7 @@
class Ci::BuildPendingState < Ci::ApplicationRecord
include Ci::Partitionable
- belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id
+ belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id, inverse_of: :pending_state
partitionable scope: :build
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 541a8b5bffa..03b59b19ef1 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -9,7 +9,7 @@ module Ci
include ::Gitlab::ExclusiveLeaseHelpers
include ::Gitlab::OptimisticLocking
- belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
+ belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id, inverse_of: :trace_chunks
partitionable scope: :build
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
new file mode 100644
index 00000000000..b9e777f27a0
--- /dev/null
+++ b/app/models/ci/catalog/listing.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ class Listing
+ # This class is the SSoT to displaying the list of resources in the
+ # CI/CD Catalog given a namespace as a scope.
+ # This model is not directly backed by a table and joins catalog resources
+ # with projects to return relevant data.
+ def initialize(namespace, current_user)
+ raise ArgumentError, 'Namespace is not a root namespace' unless namespace.root?
+
+ @namespace = namespace
+ @current_user = current_user
+ end
+
+ def resources
+ Ci::Catalog::Resource
+ .joins(:project).includes(:project)
+ .merge(projects_in_namespace_visible_to_user)
+ end
+
+ private
+
+ attr_reader :namespace, :current_user
+
+ def projects_in_namespace_visible_to_user
+ Project
+ .in_namespace(namespace.self_and_descendant_ids)
+ .public_or_visible_to_user(current_user, ::Gitlab::Access::DEVELOPER)
+ end
+ end
+ end
+end
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
new file mode 100644
index 00000000000..bb4584aacae
--- /dev/null
+++ b/app/models/ci/catalog/resource.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ # This class represents a CI/CD Catalog resource.
+ # A Catalog resource is normally associated to a project.
+ # This model connects to the `main` database because of its
+ # dependency on the Project model and its need to join with that table
+ # in order to generate the CI/CD catalog.
+ class Resource < ::ApplicationRecord
+ 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/commit_with_pipeline.rb b/app/models/ci/commit_with_pipeline.rb
index dde4b534aaa..2aa249df321 100644
--- a/app/models/ci/commit_with_pipeline.rb
+++ b/app/models/ci/commit_with_pipeline.rb
@@ -19,7 +19,7 @@ class Ci::CommitWithPipeline < SimpleDelegator
end
def lazy_latest_pipeline
- BatchLoader.for(sha).batch do |shas, loader|
+ BatchLoader.for(sha).batch(key: project.id) do |shas, loader|
preload_pipelines = project.ci_pipelines.latest_pipeline_per_commit(shas.compact)
shas.each do |sha|
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
index 598d1456a48..5ec54ee2983 100644
--- a/app/models/ci/daily_build_group_report_result.rb
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -4,9 +4,10 @@ module Ci
class DailyBuildGroupReportResult < Ci::ApplicationRecord
PARAM_TYPES = %w[coverage].freeze
- belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
+ belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id,
+ inverse_of: :daily_build_group_report_results
belongs_to :project
- belongs_to :group
+ belongs_to :group, class_name: '::Group'
validates :data, json_schema: { filename: "daily_build_group_report_result_data" }
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index b03c46a164f..f04f0d27e51 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -29,5 +29,13 @@ module Ci
def audit_details
key
end
+
+ def group_name
+ group.name
+ end
+
+ def group_ci_cd_settings_path
+ Gitlab::Routing.url_helpers.group_settings_ci_cd_path(group)
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 89a3d269a43..766155c6a99 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -132,7 +132,7 @@ module Ci
PLAN_LIMIT_PREFIX = 'ci_max_artifact_size_'
belongs_to :project
- belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id, inverse_of: :job_artifacts
mount_file_store_uploader JobArtifactUploader, skip_store_file: true
@@ -155,7 +155,7 @@ module Ci
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
scope :for_job_ids, ->(job_ids) { where(job_id: job_ids) }
- scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
+ scope :for_job_name, ->(name) { joins(:job).merge(Ci::Build.by_name(name)) }
scope :created_at_before, ->(time) { where(arel_table[:created_at].lteq(time)) }
scope :id_before, ->(id) { where(arel_table[:id].lteq(id)) }
scope :id_after, ->(id) { where(arel_table[:id].gt(id)) }
@@ -177,6 +177,8 @@ module Ci
where(file_type: self.erasable_file_types)
end
+ scope :non_trace, -> { where.not(file_type: [:trace]) }
+
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked) }
scope :order_expired_asc, -> { order(expire_at: :asc) }
diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb
index 20775077bd8..f389c642fd8 100644
--- a/app/models/ci/job_token/scope.rb
+++ b/app/models/ci/job_token/scope.rb
@@ -58,8 +58,7 @@ module Ci
end
def inbound_accessible?(accessed_project)
- # if the flag or setting is disabled any project is considered to be in scope.
- return true unless Feature.enabled?(:ci_inbound_job_token_scope, accessed_project)
+ # if the setting is disabled any project is considered to be in scope.
return true unless accessed_project.ci_inbound_job_token_scope_enabled?
inbound_linked_as_accessible?(accessed_project)
diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb
index 998f0647ad5..573999995bc 100644
--- a/app/models/ci/job_variable.rb
+++ b/app/models/ci/job_variable.rb
@@ -7,7 +7,7 @@ module Ci
include Ci::RawVariable
include BulkInsertSafe
- belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
+ belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id, inverse_of: :job_variables
partitionable scope: :job
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/pending_build.rb b/app/models/ci/pending_build.rb
index 2b1eb67d4f2..14050a1e78e 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -14,7 +14,6 @@ module Ci
validates :namespace, presence: true
scope :ref_protected, -> { where(protected: true) }
- scope :queued_before, ->(time) { where(arel_table[:created_at].lt(time)) }
scope :with_instance_runners, -> { where(instance_runners_enabled: true) }
scope :for_tags, ->(tag_ids) do
if tag_ids.present?
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index bd426e02b9c..babea831d85 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -11,7 +11,6 @@ module Ci
include Gitlab::OptimisticLocking
include Gitlab::Utils::StrongMemoize
include AtomicInternalId
- include EnumWithNil
include Ci::HasRef
include ShaAttribute
include FromUnion
@@ -19,6 +18,9 @@ module Ci
include EachBatch
include FastDestroyAll::Helpers
+ include IgnorableColumns
+ ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
+
MAX_OPEN_MERGE_REQUESTS_REFS = 4
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = {
@@ -46,39 +48,53 @@ module Ci
belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
- belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline', inverse_of: :auto_canceled_pipelines
belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule'
belongs_to :merge_request, class_name: 'MergeRequest'
belongs_to :external_pull_request
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
- has_many :processables, class_name: 'Ci::Processable', 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 :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 # rubocop:disable Cop/ActiveRecordDependent
+ has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id, inverse_of: :pipeline # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
has_many :downloadable_artifacts, -> do
- not_expired.or(where_exists(::Ci::Pipeline.artifacts_locked.where('ci_pipelines.id = ci_builds.commit_id'))).downloadable.with_job
+ not_expired.or(where_exists(Ci::Pipeline.artifacts_locked.where("#{Ci::Pipeline.quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id"))).downloadable.with_job
end, through: :latest_builds, source: :job_artifacts
has_many :latest_successful_builds, -> { latest.success.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build'
@@ -86,17 +102,24 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
- has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
+ has_many :merge_requests_as_head_pipeline, foreign_key: :head_pipeline_id, class_name: 'MergeRequest',
+ inverse_of: :head_pipeline
+
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
- has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
+ has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build',
+ inverse_of: :pipeline
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
- has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
+ has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus',
+ inverse_of: :pipeline
has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline
- has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
- has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
- has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_pipeline_id
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: :auto_canceled_by_id,
+ inverse_of: :auto_canceled_by
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: :auto_canceled_by_id,
+ inverse_of: :auto_canceled_by
+ has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_pipeline_id,
+ inverse_of: :source_pipeline
has_one :source_pipeline, class_name: 'Ci::Sources::Pipeline', inverse_of: :pipeline
@@ -114,7 +137,9 @@ module Ci
has_one :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :pipeline
- has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id
+ has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult',
+ foreign_key: :last_pipeline_id, inverse_of: :last_pipeline
+
has_many :latest_builds_report_results, through: :latest_builds, source: :report_results
has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :pipeline, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -143,9 +168,9 @@ module Ci
# We use `Enums::Ci::Pipeline.sources` here so that EE can more easily extend
# this `Hash` with new values.
- enum_with_nil source: Enums::Ci::Pipeline.sources
+ enum source: Enums::Ci::Pipeline.sources
- enum_with_nil config_source: Enums::Ci::Pipeline.config_sources
+ enum config_source: Enums::Ci::Pipeline.config_sources
# We use `Enums::Ci::Pipeline.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
@@ -336,6 +361,22 @@ module Ci
AutoDevops::DisableWorker.perform_async(pipeline.id) if pipeline.auto_devops_source?
end
end
+
+ after_transition any => [:running, *::Ci::Pipeline.completed_statuses] do |pipeline|
+ project = pipeline&.project
+
+ next unless project
+ next unless Feature.enabled?(:pipeline_trigger_merge_status, project)
+
+ pipeline.run_after_commit do
+ next if pipeline.child?
+ next unless project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true)
+
+ pipeline.all_merge_requests.opened.each do |merge_request|
+ GraphqlTriggers.merge_request_merge_status_updated(merge_request)
+ end
+ end
+ end
end
scope :internal, -> { where(source: internal_sources) }
@@ -361,18 +402,25 @@ 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)
end
scope :with_reports, -> (reports_scope) do
- where('EXISTS (?)', ::Ci::Build.latest.with_artifacts(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1))
+ where('EXISTS (?)',
+ ::Ci::Build
+ .latest
+ .with_artifacts(reports_scope)
+ .where("#{quoted_table_name}.id = #{Ci::Build.quoted_table_name}.commit_id")
+ .select(1)
+ )
end
scope :with_only_interruptible_builds, -> do
where('NOT EXISTS (?)',
- Ci::Build.where('ci_builds.commit_id = ci_pipelines.id')
+ Ci::Build.where("#{Ci::Build.quoted_table_name}.commit_id = #{quoted_table_name}.id")
.with_status(STARTED_STATUSES)
.not_interruptible
)
@@ -382,11 +430,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.
#
@@ -657,7 +709,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|
@@ -818,8 +870,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
@@ -1282,7 +1333,7 @@ module Ci
types_to_collect = report_types.empty? ? ::Ci::JobArtifact::SECURITY_REPORT_FILE_TYPES : report_types
::Gitlab::Ci::Reports::Security::Reports.new(self).tap do |security_reports|
- latest_report_builds(reports_scope).each do |build|
+ latest_report_builds_in_self_and_project_descendants(reports_scope).includes(pipeline: { project: :route }).each do |build| # rubocop:disable Rails/FindEach
build.collect_security_reports!(security_reports, report_types: types_to_collect)
end
end
@@ -1294,7 +1345,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 20ff07e88ba..49d27053745 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -8,14 +8,15 @@ module Ci
include CronSchedulable
include Limitable
include EachBatch
+ include BatchNullifyDependentAssociations
self.limit_name = 'ci_pipeline_schedules'
self.limit_scope = :project
belongs_to :project
belongs_to :owner, class_name: 'User'
- has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
- has_many :pipelines
+ has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline', inverse_of: :pipeline_schedule
+ has_many :pipelines, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineScheduleVariable'
validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? }
@@ -81,6 +82,14 @@ module Ci
def worker_cron_expression
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
+
+ super
+ end
end
end
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 8e83b41cd0b..f2457af0074 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -6,6 +6,9 @@ module Ci
include Ci::HasVariable
include Ci::RawVariable
+ include IgnorableColumns
+ ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22'
+
belongs_to :pipeline
partitionable scope: :pipeline
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 b788e4f58c1..48f321a236d 100644
--- a/app/models/ci/resource_group.rb
+++ b/app/models/ci/resource_group.rb
@@ -29,13 +29,19 @@ module Ci
partition_id: processable.partition_id
}
- resources.free.limit(1).update_all(attrs) > 0
+ success = resources.free.limit(1).update_all(attrs) > 0
+ log_event(success: success, processable: processable, action: "assign resource to processable")
+
+ success
end
def release_resource_from(processable)
attrs = { build_id: nil, partition_id: nil }
- resources.retained_by(processable).update_all(attrs) > 0
+ success = resources.retained_by(processable).update_all(attrs) > 0
+ log_event(success: success, processable: processable, action: "release resource from processable")
+
+ success
end
def upcoming_processables
@@ -52,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.
@@ -72,5 +82,14 @@ module Ci
# belong to the same resource group are executed once at time.
self.resources.build if self.resources.empty?
end
+
+ def log_event(success:, processable:, action:)
+ Gitlab::Ci::ResourceGroups::Logger.build.info({
+ resource_group_id: self.id,
+ processable_id: processable.id,
+ message: "attempted to #{action}",
+ success: success
+ })
+ end
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 09ac0fa69e7..7727e94875b 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -17,7 +17,10 @@ module Ci
extend ::Gitlab::Utils::Override
- add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration
+ add_authentication_token_field :token,
+ encrypted: :optional,
+ expires_at: :compute_token_expiration,
+ format_with_prefix: :prefix_for_new_and_legacy_runner
enum access_level: {
not_protected: 0,
@@ -54,6 +57,9 @@ module Ci
# The `STALE_TIMEOUT` constant defines the how far past the last contact or creation date a runner will be considered stale
STALE_TIMEOUT = 3.months
+ # Only allow authentication token to be visible for a short while
+ REGISTRATION_AVAILABILITY_TIME = 1.hour
+
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
AVAILABLE_TYPES = runner_types.keys.freeze
AVAILABLE_STATUSES = %w[active paused online offline never_contacted stale].freeze # TODO: Remove in %16.0: active, paused. Relevant issue: https://gitlab.com/gitlab-org/gitlab/-/issues/344648
@@ -64,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
@@ -81,8 +87,13 @@ module Ci
scope :active, -> (value = true) { where(active: value) }
scope :paused, -> { active(false) }
scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) }
- scope :recent, -> { where('ci_runners.created_at >= :date OR ci_runners.contacted_at >= :date', date: stale_deadline) }
- scope :stale, -> { where('ci_runners.created_at < :date AND (ci_runners.contacted_at IS NULL OR ci_runners.contacted_at < :date)', date: stale_deadline) }
+ scope :recent, -> do
+ where('ci_runners.created_at >= :datetime OR ci_runners.contacted_at >= :datetime', datetime: stale_deadline)
+ end
+ scope :stale, -> do
+ where('ci_runners.created_at <= :datetime AND ' \
+ '(ci_runners.contacted_at IS NULL OR ci_runners.contacted_at <= :datetime)', datetime: stale_deadline)
+ end
scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) }
scope :never_contacted, -> { where(contacted_at: nil) }
scope :ordered, -> { order(id: :desc) }
@@ -123,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 })
@@ -137,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
@@ -185,6 +196,7 @@ module Ci
scope :order_token_expires_at_asc, -> { order(token_expires_at: :asc) }
scope :order_token_expires_at_desc, -> { order(token_expires_at: :desc) }
scope :with_tags, -> { preload(:tags) }
+ scope :with_creator, -> { preload(:creator) }
validate :tag_constraints
validates :access_level, presence: true
@@ -203,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' }
@@ -332,15 +342,10 @@ module Ci
def stale?
return false unless created_at
- [created_at, contacted_at].compact.max < self.class.stale_deadline
+ [created_at, contacted_at].compact.max <= self.class.stale_deadline
end
- def status(legacy_mode = nil)
- # TODO Deprecate legacy_mode in %16.0 and make it a no-op
- # (see https://gitlab.com/gitlab-org/gitlab/-/issues/360545)
- # TODO Remove legacy_mode in %17.0
- return deprecated_rest_status if legacy_mode == '14.5'
-
+ def status
return :stale if stale?
return :never_contacted unless contacted_at
@@ -434,7 +439,7 @@ module Ci
ensure_runner_queue_value == value if value.present?
end
- def heartbeat(values)
+ def heartbeat(values, update_contacted_at: true)
##
# We can safely ignore writes performed by a runner heartbeat. We do
# not want to upgrade database connection proxy to use the primary
@@ -442,20 +447,18 @@ module Ci
#
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
- values[:contacted_at] = Time.current
+ values[:contacted_at] = Time.current if update_contacted_at
if values.include?(:executor)
values[:executor_type] = EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
end
- cache_attributes(values)
+ new_version = values[:version]
+ schedule_runner_version_update(new_version) if new_version && values[:version] != version
- # We save data without validation, it will always change due to `contacted_at`
- if persist_cached_data?
- version_updated = values.include?(:version) && values[:version] != version
+ merge_cache_attributes(values)
- update_columns(values)
- schedule_runner_version_update if version_updated
- end
+ # We save data without validation, it will always change due to `contacted_at`
+ update_columns(values) if persist_cached_data?
end
end
@@ -488,15 +491,18 @@ module Ci
end
end
- override :format_token
- def format_token(token)
- return token if registration_token_registration_type?
+ 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
- "#{CREATED_RUNNER_TOKEN_PREFIX}#{token}"
+ def registration_available?
+ authenticated_user_registration_type? &&
+ created_at > REGISTRATION_AVAILABILITY_TIME.ago &&
+ !runner_managers.any?
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 gitlab_hosted?
+ Gitlab.com? && instance_type?
end
private
@@ -586,7 +592,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
@@ -594,10 +600,16 @@ module Ci
# TODO Remove in 16.0 when runners are known to send a system_id
# For now, heartbeats with version updates might result in two Sidekiq jobs being queued if a runner has a system_id
# This is not a problem since the jobs are deduplicated on the version
- def schedule_runner_version_update
- return unless version
+ def schedule_runner_version_update(new_version)
+ return unless new_version && Gitlab::Ci::RunnerReleases.instance.enabled?
+
+ Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(new_version)
+ end
+
+ def prefix_for_new_and_legacy_runner
+ return if registration_token_registration_type?
- Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(version)
+ CREATED_RUNNER_TOKEN_PREFIX
end
end
end
diff --git a/app/models/ci/runner_machine.rb b/app/models/ci/runner_manager.rb
index e52659a011f..e36024d9f5b 100644
--- a/app/models/ci/runner_machine.rb
+++ b/app/models/ci/runner_manager.rb
@@ -1,23 +1,23 @@
# frozen_string_literal: true
module Ci
- class RunnerMachine < Ci::ApplicationRecord
+ class RunnerManager < Ci::ApplicationRecord
include FromUnion
include RedisCacheable
include Ci::HasRunnerExecutor
- include IgnorableColumns
- ignore_column :machine_xid, remove_with: '15.11', remove_after: '2022-03-22'
+ # 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
+ UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes)
belongs_to :runner
- has_many :build_metadata, class_name: 'Ci::BuildMetadata'
- has_many :builds, through: :build_metadata, 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 }
@@ -30,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
@@ -44,7 +44,15 @@ module Ci
remove_duplicates: false).where(created_some_time_ago)
end
- def heartbeat(values)
+ def self.online_contact_time_deadline
+ Ci::Runner.online_contact_time_deadline
+ end
+
+ def self.stale_deadline
+ STALE_TIMEOUT.ago
+ end
+
+ def heartbeat(values, update_contacted_at: true)
##
# We can safely ignore writes performed by a runner heartbeat. We do
# not want to upgrade database connection proxy to use the primary
@@ -52,24 +60,40 @@ module Ci
#
::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config, :executor) || {}
- values[:contacted_at] = Time.current
+ values[:contacted_at] = Time.current if update_contacted_at
if values.include?(:executor)
values[:executor_type] = Ci::Runner::EXECUTOR_NAME_TO_TYPES.fetch(values.delete(:executor), :unknown)
end
- version_changed = values.include?(:version) && values[:version] != version
+ new_version = values[:version]
+ schedule_runner_version_update(new_version) if new_version && values[:version] != version
- cache_attributes(values)
-
- schedule_runner_version_update if version_changed
+ merge_cache_attributes(values)
# We save data without validation, it will always change due to `contacted_at`
update_columns(values) if persist_cached_data?
end
end
+ def status
+ return :stale if stale?
+ return :never_contacted unless contacted_at
+
+ online? ? :online : :offline
+ end
+
private
+ def online?
+ contacted_at && contacted_at > self.class.online_contact_time_deadline
+ end
+
+ def stale?
+ return false unless created_at
+
+ [created_at, contacted_at].compact.max <= self.class.stale_deadline
+ end
+
def persist_cached_data?
# Use a random threshold to prevent beating DB updates.
contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY)
@@ -79,10 +103,10 @@ module Ci
(Time.current - real_contacted_at) >= contacted_at_max_age
end
- def schedule_runner_version_update
- return unless version
+ def schedule_runner_version_update(new_version)
+ return unless new_version && Gitlab::Ci::RunnerReleases.instance.enabled?
- Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(version)
+ Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(new_version)
end
end
end
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 ec42f46b165..03b50f13989 100644
--- a/app/models/ci/runner_version.rb
+++ b/app/models/ci/runner_version.rb
@@ -3,9 +3,8 @@
module Ci
class RunnerVersion < Ci::ApplicationRecord
include EachBatch
- include EnumWithNil
- enum_with_nil status: {
+ enum status: {
not_processed: nil,
invalid_version: -1,
unavailable: 1,
@@ -20,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/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index 855e68d1db1..719d19f4169 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -10,6 +10,7 @@ module Ci
belongs_to :project, class_name: "::Project"
belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :source_pipeline
+ belongs_to :build, class_name: "Ci::Build", foreign_key: :source_job_id, inverse_of: :sourced_pipelines
belongs_to :source_project, class_name: "::Project", foreign_key: :source_project_id
belongs_to :source_job, class_name: "CommitStatus", foreign_key: :source_job_id
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 46a9e3f6494..d61760bd0fc 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -27,6 +27,7 @@ module Ci
has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id, inverse_of: :ci_stage
has_many :builds, foreign_key: :stage_id, inverse_of: :ci_stage
has_many :bridges, foreign_key: :stage_id, inverse_of: :ci_stage
+ has_many :generic_commit_statuses, foreign_key: :stage_id, inverse_of: :ci_stage
scope :ordered, -> { order(position: :asc) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
@@ -111,12 +112,12 @@ 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
+ # This will be removed with ci_remove_ensure_stage_service
def update_legacy_status
set_status(latest_stage_status.to_s)
end
@@ -150,6 +151,7 @@ module Ci
blocked? || skipped?
end
+ # This will be removed with ci_remove_ensure_stage_service
def latest_stage_status
statuses.latest.composite_status || 'skipped'
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 1b2a7dc3fe4..58da1b4bd7e 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -8,7 +8,7 @@ module Ci
TRIGGER_TOKEN_PREFIX = 'glptt-'
- ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22'
+ ignore_column :ref, remove_with: '16.1', remove_after: '2023-05-22'
self.limit_name = 'pipeline_triggers'
self.limit_scope = :project
@@ -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..6980ec1c2d3 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -2,6 +2,8 @@
module Clusters
class Agent < ApplicationRecord
+ include FromUnion
+
self.table_name = 'cluster_agents'
INACTIVE_AFTER = 1.hour.freeze
@@ -11,12 +13,19 @@ module Clusters
belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project
has_many :agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent
+ has_many :active_agent_tokens, -> { active.order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent
+
+ 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 :group_authorizations, class_name: 'Clusters::Agents::GroupAuthorization'
- has_many :authorized_groups, class_name: '::Group', through: :group_authorizations, source: :group
+ 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 :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
- has_many :authorized_projects, class_name: '::Project', through: :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
@@ -51,6 +60,80 @@ module Clusters
def to_ability_name
:cluster
end
+
+ def ci_access_authorized_for?(user)
+ return false unless user
+ return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project)
+
+ ::Project.from_union(
+ all_ci_access_authorized_projects_for(user).limit(1),
+ all_ci_access_authorized_namespaces_for(user).limit(1)
+ ).exists?
+ end
+
+ def user_access_authorized_for?(user)
+ return false unless user
+ return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project)
+
+ Clusters::Agents::Authorizations::UserAccess::Finder
+ .new(user, agent: self, preload: false, limit: 1).execute.any?
+ end
+
+ # As of today, all config values of associated authorization rows have the same value.
+ # See `UserAccess::RefreshService` for more information.
+ def user_access_config
+ self.class.from_union(
+ user_access_project_authorizations.select('config').limit(1),
+ user_access_group_authorizations.select('config').limit(1)
+ ).compact.first&.config
+ end
+
+ private
+
+ def all_ci_access_authorized_projects_for(user)
+ ::Project.joins(:ci_access_project_authorizations)
+ .joins(:project_authorizations)
+ .where(agent_project_authorizations: { agent_id: id })
+ .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
+ end
+
+ def all_ci_access_authorized_namespaces_for(user)
+ ::Project.with(root_namespace_cte.to_arel)
+ .with(all_ci_access_authorized_namespaces_cte.to_arel)
+ .joins('INNER JOIN all_authorized_namespaces ON all_authorized_namespaces.id = projects.namespace_id')
+ .joins(:project_authorizations)
+ .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
+ end
+
+ def root_namespace_cte
+ Gitlab::SQL::CTE.new(:root_namespace, root_namespace.to_sql)
+ end
+
+ def all_ci_access_authorized_namespaces_cte
+ Gitlab::SQL::CTE.new(:all_authorized_namespaces, all_ci_access_authorized_namespaces.to_sql)
+ end
+
+ def all_ci_access_authorized_namespaces
+ Namespace.select("traversal_ids[array_length(traversal_ids, 1)] AS id")
+ .joins("INNER JOIN root_namespace ON " \
+ "namespaces.traversal_ids @> ARRAY[root_namespace.root_id]")
+ .joins("INNER JOIN agent_group_authorizations ON " \
+ "namespaces.traversal_ids @> ARRAY[agent_group_authorizations.group_id::integer]")
+ .where(agent_group_authorizations: { agent_id: id })
+ end
+
+ def root_namespace
+ Namespace.select("traversal_ids[1] AS root_id")
+ .where("traversal_ids @> ARRAY(?)", project_namespace)
+ .limit(1)
+ end
+
+ def project_namespace
+ ::Project.select('namespace_id')
+ .joins(:cluster_agents)
+ .where(cluster_agents: { id: id })
+ .limit(1)
+ end
end
end
diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb
index e2dcff13a69..b2b13f6cef7 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -20,6 +20,7 @@ module Clusters
scope :order_last_used_at_desc, -> { order(arel_table[:last_used_at].desc.nulls_last) }
scope :with_status, -> (status) { where(status: status) }
+ scope :active, -> { where(status: :active) }
enum status: {
active: 0,
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..7027870855a
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/user_access/group_authorization.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class GroupAuthorization < ApplicationRecord
+ include Scopes
+
+ 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
+
+ scope :for_user, ->(user) {
+ with(groups_with_direct_membership_cte(user).to_arel)
+ .with(all_groups_with_membership_cte.to_arel)
+ .joins('INNER JOIN all_groups_with_membership ON ' \
+ 'all_groups_with_membership.id = agent_user_access_group_authorizations.group_id')
+ .select('DISTINCT ON (id) agent_user_access_group_authorizations.*, ' \
+ 'all_groups_with_membership.access_level AS access_level')
+ .order('id, access_level DESC')
+ }
+
+ scope :for_project, ->(project) {
+ where('all_groups_with_membership.traversal_ids @> ARRAY[?]', project.namespace_id)
+ }
+
+ 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
+
+ def all_groups_with_membership_cte
+ Gitlab::SQL::CTE.new(:all_groups_with_membership, all_groups_with_membership.to_sql)
+ end
+
+ def all_groups_with_membership
+ ::Group.joins('INNER JOIN groups_with_direct_membership ON ' \
+ 'namespaces.traversal_ids @> ARRAY[groups_with_direct_membership.id]')
+ .select('namespaces.id AS id, ' \
+ 'namespaces.traversal_ids AS traversal_ids, ' \
+ 'groups_with_direct_membership.access_level AS access_level')
+ end
+
+ def groups_with_direct_membership_cte(user)
+ Gitlab::SQL::CTE.new(:groups_with_direct_membership, groups_with_direct_membership_for(user).to_sql)
+ end
+
+ def groups_with_direct_membership_for(user)
+ ::Group.joins("INNER JOIN members ON " \
+ "members.source_id = namespaces.id AND members.source_type = 'Namespace'")
+ .where(members: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
+ .select('namespaces.id AS id, members.access_level AS access_level')
+ 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..476666e3ad8
--- /dev/null
+++ b/app/models/clusters/agents/authorizations/user_access/project_authorization.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ class ProjectAuthorization < ApplicationRecord
+ include Scopes
+
+ 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
+
+ scope :for_user, ->(user) {
+ joins('INNER JOIN project_authorizations ON ' \
+ 'project_authorizations.project_id = agent_user_access_project_authorizations.project_id')
+ .where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
+ .select('agent_user_access_project_authorizations.*, project_authorizations.access_level AS access_level')
+ }
+
+ scope :for_project, ->(project) { where(project: project) }
+
+ 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/crossplane.rb b/app/models/clusters/applications/crossplane.rb
deleted file mode 100644
index a7b4fb57149..00000000000
--- a/app/models/clusters/applications/crossplane.rb
+++ /dev/null
@@ -1,58 +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 Crossplane < ApplicationRecord
- VERSION = '0.4.1'
-
- self.table_name = 'clusters_applications_crossplane'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
-
- attribute :version, default: VERSION
- attribute :stack, default: ""
-
- validates :stack, presence: true
-
- def chart
- 'crossplane/crossplane'
- end
-
- def repository
- 'https://charts.crossplane.io/alpha'
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: 'crossplane',
- repository: repository,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files
- )
- end
-
- def values
- crossplane_values.to_yaml
- end
-
- private
-
- def crossplane_values
- {
- "clusterStacks" => {
- self.stack => {
- "deploy" => true
- }
- }
- }
- 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 64366594583..00000000000
--- a/app/models/clusters/applications/knative.rb
+++ /dev/null
@@ -1,156 +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'
-
- has_one :serverless_domain_cluster, class_name: '::Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :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) }
-
- has_one :pages_domain, through: :serverless_domain_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
-
- def install_knative_metrics
- return [] unless cluster.application_prometheus&.available?
-
- [Gitlab::Kubernetes::KubectlCmd.apply_file(METRICS_CONFIG)]
- end
-
- def delete_knative_istio_metrics
- return [] unless cluster.application_prometheus&.available?
-
- [Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)]
- end
- end
- end
-end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
deleted file mode 100644
index a076c871824..00000000000
--- a/app/models/clusters/applications/prometheus.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class Prometheus < ApplicationRecord
- include ::Clusters::Concerns::PrometheusClient
-
- VERSION = '10.4.1'
-
- self.table_name = 'clusters_applications_prometheus'
-
- include ::Clusters::Concerns::ApplicationCore
- include ::Clusters::Concerns::ApplicationStatus
- include ::Clusters::Concerns::ApplicationVersion
- include ::Clusters::Concerns::ApplicationData
- include AfterCommitQueue
-
- attribute :version, default: VERSION
-
- scope :preload_cluster_platform, -> { preload(cluster: [:platform_kubernetes]) }
-
- attr_encrypted :alert_manager_token,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm'
-
- after_initialize :set_alert_manager_token, if: :new_record?
-
- after_destroy do
- cluster.find_or_build_integration_prometheus.destroy
- end
-
- state_machine :status do
- after_transition any => [:installed, :externally_installed] do |application|
- application.cluster.find_or_build_integration_prometheus.update(enabled: true, alert_manager_token: application.alert_manager_token)
- end
-
- after_transition any => :updating do |application|
- application.update(last_update_started_at: Time.current)
- end
- end
-
- def managed_prometheus?
- !externally_installed? && !uninstalled?
- end
-
- def updated_since?(timestamp)
- last_update_started_at &&
- last_update_started_at > timestamp &&
- !update_errored?
- end
-
- def chart
- "#{name}/prometheus"
- end
-
- def repository
- 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
- end
-
- def install_command
- helm_command_module::InstallCommand.new(
- name: name,
- repository: repository,
- version: VERSION,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files,
- postinstall: install_knative_metrics
- )
- end
-
- # Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280
- def patch_command(values)
- helm_command_module::PatchCommand.new(
- name: name,
- repository: repository,
- version: version,
- rbac: cluster.platform_kubernetes_rbac?,
- chart: chart,
- files: files_with_replaced_values(values)
- )
- end
-
- def uninstall_command
- helm_command_module::DeleteCommand.new(
- name: name,
- rbac: cluster.platform_kubernetes_rbac?,
- files: files,
- predelete: delete_knative_istio_metrics
- )
- end
-
- # Returns a copy of files where the values of 'values.yaml'
- # are replaced by the argument.
- #
- # See #values for the data format required
- def files_with_replaced_values(replaced_values)
- files.merge('values.yaml': replaced_values)
- end
-
- private
-
- def set_alert_manager_token
- self.alert_manager_token = SecureRandom.hex
- end
-
- def install_knative_metrics
- return [] unless cluster.application_knative_available?
-
- [Gitlab::Kubernetes::KubectlCmd.apply_file(Clusters::Applications::Knative::METRICS_CONFIG)]
- end
-
- def delete_knative_istio_metrics
- return [] unless cluster.application_knative_available?
-
- [
- Gitlab::Kubernetes::KubectlCmd.delete(
- "-f", Clusters::Applications::Knative::METRICS_CONFIG,
- "--ignore-not-found"
- )
- ]
- 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 a35ea6ddb46..a2903bba6d2 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -11,18 +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::Crossplane.application_name => Clusters::Applications::Crossplane,
- Clusters::Applications::Prometheus.application_name => Clusters::Applications::Prometheus,
- 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
@@ -54,14 +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 :crossplane
- has_one_cluster_application :prometheus
- 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
@@ -88,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?
@@ -123,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) }
@@ -232,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
@@ -270,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
@@ -365,12 +313,6 @@ module Clusters
end
end
- def serverless_domain
- strong_memoize(:serverless_domain) do
- self.application_knative&.serverless_domain_cluster
- end
- end
-
def prometheus_adapter
integration_prometheus
end
diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb
index 42332bdc193..dfb5c4cc5eb 100644
--- a/app/models/clusters/kubernetes_namespace.rb
+++ b/app/models/clusters/kubernetes_namespace.rb
@@ -22,9 +22,9 @@ module Clusters
delegate :api_url, to: :platform_kubernetes, allow_nil: true
attr_encrypted :service_account_token,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_truncated,
- algorithm: 'aes-256-cbc'
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-cbc'
scope :has_service_account_token, -> { where.not(encrypted_service_account_token: nil) }
scope :with_environment_name, -> (name) { joins(:environment).where(environments: { name: name }) }
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 165285b34b2..123ad0ebfaf 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -4,7 +4,6 @@ module Clusters
module Platforms
class Kubernetes < ApplicationRecord
include Gitlab::Kubernetes
- include EnumWithNil
include AfterCommitQueue
include ReactiveCaching
include NullifyIfBlank
@@ -63,7 +62,7 @@ module Clusters
alias_attribute :ca_pem, :ca_cert
- enum_with_nil authorization_type: {
+ enum authorization_type: {
unknown_authorization: nil,
rbac: 1,
abac: 2
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 4517b3ef216..6d17d7f495d 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -206,7 +206,8 @@ class Commit
def self.link_reference_pattern
@link_reference_pattern ||=
- super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/o)
+ compose_link_reference_pattern('commit',
+ /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/o)
end
def to_reference(from = nil, full: false)
@@ -387,8 +388,6 @@ class Commit
Gitlab::X509::Commit.new(self).signature
when :SSH
Gitlab::Ssh::Commit.new(self).signature
- else
- nil
end
end
end
@@ -573,8 +572,43 @@ class Commit
}
end
+ def tipping_branches(limit: 0)
+ tipping_refs(Gitlab::Git::BRANCH_REF_PREFIX, limit: limit)
+ end
+
+ def tipping_tags(limit: 0)
+ tipping_refs(Gitlab::Git::TAG_REF_PREFIX, limit: limit)
+ end
+
+ def branches_containing(limit: 0, exclude_tipped: false)
+ # WARNING: This argument can be confusing, if there is a limit.
+ # for example set the limit to 5 and in the 5 out a total of 25 refs there is 2 tipped refs,
+ # then the method will only 3 refs, even though there is more.
+ excluded = exclude_tipped ? tipping_branches : []
+
+ refs = repository.branch_names_contains(id, limit: limit) || []
+ refs - excluded
+ end
+
+ def tags_containing(limit: 0, exclude_tipped: false)
+ # WARNING: This argument can be confusing, if there is a limit.
+ # for example set the limit to 5 and in the 5 out a total of 25 refs there is 2 tipped refs,
+ # then the method will only 3 refs, even though there is more.
+ excluded = exclude_tipped ? tipping_tags : []
+
+ refs = repository.tag_names_contains(id, limit: limit) || []
+ refs - excluded
+ end
+
private
+ def tipping_refs(ref_prefix, limit: 0)
+ strong_memoize_with(:tipping_tags, ref_prefix, limit) do
+ refs = repository.refs_by_oid(oid: id, ref_patterns: [ref_prefix], limit: limit)
+ refs.map { |n| n.delete_prefix(ref_prefix) }
+ end
+ end
+
def expire_note_etag_cache_for_related_mrs
MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each(&:expire_note_etag_cache)
end
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 47ecdfa8574..edc60a757d2 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -30,6 +30,10 @@ class CommitCollection
User.by_any_email(emails)
end
+ def committer_user_ids
+ committers.pluck(:id)
+ end
+
def without_merge_commits
strong_memoize(:without_merge_commits) do
# `#enrich!` the collection to ensure all commits contain
@@ -118,4 +122,21 @@ class CommitCollection
def next_page
@pagination.next_page
end
+
+ def load_tags
+ oids = commits.map(&:id)
+ references = repository.list_refs([Gitlab::Git::TAG_REF_PREFIX], pointing_at_oids: oids, peel_tags: true)
+ oid_to_references = references.group_by { |reference| reference.peeled_target.presence || reference.target }
+
+ return self if oid_to_references.empty?
+
+ commits.each do |commit|
+ grouped_references = oid_to_references[commit.id]
+ next unless grouped_references
+
+ commit.referenced_by = grouped_references.map(&:name)
+ end
+
+ self
+ end
end
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 87029cb2033..90cdd267cbd 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -50,7 +50,7 @@ class CommitRange
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("compare", /(?<commit_range>#{PATTERN})/o)
+ @link_reference_pattern ||= compose_link_reference_pattern('compare', /(?<commit_range>#{PATTERN})/o)
end
# Initialize a CommitRange
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 333a176b8f3..6dfea7ef9a7 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -6,17 +6,20 @@ class CommitStatus < Ci::ApplicationRecord
include Importable
include AfterCommitQueue
include Presentable
- include EnumWithNil
include BulkInsertableAssociations
include TaggableQueries
+ include SafelyChangeColumnDefault
self.table_name = 'ci_builds'
+ self.sequence_name = 'ci_builds_id_seq'
+ self.primary_key = :id
partitionable scope: :pipeline
+ columns_changing_default :partition_id
belongs_to :user
belongs_to :project
- belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
- belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+ belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, inverse_of: :statuses
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline', inverse_of: :auto_canceled_jobs
belongs_to :ci_stage, class_name: 'Ci::Stage', foreign_key: :stage_id
has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
@@ -26,13 +29,14 @@ class CommitStatus < Ci::ApplicationRecord
enum scheduling_type: { stage: 0, dag: 1 }, _prefix: true
# We use `Enums::Ci::CommitStatus.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
- enum_with_nil failure_reason: Enums::Ci::CommitStatus.failure_reasons
+ enum failure_reason: Enums::Ci::CommitStatus.failure_reasons
delegate :commit, to: :pipeline
delegate :sha, :short_sha, :before_sha, to: :pipeline
validates :pipeline, presence: true, unless: :importing?
validates :name, presence: true, unless: :importing?
+ validates :stage, :ref, :target_url, :description, length: { maximum: 255 }
alias_attribute :author, :user
alias_attribute :pipeline_id, :commit_id
@@ -43,14 +47,6 @@ class CommitStatus < Ci::ApplicationRecord
scope :order_id_desc, -> { order(id: :desc) }
- scope :exclude_ignored, -> do
- # We want to ignore failed but allowed to fail jobs.
- #
- # TODO, we also skip ignored optional manual actions.
- where("allow_failure = ? OR status IN (?)",
- false, all_state_names - [:failed, :canceled, :manual])
- end
-
scope :latest, -> { where(retried: [false, nil]) }
scope :retried, -> { where(retried: true) }
scope :ordered, -> { order(:name) }
@@ -66,12 +62,13 @@ class CommitStatus < Ci::ApplicationRecord
scope :by_name, -> (name) { where(name: name) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
scope :with_pipeline, -> { joins(:pipeline) }
- scope :updated_at_before, ->(date) { where('ci_builds.updated_at < ?', date) }
- scope :created_at_before, ->(date) { where('ci_builds.created_at < ?', date) }
+ scope :updated_at_before, ->(date) { where("#{quoted_table_name}.updated_at < ?", date) }
+ scope :created_at_before, ->(date) { where("#{quoted_table_name}.created_at < ?", date) }
scope :scheduled_at_before, ->(date) {
- where('ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', date)
+ where("#{quoted_table_name}.scheduled_at IS NOT NULL AND #{quoted_table_name}.scheduled_at < ?", date)
}
scope :with_when_executed, ->(when_executed) { where(when: when_executed) }
+ scope :with_type, ->(type) { where(type: type) }
# The scope applies `pluck` to split the queries. Use with care.
scope :for_project_paths, -> (paths) do
@@ -239,10 +236,6 @@ class CommitStatus < Ci::ApplicationRecord
name.to_s.sub(regex, '').strip
end
- def failed_but_allowed?
- allow_failure? && (failed? || canceled?)
- end
-
# Time spent running.
def duration
calculate_duration(started_at, finished_at)
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/analytics/cycle_analytics/stageable.rb b/app/models/concerns/analytics/cycle_analytics/stageable.rb
index caac4f31e1a..d1dd46883e3 100644
--- a/app/models/concerns/analytics/cycle_analytics/stageable.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stageable.rb
@@ -7,8 +7,8 @@ module Analytics
include Gitlab::Utils::StrongMemoize
included do
- belongs_to :start_event_label, class_name: 'GroupLabel', optional: true
- belongs_to :end_event_label, class_name: 'GroupLabel', optional: true
+ belongs_to :start_event_label, class_name: 'Label', optional: true
+ belongs_to :end_event_label, class_name: 'Label', optional: true
belongs_to :stage_event_hash, class_name: 'Analytics::CycleAnalytics::StageEventHash', optional: true
validates :name, presence: true
@@ -119,10 +119,11 @@ module Analytics
end
def label_available_for_namespace?(label_id)
- subject = is_a?(::Analytics::CycleAnalytics::Stage) ? namespace : project.group
+ subject = namespace.is_a?(Namespaces::ProjectNamespace) ? namespace.project.group : namespace
return unless subject
- LabelsFinder.new(nil, { group_id: subject.id, include_ancestor_groups: true, only_group_labels: true })
+ LabelsFinder.new(nil,
+ { group_id: subject.id, include_ancestor_groups: true, only_group_labels: namespace.is_a?(Group) })
.execute(skip_authorization: true)
.id_in(label_id)
.exists?
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 14be924f9da..ec4ee7985fe 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -61,6 +61,8 @@ module AtomicInternalId
AtomicInternalId.project_init(self)
when :group
AtomicInternalId.group_init(self)
+ when :namespace
+ AtomicInternalId.namespace_init(self)
else
# We require init here to retain the ability to recalculate in the absence of a
# InternalId record (we may delete records in `internal_ids` for example).
@@ -241,6 +243,16 @@ module AtomicInternalId
end
end
+ def self.namespace_init(klass, column_name = :iid)
+ ->(instance, scope) do
+ if instance
+ klass.where(namespace_id: instance.namespace_id).maximum(column_name)
+ elsif scope.present?
+ klass.where(**scope).maximum(column_name)
+ end
+ end
+ end
+
def internal_id_read_scope(scope)
association(scope).reader
end
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/cached_commit.rb b/app/models/concerns/cached_commit.rb
index 0fb72552dd5..8a53fec0612 100644
--- a/app/models/concerns/cached_commit.rb
+++ b/app/models/concerns/cached_commit.rb
@@ -14,4 +14,9 @@ module CachedCommit
def parent_ids
[]
end
+
+ # These are not saved
+ def referenced_by
+ []
+ end
end
diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb
index 731729a1ed5..d0ee4f33ce6 100644
--- a/app/models/concerns/cascading_namespace_setting_attribute.rb
+++ b/app/models/concerns/cascading_namespace_setting_attribute.rb
@@ -57,11 +57,13 @@ module CascadingNamespaceSettingAttribute
# private methods
define_validator_methods(attribute)
+ define_attr_before_save(attribute)
define_after_update(attribute)
validate :"#{attribute}_changeable?"
validate :"lock_#{attribute}_changeable?"
+ before_save :"before_save_#{attribute}", if: -> { will_save_change_to_attribute?(attribute) }
after_update :"clear_descendant_#{attribute}_locks", if: -> { saved_change_to_attribute?("lock_#{attribute}", to: true) }
end
end
@@ -92,13 +94,26 @@ module CascadingNamespaceSettingAttribute
def define_attr_writer(attribute)
define_method("#{attribute}=") do |value|
- return value if value == cascaded_ancestor_value(attribute)
+ return value if read_attribute(attribute).nil? && to_bool(value) == cascaded_ancestor_value(attribute)
clear_memoization(attribute)
super(value)
end
end
+ def define_attr_before_save(attribute)
+ # rubocop:disable GitlabSecurity/PublicSend
+ define_method("before_save_#{attribute}") do
+ new_value = public_send(attribute)
+ if public_send("#{attribute}_was").nil? && new_value == cascaded_ancestor_value(attribute)
+ write_attribute(attribute, nil)
+ end
+ end
+ # rubocop:enable GitlabSecurity/PublicSend
+
+ private :"before_save_#{attribute}"
+ end
+
def define_lock_attr_writer(attribute)
define_method("lock_#{attribute}=") do |value|
attr_value = public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
@@ -239,4 +254,8 @@ module CascadingNamespaceSettingAttribute
namespace.descendants.pluck(:id)
end
end
+
+ def to_bool(value)
+ ActiveModel::Type::Boolean.new.cast(value)
+ end
end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index 9a04776f1c6..2971ecb04b8 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -13,7 +13,7 @@ module Ci
STOPPED_STATUSES = COMPLETED_STATUSES + BLOCKED_STATUS
ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
- EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
+ IGNORED_STATUSES = %w[manual].to_set.freeze
ALIVE_STATUSES = (ACTIVE_STATUSES + ['created']).freeze
CANCELABLE_STATUSES = (ALIVE_STATUSES + ['scheduled']).freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
@@ -23,6 +23,7 @@ module Ci
UnknownStatusError = Class.new(StandardError)
class_methods do
+ # This will be removed with ci_remove_ensure_stage_service
def composite_status
Gitlab::Ci::Status::Composite
.new(all, with_allow_failure: columns_hash.key?('allow_failure'))
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 d6ba0f4488f..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,6 +36,7 @@ module Ci
Ci::Pipeline
Ci::PendingBuild
Ci::RunningBuild
+ Ci::RunnerManagerBuild
Ci::PipelineVariable
Ci::Sources::Pipeline
Ci::Stage
@@ -70,8 +71,8 @@ module Ci
class_methods do
def partitionable(scope:, through: nil, partitioned: false)
handle_partitionable_through(through)
- handle_partitionable_dml(partitioned)
handle_partitionable_scope(scope)
+ handle_partitionable_ddl(partitioned)
end
private
@@ -85,13 +86,6 @@ module Ci
include Partitionable::Switch
end
- def handle_partitionable_dml(partitioned)
- define_singleton_method(:partitioned?) { partitioned }
- return unless partitioned
-
- include Partitionable::PartitionedFilter
- end
-
def handle_partitionable_scope(scope)
define_method(:partition_scope_value) do
strong_memoize(:partition_scope_value) do
@@ -102,6 +96,17 @@ module Ci
end
end
end
+
+ def handle_partitionable_ddl(partitioned)
+ return unless partitioned
+
+ include ::PartitionedTable
+
+ partitioned_by :partition_id,
+ strategy: :ci_sliding_list,
+ next_partition_if: proc { false },
+ detach_partition_if: proc { false }
+ end
end
end
end
diff --git a/app/models/concerns/ci/partitionable/partitioned_filter.rb b/app/models/concerns/ci/partitionable/partitioned_filter.rb
deleted file mode 100644
index 4adae3be26a..00000000000
--- a/app/models/concerns/ci/partitionable/partitioned_filter.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module Partitionable
- # Used to patch the save, update, delete, destroy methods to use the
- # partition_id attributes for their SQL queries.
- module PartitionedFilter
- extend ActiveSupport::Concern
-
- if Rails::VERSION::MAJOR >= 7
- # These methods are updated in Rails 7 to use `_primary_key_constraints_hash`
- # by default, so this patch will no longer be required.
- #
- # rubocop:disable Gitlab/NoCodeCoverageComment
- # :nocov:
- raise "`#{__FILE__}` should be double checked" if Rails.env.test?
-
- warn "Update `#{__FILE__}`. Patches Rails internals for partitioning"
- # :nocov:
- # rubocop:enable Gitlab/NoCodeCoverageComment
- else
- def _update_row(attribute_names, attempted_action = "update")
- self.class._update_record(
- attributes_with_values(attribute_names),
- _primary_key_constraints_hash
- )
- end
-
- def _delete_row
- self.class._delete_record(_primary_key_constraints_hash)
- end
- end
-
- # Introduced in Rails 7, but updated to include `partition_id` filter.
- # https://github.com/rails/rails/blob/a4dbb153fd390ac31bb9808809e7ac4d3a2c5116/activerecord/lib/active_record/persistence.rb#L1031-L1033
- def _primary_key_constraints_hash
- { @primary_key => id_in_database, partition_id: partition_id } # rubocop:disable Gitlab/ModuleWithInstanceVariables
- end
- end
- end
-end
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/clusters/agents/authorizations/user_access/scopes.rb b/app/models/concerns/clusters/agents/authorizations/user_access/scopes.rb
new file mode 100644
index 00000000000..515b4ed3c87
--- /dev/null
+++ b/app/models/concerns/clusters/agents/authorizations/user_access/scopes.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ module Authorizations
+ module UserAccess
+ module Scopes
+ extend ActiveSupport::Concern
+
+ included do
+ scope :for_agent, ->(agent) { where(agent: agent) }
+ scope :preloaded, -> { joins(agent: :project).preload(agent: :project) }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index 58ea57962c5..56608c49a6b 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -5,7 +5,7 @@
# after a period of time (10 minutes).
# When an attribute is incremented by a value, the increment is added
# to a Redis key. Then, FlushCounterIncrementsWorker will execute
-# `flush_increments_to_database!` which removes increments from Redis for a
+# `commit_increment!` which removes increments from Redis for a
# given model attribute and updates the values in the database.
#
# @example:
@@ -29,8 +29,24 @@
# counter_attribute :conditional_one, if: -> { |object| object.use_counter_attribute? }
# end
#
+# The `counter_attribute` by default will return last persisted value.
+# It's possible to always return accurate (real) value instead by using `returns_current: true`.
+# While doing this the `counter_attribute` will overwrite attribute accessor to fetch
+# the buffered information added to the last persisted value. This will incur cost a Redis call per attribute fetched.
+#
+# @example:
+#
+# class ProjectStatistics
+# include CounterAttribute
+#
+# counter_attribute :commit_count, returns_current: true
+# end
+#
+# in that case
+# model.commit_count => persisted value + buffered amount to be added
+#
# To increment the counter we can use the method:
-# increment_counter(:commit_count, 3)
+# increment_amount(:commit_count, 3)
#
# This method would determine whether it would increment the counter using Redis,
# or fallback to legacy increment on ActiveRecord counters.
@@ -50,11 +66,22 @@ module CounterAttribute
include Gitlab::Utils::StrongMemoize
class_methods do
- def counter_attribute(attribute, if: nil)
+ def counter_attribute(attribute, if: nil, returns_current: false)
counter_attributes << {
attribute: attribute,
- if_proc: binding.local_variable_get(:if) # can't read `if` directly
+ if_proc: binding.local_variable_get(:if), # can't read `if` directly
+ returns_current: returns_current
}
+
+ if returns_current
+ define_method(attribute) do
+ current_counter(attribute)
+ end
+ end
+
+ define_method("increment_#{attribute}") do |amount|
+ increment_amount(attribute, amount)
+ end
end
def counter_attributes
@@ -87,6 +114,15 @@ module CounterAttribute
end
end
+ def increment_amount(attribute, amount)
+ counter = Gitlab::Counters::Increment.new(amount: amount)
+ increment_counter(attribute, counter)
+ end
+
+ def current_counter(attribute)
+ read_attribute(attribute) + counter(attribute).get
+ end
+
def increment_counter(attribute, increment)
return if increment.amount == 0
@@ -165,14 +201,13 @@ module CounterAttribute
#
# It does not guarantee that there will not be any concurrent updates.
def detect_race_on_record(log_fields: {})
- return yield unless Feature.enabled?(:counter_attribute_db_lease_for_update, project)
-
# Ensure attributes is always an array before we log
log_fields[:attributes] = Array(log_fields[:attributes])
Gitlab::AppLogger.info(
message: 'Acquiring lease for project statistics update',
- project_statistics_id: id,
+ model: self.class.name,
+ model_id: id,
project_id: project.id,
**log_fields,
**Gitlab::ApplicationContext.current
@@ -184,7 +219,8 @@ module CounterAttribute
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
Gitlab::AppLogger.warn(
message: 'Concurrent project statistics update detected',
- project_statistics_id: id,
+ model: self.class.name,
+ model_id: id,
project_id: project.id,
**log_fields,
**Gitlab::ApplicationContext.current
diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb
index 9f75b3ed4d8..26e184c202f 100644
--- a/app/models/concerns/database_event_tracking.rb
+++ b/app/models/concerns/database_event_tracking.rb
@@ -3,6 +3,8 @@
module DatabaseEventTracking
extend ActiveSupport::Concern
+ FEATURE_FLAG_BATCH2_CLASSES = %w[Vulnerability MergeRequest::Metrics].freeze
+
included do
after_create_commit :publish_database_create_event
after_destroy_commit :publish_database_destroy_event
@@ -22,7 +24,8 @@ module DatabaseEventTracking
end
def publish_database_event(name)
- return unless Feature.enabled?(:product_intelligence_database_event_tracking)
+ return unless database_events_for_class_enabled?
+ return unless database_events_feature_flag_enabled?
# Gitlab::Tracking#event is triggering Snowplow event
# Snowplow events are sent with usage of
@@ -30,11 +33,12 @@ 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,
- namespace: try(:group) || try(:namespace),
+ project: try(:project),
+ namespace: (try(:group) || try(:namespace)) || try(:project)&.namespace,
property: name,
**filtered_record_attributes
)
@@ -50,4 +54,14 @@ module DatabaseEventTracking
.with_indifferent_access
.slice(*self.class::SNOWPLOW_ATTRIBUTES)
end
+
+ def database_events_for_class_enabled?
+ is_batch2 = FEATURE_FLAG_BATCH2_CLASSES.include?(self.class.to_s)
+
+ !is_batch2 || Feature.enabled?(:product_intelligence_database_event_tracking_batch2)
+ end
+
+ def database_events_feature_flag_enabled?
+ Feature.enabled?(:product_intelligence_database_event_tracking)
+ end
end
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/each_batch.rb b/app/models/concerns/each_batch.rb
index dbc0887dc97..79fb81e7820 100644
--- a/app/models/concerns/each_batch.rb
+++ b/app/models/concerns/each_batch.rb
@@ -161,5 +161,81 @@ module EachBatch
break unless stop
end
end
+
+ # Iterates over the relation and counts the rows. The counting
+ # logic is combined with the iteration query which saves one query
+ # compared to a standard each_batch approach.
+ #
+ # Basic usage:
+ # count, _last_value = Project.each_batch_count
+ #
+ # The counting can be stopped by passing a block and making the last statement true.
+ # Example:
+ #
+ # query_count = 0
+ # count, last_value = Project.each_batch_count do
+ # query_count += 1
+ # query_count == 5 # stop counting after 5 loops
+ # end
+ #
+ # Resume where the previous counting has stopped:
+ #
+ # count, last_value = Project.each_batch_count(last_count: count, last_value: last_value)
+ #
+ # Another example, counting issues in project:
+ #
+ # project = Project.find(1)
+ # count, _ = project.issues.each_batch_count(column: :iid)
+ def each_batch_count(of: 1000, column: :id, last_count: 0, last_value: nil)
+ arel_table = self.arel_table
+ window = Arel::Nodes::Window.new.order(arel_table[column])
+ last_value_column = Arel::Nodes::NamedFunction
+ .new('LAST_VALUE', [arel_table[column]])
+ .over(window)
+ .as(column.to_s)
+
+ loop do
+ count_column = Arel::Nodes::Addition
+ .new(Arel::Nodes::NamedFunction.new('ROW_NUMBER', []).over(window), last_count)
+ .as('count')
+
+ projections = [count_column, last_value_column]
+ scope = limit(1).offset(of - 1)
+ scope = scope.where(arel_table[column].gt(last_value)) if last_value
+ new_count, last_value = scope.pick(*projections)
+
+ # When reaching the last batch the offset query might return no data, to address this
+ # problem, we invoke a specialized query that takes the last row out of the resultset.
+ # We could do this for each batch, however it would add unnecessary overhead to all
+ # queries.
+ if new_count.nil?
+ inner_query = scope
+ .select(*projections)
+ .limit(nil)
+ .offset(nil)
+ .arel
+ .as(quoted_table_name)
+
+ new_count, last_value =
+ unscoped
+ .from(inner_query)
+ .order(count: :desc)
+ .limit(1)
+ .pick(:count, column)
+
+ last_count = new_count if new_count
+ last_value = nil
+ break
+ end
+
+ last_count = new_count
+
+ if block_given?
+ should_break = yield(last_count, last_value)
+ break if should_break
+ end
+ end
+ [last_count, last_value]
+ end
end
end
diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb
deleted file mode 100644
index c66942025d7..00000000000
--- a/app/models/concerns/enum_with_nil.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module EnumWithNil
- extend ActiveSupport::Concern
-
- included do
- def self.enum_with_nil(definitions)
- # use original `enum` to auto-define all methods
- enum(definitions)
-
- # override auto-defined methods only for the
- # key which uses nil value
- definitions.each do |name, values|
- # E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
- # this overrides auto-generated method `failure_reason`
- define_method(name) do
- orig = super()
-
- return orig unless orig.nil?
-
- self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
- end
- end
-end
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/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index 8ed6c54441b..778471eac8b 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -11,7 +11,6 @@ module Enums
config_error: 1,
external_validation_failure: 2,
user_not_verified: 3,
- activity_limit_exceeded: 20,
size_limit_exceeded: 21,
job_activity_limit_exceeded: 22,
deployments_limit_exceeded: 23,
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..3f107987ef6 100644
--- a/app/models/concerns/enums/package_metadata.rb
+++ b/app/models/concerns/enums/package_metadata.rb
@@ -10,11 +10,46 @@ 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
+
+ ADVISORY_SOURCES = {
+ glad: 1, # gitlab advisory db
+ trivy: 2
+ }.with_indifferent_access.freeze
+
+ DATA_TYPES = {
+ advisories: 1,
+ licenses: 2
+ }.with_indifferent_access.freeze
+
+ VERSION_FORMATS = {
+ v1: 1,
+ v2: 2
}.with_indifferent_access.freeze
def self.purl_types
PURL_TYPES
end
+
+ def self.purl_types_numerical
+ purl_types.invert
+ end
+
+ def self.advisory_sources
+ ADVISORY_SOURCES
+ end
+
+ def self.data_types
+ DATA_TYPES
+ end
+
+ def self.version_formats
+ VERSION_FORMATS
+ 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_unique_internal_users.rb b/app/models/concerns/has_unique_internal_users.rb
index 4d60cfa03b0..25b56f6d70f 100644
--- a/app/models/concerns/has_unique_internal_users.rb
+++ b/app/models/concerns/has_unique_internal_users.rb
@@ -28,7 +28,7 @@ module HasUniqueInternalUsers
existing_user = uncached { scope.first }
return existing_user if existing_user.present?
- uniquify = Uniquify.new
+ uniquify = Gitlab::Utils::Uniquify.new
username = uniquify.string(username) { |s| User.find_by_username(s) }
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index b02c95c9662..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,
@@ -14,8 +15,11 @@ module HasUserType
migration_bot: 7,
security_bot: 8,
automation_bot: 9,
+ security_policy_bot: 10, # Currently not in use. See https://gitlab.com/gitlab-org/gitlab/-/issues/384174
admin_bot: 11,
- suggested_reviewers_bot: 12
+ suggested_reviewers_bot: 12,
+ service_account: 13,
+ llm_bot: 14
}.with_indifferent_access.freeze
BOT_USER_TYPES = %w[
@@ -26,15 +30,24 @@ module HasUserType
migration_bot
security_bot
automation_bot
+ security_policy_bot
admin_bot
suggested_reviewers_bot
+ service_account
+ llm_bot
].freeze
- NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].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 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)) }
@@ -42,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
@@ -53,10 +64,8 @@ module HasUserType
BOT_USER_TYPES.include?(user_type)
end
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def internal?
- ghost? || (bot? && !project_bot?)
+ INTERNAL_USER_TYPES.include?(user_type)
end
def redacted_name(viewing_user)
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 50696c7b5e1..b1ec6b8ba32 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 }
@@ -174,6 +174,10 @@ module Issuable
end
end
+ def issuable_type
+ self.class.name.underscore
+ end
+
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
@@ -197,15 +201,15 @@ module Issuable
end
def supports_severity?
- incident?
+ incident_type_issue?
end
def supports_escalation?
- incident?
+ incident_type_issue?
end
- def incident?
- is_a?(Issue) && super
+ def incident_type_issue?
+ is_a?(Issue) && work_item_type&.incident?
end
def supports_issue_type?
@@ -345,8 +349,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 +623,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
##
@@ -640,10 +645,6 @@ module Issuable
false
end
- def ensure_metrics
- self.metrics || create_metrics
- end
-
##
# Overridden in MergeRequest
#
@@ -658,6 +659,10 @@ module Issuable
{ name: name, subject: self }
end
+
+ def supports_health_status?
+ false
+ end
end
Issuable.prepend_mod_with('Issuable')
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..0b6075fbeb8 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
@@ -22,14 +20,14 @@ module Mentionable
def self.default_pattern
strong_memoize(:default_pattern) do
issue_pattern = Issue.reference_pattern
- link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact)
+ link_patterns = Regexp.union([Issue, WorkItem, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact)
reference_pattern(link_patterns, issue_pattern)
end
end
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/noteable.rb b/app/models/concerns/noteable.rb
index 7addcf9e2ec..65e7f734233 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -169,6 +169,7 @@ module Noteable
def expire_note_etag_cache
return unless discussions_rendered_on_frontend?
return unless etag_caching_enabled?
+ return unless project.present?
Gitlab::EtagCaching::Store.new.touch(note_etag_key)
end
@@ -197,7 +198,7 @@ module Noteable
def creatable_note_email_address(author)
return unless supports_creating_notes_by_email?
- project_email = project.new_issuable_address(author, self.class.name.underscore)
+ project_email = project&.new_issuable_address(author, base_class_name.underscore)
return unless project_email
project_email.sub('@', "-#{iid}@")
diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb
index 77409549e85..cc7279d05f8 100644
--- a/app/models/concerns/packages/debian/component_file.rb
+++ b/app/models/concerns/packages/debian/component_file.rb
@@ -8,6 +8,9 @@ module Packages
included do
include Sortable
include FileStoreMounter
+ include IgnorableColumns
+
+ ignore_column :file_md5, remove_with: '16.2', remove_after: '2023-06-22'
def self.container_foreign_key
"#{container_type}_id".to_sym
@@ -30,7 +33,6 @@ module Packages
validates :file, length: { minimum: 0, allow_nil: false }
validates :size, presence: true
validates :file_store, presence: true
- validates :file_md5, presence: true
validates :file_sha256, presence: true
scope :with_container, ->(container) do
@@ -88,6 +90,10 @@ module Packages
end
end
+ def empty?
+ size == 0
+ end
+
private
def extension
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
index f95f9dd8ad7..c322a736e79 100644
--- a/app/models/concerns/partitioned_table.rb
+++ b/app/models/concerns/partitioned_table.rb
@@ -8,7 +8,8 @@ module PartitionedTable
PARTITIONING_STRATEGIES = {
monthly: Gitlab::Database::Partitioning::MonthlyStrategy,
- sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy
+ sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy,
+ ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy
}.freeze
def partitioned_by(partitioning_key, strategy:, **kwargs)
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index 58761fce952..8156090fd9c 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -9,10 +9,4 @@ module ProtectedBranchAccess
delegate :project, to: :protected_branch
end
-
- def check_access(user)
- return false if access_level == Gitlab::Access::NO_ACCESS
-
- super
- end
end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index facf0808e7a..c1c670db543 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -2,38 +2,45 @@
module ProtectedRefAccess
extend ActiveSupport::Concern
- HUMAN_ACCESS_LEVELS = {
- Gitlab::Access::MAINTAINER => "Maintainers",
- Gitlab::Access::DEVELOPER => "Developers + Maintainers",
- Gitlab::Access::NO_ACCESS => "No one"
- }.freeze
class_methods do
+ def human_access_levels
+ {
+ Gitlab::Access::DEVELOPER => 'Developers + Maintainers',
+ Gitlab::Access::MAINTAINER => 'Maintainers',
+ Gitlab::Access::ADMIN => 'Instance admins',
+ Gitlab::Access::NO_ACCESS => 'No one'
+ }.slice(*allowed_access_levels)
+ end
+
def allowed_access_levels
- [
- Gitlab::Access::MAINTAINER,
+ levels = [
Gitlab::Access::DEVELOPER,
+ Gitlab::Access::MAINTAINER,
+ Gitlab::Access::ADMIN,
Gitlab::Access::NO_ACCESS
]
+
+ return levels unless Gitlab.com?
+
+ levels.excluding(Gitlab::Access::ADMIN)
+ end
+
+ def humanize(access_level)
+ human_access_levels[access_level]
end
end
included do
scope :maintainer, -> { where(access_level: Gitlab::Access::MAINTAINER) }
scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
- scope :by_user, -> (user) { where(user_id: user) }
- scope :by_group, -> (group) { where(group_id: group) }
scope :for_role, -> { where(user_id: nil, group_id: nil) }
- 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]
+ self.class.humanize(access_level)
end
def type
@@ -44,12 +51,28 @@ module ProtectedRefAccess
type == :role
end
- def check_access(user)
- return false unless user
- return true if user.admin?
+ def check_access(current_user)
+ return false if current_user.nil? || no_access?
+ return current_user.admin? if admin_access?
+
+ yield if block_given?
+
+ user_can_access?(current_user)
+ end
+
+ private
+
+ def admin_access?
+ role? && access_level == ::Gitlab::Access::ADMIN
+ end
+
+ def no_access?
+ role? && access_level == Gitlab::Access::NO_ACCESS
+ end
- user.can?(:push_code, project) &&
- project.team.max_member_access(user.id) >= access_level
+ def user_can_access?(current_user)
+ current_user.can?(:push_code, project) &&
+ project.team.max_member_access(current_user.id) >= access_level
end
end
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index f1d29ad5a90..460cb529715 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -33,6 +33,14 @@ module RedisCacheable
clear_memoization(:cached_attributes)
end
+ def merge_cache_attributes(values)
+ existing_attributes = Hash(cached_attributes)
+ merged_attributes = existing_attributes.merge(values.symbolize_keys)
+ return if merged_attributes == existing_attributes
+
+ cache_attributes(merged_attributes)
+ end
+
private
def cache_attribute_key
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 9a17131c91c..5303d110078 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -76,7 +76,11 @@ module Referable
true
end
- def link_reference_pattern(route, pattern)
+ def link_reference_pattern
+ raise NotImplementedError, "#{self} does not implement #{__method__}"
+ end
+
+ def compose_link_reference_pattern(route, pattern)
%r{
(?<url>
#{Regexp.escape(Gitlab.config.gitlab.url)}
diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb
index 5ff4f520d24..d7182778b36 100644
--- a/app/models/concerns/require_email_verification.rb
+++ b/app/models/concerns/require_email_verification.rb
@@ -47,6 +47,7 @@ module RequireEmailVerification
def override_devise_lockable?
Feature.enabled?(:require_email_verification, self) &&
!two_factor_enabled? &&
+ identities.none? &&
Feature.disabled?(:skip_require_email_verification, self, type: :ops)
end
strong_memoize_attr :override_devise_lockable?
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/routable.rb b/app/models/concerns/routable.rb
index 262839a3fa6..d70aad4e9ae 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -99,39 +99,11 @@ module Routable
end
def full_name
- # We have to test for persistence as the cache key uses #updated_at
- return (route&.name || build_full_name) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops)
-
- # Return the name as-is if the parent is missing
- return name if route.nil? && parent.nil? && name.present?
-
- # If the route is already preloaded, return directly, preventing an extra load
- return route.name if route_loaded? && route.present?
-
- # Similarly, we can allow the build if the parent is loaded
- return build_full_name if parent_loaded?
-
- Gitlab::Cache.fetch_once([cache_key, :full_name]) do
- route&.name || build_full_name
- end
+ full_attribute(:name)
end
def full_path
- # We have to test for persistence as the cache key uses #updated_at
- return (route&.path || build_full_path) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops)
-
- # Return the path as-is if the parent is missing
- return path if route.nil? && parent.nil? && path.present?
-
- # If the route is already preloaded, return directly, preventing an extra load
- return route.path if route_loaded? && route.present?
-
- # Similarly, we can allow the build if the parent is loaded
- return build_full_path if parent_loaded?
-
- Gitlab::Cache.fetch_once([cache_key, :full_path]) do
- route&.path || build_full_path
- end
+ full_attribute(:path)
end
# Overriden in the Project model
@@ -163,6 +135,31 @@ module Routable
private
+ # rubocop: disable GitlabSecurity/PublicSend
+ def full_attribute(attribute)
+ attribute_from_route_or_self = ->(attribute) do
+ route&.public_send(attribute) || send("build_full_#{attribute}")
+ end
+
+ unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops)
+ return attribute_from_route_or_self.call(attribute)
+ end
+
+ # Return the attribute as-is if the parent is missing
+ return public_send(attribute) if route.nil? && parent.nil? && public_send(attribute).present?
+
+ # If the route is already preloaded, return directly, preventing an extra load
+ return route.public_send(attribute) if route_loaded? && route.present? && route.public_send(attribute)
+
+ # Similarly, we can allow the build if the parent is loaded
+ return send("build_full_#{attribute}") if parent_loaded?
+
+ Gitlab::Cache.fetch_once([cache_key, :"full_#{attribute}"]) do
+ attribute_from_route_or_self.call(attribute)
+ end
+ end
+ # rubocop: enable GitlabSecurity/PublicSend
+
def set_path_errors
route_path_errors = self.errors.delete(:"route.path")
route_path_errors&.each do |msg|
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index 5a10ea7a248..fe47393c554 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -27,8 +27,6 @@ module Subscribable
def lazy_subscription(user, project = nil)
return unless user
- # handle project and group labels as well as issuable subscriptions
- subscribable_type = self.class.ancestors.include?(Label) ? 'Label' : self.class.name
BatchLoader.for(id: id, subscribable_type: subscribable_type, project_id: project&.id).batch do |items, loader|
values = items.each_with_object({ ids: Set.new, subscribable_types: Set.new, project_ids: Set.new }) do |item, result|
result[:ids] << item[:id]
@@ -121,4 +119,15 @@ module Subscribable
subscriptions
.where(t[:project_id].eq(nil).or(t[:project_id].eq(project.try(:id))))
end
+
+ def subscribable_type
+ # handle project and group labels as well as issuable subscriptions
+ if self.class.ancestors.include?(Label)
+ 'Label'
+ elsif self.class.ancestors.include?(Issue)
+ 'Issue'
+ else
+ self.class.name
+ end
+ end
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index dee1c820f23..bf645e99b5e 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -15,19 +15,19 @@ module Taskable
INCOMPLETE_PATTERN = /\[[[:space:]]\]/.freeze
ITEM_PATTERN = %r{
^
- (?:(?:>\s{0,4})*) # optional blockquote characters
- ((?:\s*(?:[-+*]|(?:\d+\.)))+) # list prefix (one or more) required - task item has to be always in a list
- \s+ # whitespace prefix has to be always presented for a list item
- ( # checkbox
+ (?:(?:>\s{0,4})*) # optional blockquote characters
+ ((?:\s*(?:[-+*]|(?:\d+[.)])))+) # list prefix (one or more) required - task item has to be always in a list
+ \s+ # whitespace prefix has to be always presented for a list item
+ ( # checkbox
#{COMPLETE_PATTERN}|#{INCOMPLETE_PATTERN}
)
- (\s.+) # followed by whitespace and some text.
+ (\s.+) # followed by whitespace and some text.
}x.freeze
ITEM_PATTERN_UNTRUSTED =
'^' \
'(?:(?:>\s{0,4})*)' \
- '(?P<prefix>(?:\s*(?:[-+*]|(?:\d+\.)))+)' \
+ '(?P<prefix>(?:\s*(?:[-+*]|(?:\d+[.)])))+)' \
'\s+' \
'(?P<checkbox>' \
"#{COMPLETE_PATTERN.source}|#{INCOMPLETE_PATTERN.source}" \
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 2b677f37c89..d0085b60d98 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -31,9 +31,13 @@ module TokenAuthenticatableStrategies
result
end
- # Default implementation returns the token as-is
+ # If a `format_with_prefix` option is provided, it applies and returns the formatted token.
+ # Otherwise, default implementation returns the token as-is
def format_token(instance, token)
- instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend
+ prefix = prefix_for(instance)
+ prefixed_token = prefix ? "#{prefix}#{token}" : token
+
+ instance.send("format_#{@token_field}", prefixed_token) # rubocop:disable GitlabSecurity/PublicSend
end
def ensure_token(instance)
@@ -88,6 +92,17 @@ module TokenAuthenticatableStrategies
protected
+ def prefix_for(instance)
+ case prefix_option = options[:format_with_prefix]
+ when nil
+ nil
+ when Symbol
+ instance.send(prefix_option) # rubocop:disable GitlabSecurity/PublicSend
+ else
+ raise NotImplementedError
+ end
+ end
+
def write_new_token(instance)
new_token = generate_available_token
formatted_token = format_token(instance, new_token)
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index 1db88c27181..4b3b80437db 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -106,11 +106,7 @@ module TokenAuthenticatableStrategies
end
def matches_prefix?(instance, token)
- prefix = options[:prefix]
- prefix = prefix.call(instance) if prefix.is_a?(Proc)
- prefix = '' unless prefix.is_a?(String)
-
- token.start_with?(prefix)
+ !options[:require_prefix_for_validation] || token.start_with?(prefix_for(instance))
end
def token_set?(instance)
diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
index 447521ad8c1..5e77dfde397 100644
--- a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
@@ -20,8 +20,6 @@ module TokenAuthenticatableStrategies
end
def self.encrypt_token(plaintext_token)
- return Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token) unless Feature.enabled?(:dynamic_nonce, type: :ops)
-
iv = ::Digest::SHA256.hexdigest(plaintext_token).bytes.take(NONCE_SIZE).pack('c*')
token = Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token, nonce: iv)
"#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv}"
diff --git a/app/models/concerns/uniquify.rb b/app/models/concerns/uniquify.rb
deleted file mode 100644
index 382e826ec58..00000000000
--- a/app/models/concerns/uniquify.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-# Uniquify
-#
-# Return a version of the given 'base' string that is unique
-# by appending a counter to it. Uniqueness is determined by
-# repeated calls to the passed block.
-#
-# You can pass an initial value for the counter, if not given
-# counting starts from 1.
-#
-# If `base` is a function/proc, we expect that calling it with a
-# candidate counter returns a string to test/return.
-class Uniquify
- def initialize(counter = nil)
- @counter = counter
- end
-
- def string(base)
- @base = base
-
- increment_counter! while yield(base_string)
- base_string
- end
-
- private
-
- def base_string
- if @base.respond_to?(:call)
- @base.call(@counter)
- else
- "#{@base}#{@counter}"
- end
- end
-
- def increment_counter!
- @counter ||= 0
- @counter += 1
- end
-end
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 2cc17a6f185..2ad2e47ec4e 100644
--- a/app/models/concerns/web_hooks/auto_disabling.rb
+++ b/app/models/concerns/web_hooks/auto_disabling.rb
@@ -4,7 +4,32 @@ module WebHooks
module AutoDisabling
extend ActiveSupport::Concern
+ ENABLED_HOOK_TYPES = %w[ProjectHook].freeze
+ MAX_FAILURES = 100
+ FAILURE_THRESHOLD = 3
+ EXCEEDED_FAILURE_THRESHOLD = FAILURE_THRESHOLD + 1
+ INITIAL_BACKOFF = 1.minute.freeze
+ MAX_BACKOFF = 1.day.freeze
+ BACKOFF_GROWTH_FACTOR = 2.0
+
+ class_methods do
+ def auto_disabling_enabled?
+ enabled_hook_types.include?(name) &&
+ Gitlab::SafeRequestStore.fetch(:auto_disabling_web_hooks) do
+ Feature.enabled?(:auto_disabling_web_hooks, type: :ops)
+ end
+ end
+
+ private
+
+ def enabled_hook_types
+ ENABLED_HOOK_TYPES
+ end
+ end
+
included do
+ delegate :auto_disabling_enabled?, to: :class, private: true
+
# A hook is disabled if:
#
# - we are no longer in the grace-perod (recent_failures > ?)
@@ -12,8 +37,13 @@ module WebHooks
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - or disabled_until is in the future (i.e. this was set by WebHook#backoff!)
scope :disabled, -> do
- where('recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)',
- WebHook::FAILURE_THRESHOLD, Time.current)
+ return none unless auto_disabling_enabled?
+
+ where(
+ 'recent_failures > ? AND (disabled_until IS NULL OR disabled_until >= ?)',
+ FAILURE_THRESHOLD,
+ Time.current
+ )
end
# A hook is executable if:
@@ -23,40 +53,85 @@ module WebHooks
# - disabled_until is nil (i.e. this was set by WebHook#fail!)
# - disabled_until is in the future (i.e. this was set by WebHook#backoff!)
scope :executable, -> do
- where('recent_failures <= ? OR (recent_failures > ? AND (disabled_until IS NOT NULL) AND (disabled_until < ?))',
- WebHook::FAILURE_THRESHOLD, WebHook::FAILURE_THRESHOLD, Time.current)
+ 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
+ )
end
end
def executable?
+ return true unless auto_disabling_enabled?
+
!temporarily_disabled? && !permanently_disabled?
end
def temporarily_disabled?
- return false if recent_failures <= WebHook::FAILURE_THRESHOLD
+ return false unless auto_disabling_enabled?
- disabled_until.present? && disabled_until >= Time.current
+ disabled_until.present? && disabled_until >= Time.current && recent_failures > FAILURE_THRESHOLD
end
def permanently_disabled?
- return false if disabled_until.present?
+ return false unless auto_disabling_enabled?
- recent_failures > WebHook::FAILURE_THRESHOLD
+ recent_failures > FAILURE_THRESHOLD && disabled_until.blank?
end
def disable!
- return if permanently_disabled?
+ return if !auto_disabling_enabled? || permanently_disabled?
- super
+ update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD)
end
+ def enable!
+ return unless auto_disabling_enabled?
+ return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0
+
+ assign_attributes(recent_failures: 0, disabled_until: nil, backoff_count: 0)
+ save(validate: false)
+ end
+
+ # Don't actually back-off until FAILURE_THRESHOLD failures have been seen
+ # we mark the grace-period using the recent_failures counter
def backoff!
- return if permanently_disabled? || (backoff_count >= WebHook::MAX_FAILURES && temporarily_disabled?)
+ return unless auto_disabling_enabled?
+ return if permanently_disabled? || (backoff_count >= MAX_FAILURES && temporarily_disabled?)
+
+ attrs = { recent_failures: next_failure_count }
- super
+ if recent_failures >= FAILURE_THRESHOLD
+ attrs[:backoff_count] = next_backoff_count
+ attrs[:disabled_until] = next_backoff.from_now
+ end
+
+ assign_attributes(attrs)
+ save(validate: false) if changed?
+ end
+
+ def failed!
+ return unless auto_disabling_enabled?
+ return unless recent_failures < MAX_FAILURES
+
+ assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count)
+ save(validate: false)
+ end
+
+ def next_backoff
+ return MAX_BACKOFF if backoff_count >= 8 # optimization to prevent expensive exponentiation and possible overflows
+
+ (INITIAL_BACKOFF * (BACKOFF_GROWTH_FACTOR**backoff_count))
+ .clamp(INITIAL_BACKOFF, MAX_BACKOFF)
+ .seconds
end
def alert_status
+ return :executable unless auto_disabling_enabled?
+
if temporarily_disabled?
:temporarily_disabled
elsif permanently_disabled?
@@ -65,5 +140,18 @@ module WebHooks
:executable
end
end
+
+ private
+
+ def next_failure_count
+ recent_failures.succ.clamp(1, MAX_FAILURES)
+ end
+
+ def next_backoff_count
+ backoff_count.succ.clamp(1, MAX_FAILURES)
+ end
end
end
+
+WebHooks::AutoDisabling.prepend_mod
+WebHooks::AutoDisabling::ClassMethods.prepend_mod
diff --git a/app/models/concerns/web_hooks/has_web_hooks.rb b/app/models/concerns/web_hooks/has_web_hooks.rb
index 161ce106b9b..2183cc3c44b 100644
--- a/app/models/concerns/web_hooks/has_web_hooks.rb
+++ b/app/models/concerns/web_hooks/has_web_hooks.rb
@@ -2,8 +2,6 @@
module WebHooks
module HasWebHooks
- extend ActiveSupport::Concern
-
WEB_HOOK_CACHE_EXPIRY = 1.hour
def any_hook_failed?
@@ -15,7 +13,7 @@ module WebHooks
end
def last_failure_redis_key
- "web_hooks:last_failure:project-#{id}"
+ "web_hooks:last_failure:#{self.class.name.underscore}-#{id}"
end
def get_web_hook_failure
@@ -42,5 +40,13 @@ module WebHooks
state
end
end
+
+ def last_webhook_failure
+ last_failure = Gitlab::Redis::SharedState.with do |redis|
+ redis.get(last_failure_redis_key)
+ end
+
+ DateTime.parse(last_failure) if last_failure
+ end
end
end
diff --git a/app/models/concerns/web_hooks/unstoppable.rb b/app/models/concerns/web_hooks/unstoppable.rb
deleted file mode 100644
index 26284fe3c36..00000000000
--- a/app/models/concerns/web_hooks/unstoppable.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module WebHooks
- module Unstoppable
- extend ActiveSupport::Concern
-
- included do
- scope :executable, -> { all }
-
- scope :disabled, -> { none }
- end
-
- def executable?
- true
- end
-
- def temporarily_disabled?
- false
- end
-
- def permanently_disabled?
- false
- end
-
- def alert_status
- :executable
- end
- 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_registry/data_repair_detail.rb b/app/models/container_registry/data_repair_detail.rb
new file mode 100644
index 00000000000..a2616490905
--- /dev/null
+++ b/app/models/container_registry/data_repair_detail.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ class DataRepairDetail < ApplicationRecord
+ include EachBatch
+
+ self.table_name = 'container_registry_data_repair_details'
+ self.primary_key = :project_id
+
+ belongs_to :project, optional: false
+
+ enum status: { ongoing: 0, completed: 1, failed: 2 }
+
+ scope :ongoing_since, ->(threshold) { where(status: :ongoing).where('updated_at < ?', threshold) }
+ end
+end
diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb
index c4d06be8841..dd2675e17d8 100644
--- a/app/models/container_registry/event.rb
+++ b/app/models/container_registry/event.rb
@@ -8,7 +8,7 @@ module ContainerRegistry
PUSH_ACTION = 'push'
DELETE_ACTION = 'delete'
EVENT_TRACKING_CATEGORY = 'container_registry:notification'
- EVENT_PREFIX = "i_container_registry"
+ EVENT_PREFIX = 'i_container_registry'
ALLOWED_ACTOR_TYPES = %w(
personal_access_token
@@ -48,8 +48,12 @@ module ContainerRegistry
::Gitlab::Tracking.event(EVENT_TRACKING_CATEGORY, tracking_action)
- event = usage_data_event_for(tracking_action)
- ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event, values: originator.id) if event
+ if manifest_delete_event?
+ ::Gitlab::UsageDataCounters::ContainerRegistryEventCounter.count("#{EVENT_PREFIX}_delete_manifest")
+ else
+ event = usage_data_event_for(tracking_action)
+ ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event, values: originator.id) if event
+ end
end
private
@@ -122,9 +126,13 @@ module ContainerRegistry
end
end
+ def manifest_delete_event?
+ action_delete? && target_digest?
+ end
+
def update_project_statistics
return unless supported?
- return unless target_tag? || (action_delete? && target_digest?)
+ return unless target_tag? || manifest_delete_event?
return unless project
Rails.cache.delete(project.root_ancestor.container_repositories_size_cache_key)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 98ce981ad8e..0f0abeae795 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
@@ -32,8 +38,8 @@ class ContainerRepository < ApplicationRecord
validates :migration_aborted_in_state, inclusion: { in: ABORTABLE_MIGRATION_STATES }, allow_nil: true
validates :migration_retries_count, presence: true,
- numericality: { greater_than_or_equal_to: 0 },
- allow_nil: false
+ numericality: { greater_than_or_equal_to: 0 },
+ allow_nil: false
enum status: { delete_scheduled: 0, delete_failed: 1, delete_ongoing: 2 }
enum expiration_policy_cleanup_status: { cleanup_unscheduled: 0, cleanup_scheduled: 1, cleanup_unfinished: 2, cleanup_ongoing: 3 }
@@ -69,7 +75,7 @@ class ContainerRepository < ApplicationRecord
scope :with_migration_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_import_started_at, '01-01-1970') < ?", timestamp) }
scope :with_migration_pre_import_started_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_started_at, '01-01-1970') < ?", timestamp) }
scope :with_migration_pre_import_done_at_nil_or_before, ->(timestamp) { where("COALESCE(migration_pre_import_done_at, '01-01-1970') < ?", timestamp) }
- scope :with_stale_ongoing_cleanup, ->(threshold) { cleanup_ongoing.where('expiration_policy_started_at < ?', threshold) }
+ scope :with_stale_ongoing_cleanup, ->(threshold) { cleanup_ongoing.expiration_policy_started_at_nil_or_before(threshold) }
scope :with_stale_delete_at, ->(threshold) { where('delete_started_at < ?', threshold) }
scope :import_in_process, -> { where(migration_state: %w[pre_importing pre_import_done importing]) }
@@ -118,9 +124,7 @@ class ContainerRepository < ApplicationRecord
state :import_done
state :import_skipped do
- validates :migration_skipped_reason,
- :migration_skipped_at,
- presence: true
+ validates :migration_skipped_reason, :migration_skipped_at, presence: true
end
state :import_aborted do
@@ -289,6 +293,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
@@ -395,7 +403,7 @@ class ContainerRepository < ApplicationRecord
end
def migrated?
- (self.created_at && MIGRATION_PHASE_1_ENDED_AT < self.created_at) || import_done?
+ Gitlab.com?
end
def last_import_step_done_at
@@ -410,7 +418,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
@@ -509,7 +517,11 @@ class ContainerRepository < ApplicationRecord
end
def start_expiration_policy!
- update!(expiration_policy_started_at: Time.zone.now, last_cleanup_deleted_tags_count: nil)
+ update!(
+ expiration_policy_started_at: Time.zone.now,
+ last_cleanup_deleted_tags_count: nil,
+ expiration_policy_cleanup_status: :cleanup_ongoing
+ )
end
def size
@@ -589,8 +601,7 @@ class ContainerRepository < ApplicationRecord
end
def self.build_from_path(path)
- self.new(project: path.repository_project,
- name: path.repository_name)
+ self.new(project: path.repository_project, name: path.repository_name)
end
def self.find_or_create_from_path(path)
@@ -608,13 +619,11 @@ class ContainerRepository < ApplicationRecord
end
def self.find_by_path!(path)
- self.find_by!(project: path.repository_project,
- name: path.repository_name)
+ self.find_by!(project: path.repository_project, name: path.repository_name)
end
def self.find_by_path(path)
- self.find_by(project: path.repository_project,
- name: path.repository_name)
+ self.find_by(project: path.repository_project, name: path.repository_name)
end
private
diff --git a/app/models/cycle_analytics/project_level_stage_adapter.rb b/app/models/cycle_analytics/project_level_stage_adapter.rb
index 9b9c0822f63..ae21a4a6bfe 100644
--- a/app/models/cycle_analytics/project_level_stage_adapter.rb
+++ b/app/models/cycle_analytics/project_level_stage_adapter.rb
@@ -16,12 +16,12 @@ module CycleAnalytics
presenter = Analytics::CycleAnalytics::StagePresenter.new(stage)
serializer.new.represent(ProjectLevelStage.new(
- title: presenter.title,
- description: presenter.description,
- legend: presenter.legend,
- name: stage.name,
- project_median: median
- ))
+ title: presenter.title,
+ description: presenter.description,
+ legend: presenter.legend,
+ name: stage.name,
+ project_median: median
+ ))
end
# rubocop: enable CodeReuse/Presenter
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index 5ad746e4cd1..11fe0503f50 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -12,6 +12,11 @@ class DependencyProxy::Manifest < ApplicationRecord
MAX_FILE_SIZE = 10.megabytes.freeze
DIGEST_HEADER = 'Docker-Content-Digest'
+ ACCEPTED_TYPES = [
+ ContainerRegistry::BaseClient::DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE,
+ ContainerRegistry::BaseClient::OCI_MANIFEST_V1_TYPE,
+ ContainerRegistry::BaseClient::OCI_DISTRIBUTION_INDEX_TYPE
+ ].freeze
validates :group, presence: true
validates :file, presence: true
diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb
index 6492acf325a..3073dd59c7b 100644
--- a/app/models/dependency_proxy/registry.rb
+++ b/app/models/dependency_proxy/registry.rb
@@ -33,3 +33,5 @@ class DependencyProxy::Registry
end
end
end
+
+::DependencyProxy::Registry.prepend_mod
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index f8873d388a3..f3ee21ea4e0 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -372,9 +372,11 @@ class Deployment < ApplicationRecord
# i.e.:
# MergeRequest.select(1, 2).to_sql #=> SELECT 1, 2 FROM "merge_requests"
# MergeRequest.select(1, 1).to_sql #=> SELECT 1 FROM "merge_requests"
- select = relation.select('merge_requests.id',
- "#{id} as deployment_id",
- "#{environment_id} as environment_id").to_sql
+ select = relation.select(
+ 'merge_requests.id',
+ "#{id} as deployment_id",
+ "#{environment_id} as environment_id"
+ ).to_sql
# We don't use `ApplicationRecord.legacy_bulk_insert` here so that we don't need to
# first pluck lots of IDs into memory.
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index 317399e780a..505935bb230 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -13,6 +13,9 @@ module DesignManagement
include RelativePositioning
include Todoable
include Participable
+ include CacheMarkdownField
+
+ cache_markdown_field :description
belongs_to :project, inverse_of: :designs
belongs_to :issue
@@ -28,12 +31,13 @@ module DesignManagement
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_internal_id :iid, scope: :project, presence: true,
- hook_names: %i[create update], # Deal with old records
- track_if: -> { !importing? }
+ hook_names: %i[create update], # Deal with old records
+ track_if: -> { !importing? }
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }
+ validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }
validate :validate_file_is_image
alias_attribute :title, :filename
@@ -43,7 +47,7 @@ module DesignManagement
# Pre-fetching scope to include the data necessary to construct a
# reference using `to_reference`.
- scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
+ scope :for_reference, -> { includes(issue: [{ namespace: :project }, { project: [:route, :namespace] }]) }
# A design can be uniquely identified by issue_id and filename
# Takes one or more sets of composite IDs of the form:
@@ -174,7 +178,7 @@ module DesignManagement
(?<url_filename> #{valid_char}+ \. #{ext})
}x
- super(path_segment, filename_pattern)
+ compose_link_reference_pattern(path_segment, filename_pattern)
end
end
@@ -182,10 +186,6 @@ module DesignManagement
File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename)
end
- def description
- ''
- end
-
def new_design?
strong_memoize(:new_design) { actions.none? }
end
diff --git a/app/models/design_management/git_repository.rb b/app/models/design_management/git_repository.rb
new file mode 100644
index 00000000000..38c457c7991
--- /dev/null
+++ b/app/models/design_management/git_repository.rb
@@ -0,0 +1,44 @@
+# 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
+
+ # 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..33c5dc15fa4 100644
--- a/app/models/design_management/repository.rb
+++ b/app/models/design_management/repository.rb
@@ -1,51 +1,36 @@
# frozen_string_literal: true
module DesignManagement
- class Repository < ::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
- /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
- GA
-
- def initialize(project)
- full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
- disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
-
- 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)
+ class Repository < ApplicationRecord
+ include ::Gitlab::Utils::StrongMemoize
+ include HasRepository
+
+ belongs_to :project, inverse_of: :design_management_repository
+ validates :project, presence: true, uniqueness: true
+
+ delegate :lfs_enabled?, :storage, :repository_storage, to: :project
+
+ def repository
+ ::DesignManagement::GitRepository.new(
+ full_path,
+ self,
+ shard: repository_storage,
+ disk_path: disk_path,
+ repo_type: repo_type
+ )
end
+ strong_memoize_attr :repository
- # 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]
+ def full_path
+ project.full_path + repo_type.path_suffix
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
+ def disk_path
+ project.disk_path + repo_type.path_suffix
end
- override :copy_gitattributes
- def copy_gitattributes(_ref = nil)
- true
+ def repo_type
+ Gitlab::GlRepository::DESIGN
end
end
end
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
index 5819404efb9..dd6812f0eac 100644
--- a/app/models/design_management/version.rb
+++ b/app/models/design_management/version.rb
@@ -36,10 +36,10 @@ module DesignManagement
belongs_to :author, class_name: 'User'
has_many :actions
has_many :designs,
- through: :actions,
- class_name: "DesignManagement::Design",
- source: :design,
- inverse_of: :versions
+ through: :actions,
+ class_name: "DesignManagement::Design",
+ source: :design,
+ inverse_of: :versions
validates :designs, presence: true, unless: :importing?
validates :sha, presence: true
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 041ec98ffc9..e2ee951522d 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -10,13 +10,13 @@ class DiffDiscussion < Discussion
DiffNote
end
- delegate :position,
- :original_position,
- :change_position,
- :diff_note_positions,
- :on_text?,
- :on_image?,
- to: :first_note
+ delegate :position,
+ :original_position,
+ :change_position,
+ :diff_note_positions,
+ :on_text?,
+ :on_image?,
+ to: :first_note
def legacy_diff_discussion?
false
diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb
index 75aa51348c8..05552e83700 100644
--- a/app/models/diff_viewer/base.rb
+++ b/app/models/diff_viewer/base.rb
@@ -101,8 +101,9 @@ module DiffViewer
def render_error_options
options = []
- blob_url = Gitlab::Routing.url_helpers.project_blob_path(diff_file.repository.project,
- File.join(diff_file.content_sha, diff_file.file_path))
+ blob_url = Gitlab::Routing.url_helpers.project_blob_path(
+ diff_file.repository.project, File.join(diff_file.content_sha, diff_file.file_path)
+ )
options << ActionController::Base.helpers.link_to(_('view the blob'), blob_url)
options
diff --git a/app/models/draft_note.rb b/app/models/draft_note.rb
index 9f7977fce68..ffc04f9bf90 100644
--- a/app/models/draft_note.rb
+++ b/app/models/draft_note.rb
@@ -108,7 +108,7 @@ class DraftNote < ApplicationRecord
end
def self.preload_author(draft_notes)
- ActiveRecord::Associations::Preloader.new.preload(draft_notes, { author: :status })
+ ActiveRecord::Associations::Preloader.new(records: draft_notes, associations: { author: :status }).call
end
def diff_file
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index d06d0a99948..7687bc2be60 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -9,6 +9,7 @@ class EnvironmentStatus
delegate :name, to: :environment
delegate :status, to: :deployment, allow_nil: true
delegate :deployed_at, to: :deployment, allow_nil: true
+ delegate :deployable, to: :deployment, allow_nil: true
def self.for_merge_request(mr, user)
build_environments_status(mr, user, mr.actual_head_pipeline)
@@ -100,11 +101,14 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline
- pipeline.environments_in_self_and_project_descendants.includes(:project).available.map do |environment|
+ environments = pipeline.environments_in_self_and_project_descendants.includes(:project)
+ environments = environments.available if Feature.disabled?(:review_apps_redeploy_mr_widget, mr.project)
+ environments.map do |environment|
next unless Ability.allowed?(user, :read_environment, environment)
EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
end.compact
end
+
private_class_method :build_environments_status
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 1c7a8d93e6e..c52f8a58c00 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -145,7 +145,7 @@ module ErrorTracking
ensure_issue_belongs_to_project!(issue_to_be_updated.project_id)
handle_exceptions do
- { updated: sentry_client.update_issue(opts) }
+ { updated: sentry_client.update_issue(**opts) }
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 333841b1f90..9345776c32b 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,
@@ -66,7 +69,7 @@ class Event < ApplicationRecord
# If the association for "target" defines an "author" association we want to
# eager-load this so Banzai & friends don't end up performing N+1 queries to
# get the authors of notes, issues, etc. (likewise for "noteable").
- incs = %i(author noteable).select do |a|
+ incs = %i(author noteable work_item_type).select do |a|
reflections['events'].active_record.reflect_on_association(a)
end
diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb
index 4654f7e2341..94c242782c1 100644
--- a/app/models/external_pull_request.rb
+++ b/app/models/external_pull_request.rb
@@ -14,6 +14,7 @@
class ExternalPullRequest < Ci::ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ShaAttribute
+ include EachBatch
belongs_to :project
diff --git a/app/models/group.rb b/app/models/group.rb
index 7e09280dfff..ab8e0101684 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -7,7 +7,6 @@ class Group < Namespace
include AfterCommitQueue
include AccessRequestable
include Avatarable
- include Referable
include SelectForProjectAuthorization
include LoadedInGroupList
include GroupDescendant
@@ -21,7 +20,6 @@ class Group < Namespace
include ChronicDurationAttribute
include RunnerTokenExpirationInterval
include Todoable
- include IssueParent
extend ::Gitlab::Utils::Override
@@ -111,6 +109,7 @@ class Group < Namespace
has_one :import_state, class_name: 'GroupImportState', inverse_of: :group
has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :group
+ has_many :bulk_import_entities, class_name: 'BulkImports::Entity', foreign_key: :namespace_id, inverse_of: :group
has_many :group_deploy_keys_groups, inverse_of: :group
has_many :group_deploy_keys, through: :group_deploy_keys_groups
@@ -162,7 +161,8 @@ class Group < Namespace
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required },
- prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ format_with_prefix: :runners_token_prefix,
+ require_prefix_for_validation: true
after_create :post_create_hook
after_create -> { create_or_load_association(:group_feature) }
@@ -198,14 +198,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
@@ -240,14 +253,6 @@ class Group < Namespace
end
end
- def reference_prefix
- User.reference_prefix
- end
-
- def reference_pattern
- User.reference_pattern
- end
-
# WARNING: This method should never be used on its own
# please do make sure the number of rows you are filtering is small
# enough for this query
@@ -364,10 +369,6 @@ class Group < Namespace
notification_settings.find { |n| n.notification_email.present? }&.notification_email
end
- def to_reference(_from = nil, target_project: nil, full: nil)
- "#{self.class.reference_prefix}#{full_path}"
- end
-
def web_url(only_path: nil)
Gitlab::UrlBuilder.build(self, only_path: only_path)
end
@@ -561,7 +562,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
@@ -762,11 +763,6 @@ class Group < Namespace
ensure_runners_token!
end
- override :format_runners_token
- def format_runners_token(token)
- "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
- end
-
def project_creation_level
super || ::Gitlab::CurrentSettings.default_project_creation
end
@@ -814,8 +810,10 @@ class Group < Namespace
end
def preload_shared_group_links
- preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(self, shared_with_group_links: [shared_with_group: :route])
+ ActiveRecord::Associations::Preloader.new(
+ records: [self],
+ associations: { shared_with_group_links: [shared_with_group: :route] }
+ ).call
end
def update_shared_runners_setting!(state)
@@ -907,6 +905,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
@@ -1095,6 +1097,10 @@ class Group < Namespace
def enable_shared_runners!
update!(shared_runners_enabled: true)
end
+
+ def runners_token_prefix
+ RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ end
end
Group.prepend_mod_with('Group')
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/project_hook.rb b/app/models/hooks/project_hook.rb
index 8e9a74a68d0..695041f0247 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -2,7 +2,6 @@
class ProjectHook < WebHook
include TriggerableHooks
- include WebHooks::AutoDisabling
include Presentable
include Limitable
extend ::Gitlab::Utils::Override
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index 6af70c249a0..453b986ca4d 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ServiceHook < WebHook
- include WebHooks::Unstoppable
include Presentable
extend ::Gitlab::Utils::Override
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index eaffe83cab3..3c7f0ef9ffc 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -2,7 +2,6 @@
class SystemHook < WebHook
include TriggerableHooks
- include WebHooks::Unstoppable
triggerable_hooks [
:repository_update_hooks,
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 7202a530feb..5ccbc926a71 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -2,15 +2,10 @@
class WebHook < ApplicationRecord
include Sortable
+ include WebHooks::AutoDisabling
InterpolationError = Class.new(StandardError)
- MAX_FAILURES = 100
- FAILURE_THRESHOLD = 3 # three strikes
- EXCEEDED_FAILURE_THRESHOLD = FAILURE_THRESHOLD + 1
- INITIAL_BACKOFF = 1.minute
- MAX_BACKOFF = 1.day
- BACKOFF_GROWTH_FACTOR = 2.0
SECRET_MASK = '************'
attr_encrypted :token,
@@ -78,46 +73,6 @@ class WebHook < ApplicationRecord
'user/project/integrations/webhooks'
end
- def next_backoff
- return MAX_BACKOFF if backoff_count >= 8 # optimization to prevent expensive exponentiation and possible overflows
-
- (INITIAL_BACKOFF * (BACKOFF_GROWTH_FACTOR**backoff_count))
- .clamp(INITIAL_BACKOFF, MAX_BACKOFF)
- .seconds
- end
-
- def disable!
- update_attribute(:recent_failures, EXCEEDED_FAILURE_THRESHOLD)
- end
-
- def enable!
- return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0
-
- assign_attributes(recent_failures: 0, disabled_until: nil, backoff_count: 0)
- save(validate: false)
- end
-
- # Don't actually back-off until FAILURE_THRESHOLD failures have been seen
- # we mark the grace-period using the recent_failures counter
- def backoff!
- attrs = { recent_failures: next_failure_count }
-
- if recent_failures >= FAILURE_THRESHOLD
- attrs[:backoff_count] = next_backoff_count
- attrs[:disabled_until] = next_backoff.from_now
- end
-
- assign_attributes(attrs)
- save(validate: false) if changed?
- end
-
- def failed!
- return unless recent_failures < MAX_FAILURES
-
- assign_attributes(disabled_until: nil, backoff_count: 0, recent_failures: next_failure_count)
- save(validate: false)
- end
-
# @return [Boolean] Whether or not the WebHook is currently throttled.
def rate_limited?
rate_limiter.rate_limited?
@@ -178,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
@@ -191,14 +146,6 @@ class WebHook < ApplicationRecord
self.class.decrypt_url_variables(encrypted_url_variables_was, iv: encrypted_url_variables_iv_was)
end
- def next_failure_count
- recent_failures.succ.clamp(1, MAX_FAILURES)
- end
-
- def next_backoff_count
- backoff_count.succ.clamp(1, MAX_FAILURES)
- end
-
def initialize_url_variables
self.url_variables = {} if encrypted_url_variables.nil?
end
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
index 109c0c82487..e5b27009115 100644
--- a/app/models/import_failure.rb
+++ b/app/models/import_failure.rb
@@ -6,6 +6,9 @@ class ImportFailure < ApplicationRecord
validates :project, presence: true, unless: :group
validates :group, presence: true, unless: :project
+ validates :external_identifiers, json_schema: { filename: "import_failure_external_identifiers" }
+
+ scope :with_external_identifiers, -> { where.not(external_identifiers: {}) }
# Returns any `import_failures` for relations that were unrecoverable errors or failed after
# several retries. An import can be successful even if some relations failed to import correctly.
@@ -13,4 +16,8 @@ class ImportFailure < ApplicationRecord
scope :hard_failures_by_correlation_id, ->(correlation_id) {
where(correlation_id_value: correlation_id, retry_count: 0).order(created_at: :desc)
}
+
+ scope :failures_by_correlation_id, ->(correlation_id) {
+ where(correlation_id_value: correlation_id).order(created_at: :desc)
+ }
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 8a8c1a29375..64c9680ce90 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -138,7 +138,6 @@ class InstanceConfiguration
plan.actual_limits.slice(
:ci_pipeline_size,
:ci_active_jobs,
- :ci_active_pipelines,
:ci_project_subscriptions,
:ci_pipeline_schedules,
:ci_needs_size_limit,
diff --git a/app/models/integration.rb b/app/models/integration.rb
index d3006f00ba1..860739fe5aa 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -21,13 +21,14 @@ class Integration < ApplicationRecord
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao
+ pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity
+ unify_circuit webex_teams youtrack zentao
].freeze
# TODO Shimo is temporary disabled on group and instance-levels.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
- apple_app_store jenkins shimo
+ apple_app_store google_play jenkins shimo
].freeze
# Fake integrations to help with local development.
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index 84185542939..5e502cce927 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -6,11 +6,15 @@ module Integrations
class AppleAppStore < Integration
ISSUER_ID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/.freeze
KEY_ID_REGEX = /\A(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]+\z/.freeze
+ IS_KEY_CONTENT_BASE64 = "true"
+
+ SECTION_TYPE_APPLE_APP_STORE = 'apple_app_store'
with_options if: :activated? do
validates :app_store_issuer_id, presence: true, format: { with: ISSUER_ID_REGEX }
validates :app_store_key_id, presence: true, format: { with: KEY_ID_REGEX }
validates :app_store_private_key, presence: true, certificate_key: true
+ validates :app_store_private_key_file_name, presence: true
end
field :app_store_issuer_id,
@@ -21,15 +25,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,
- section: SECTION_TYPE_CONNECTION,
- required: true,
- type: 'textarea',
- title: -> { s_('AppleAppStore|The Apple App Store Connect Private Key.') },
- is_secret: false
+ field :app_store_private_key_file_name,
+ section: SECTION_TYPE_CONNECTION
+
+ field :app_store_private_key, api_only: true
def title
'Apple App Store Connect'
@@ -43,7 +44,8 @@ module Integrations
variable_list = [
'<code>APP_STORE_CONNECT_API_KEY_ISSUER_ID</code>',
'<code>APP_STORE_CONNECT_API_KEY_KEY_ID</code>',
- '<code>APP_STORE_CONNECT_API_KEY_KEY</code>'
+ '<code>APP_STORE_CONNECT_API_KEY_KEY</code>',
+ '<code>APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64</code>'
]
# rubocop:disable Layout/LineLength
@@ -51,7 +53,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
@@ -69,7 +71,7 @@ module Integrations
def sections
[
{
- type: SECTION_TYPE_CONNECTION,
+ type: SECTION_TYPE_APPLE_APP_STORE,
title: s_('Integrations|Integration details'),
description: help
}
@@ -92,20 +94,20 @@ module Integrations
{ key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: app_store_issuer_id, masked: true, public: false },
{ key: 'APP_STORE_CONNECT_API_KEY_KEY', value: Base64.encode64(app_store_private_key), masked: true,
public: false },
- { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: app_store_key_id, masked: true, public: false }
+ { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: app_store_key_id, masked: true, public: false },
+ { key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', value: IS_KEY_CONTENT_BASE64, masked: false,
+ public: false }
]
end
private
def client
- config = {
+ AppStoreConnect::Client.new(
issuer_id: app_store_issuer_id,
key_id: app_store_key_id,
private_key: app_store_private_key
- }
-
- AppStoreConnect::Client.new(config)
+ )
end
end
end
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/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb
index 7a2a91aa0d2..c83a559e0da 100644
--- a/app/models/integrations/base_slack_notification.rb
+++ b/app/models/integrations/base_slack_notification.rb
@@ -44,8 +44,6 @@ module Integrations
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
-
optional_arguments = {
project: project,
namespace: group || project&.namespace
diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb
index 619579a543a..7662da933ba 100644
--- a/app/models/integrations/base_slash_commands.rb
+++ b/app/models/integrations/base_slash_commands.rb
@@ -6,10 +6,6 @@ module Integrations
class BaseSlashCommands < Integration
attribute :category, default: 'chat'
- prop_accessor :token
-
- has_many :chat_names, foreign_key: :integration_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
-
def valid_token?(token)
self.respond_to?(:token) &&
self.token.present? &&
@@ -24,18 +20,6 @@ module Integrations
false
end
- def fields
- [
- {
- type: 'password',
- name: 'token',
- non_empty_password_title: s_('ProjectService|Enter new token'),
- non_empty_password_help: s_('ProjectService|Leave blank to use your current token.'),
- placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx'
- }
- ]
- end
-
def trigger(params)
return unless valid_token?(params[:token])
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index 3f7fa1c51b2..9b837faf79b 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -68,7 +68,7 @@ module Integrations
def execute(data)
return unless supported_events.include?(data[:object_kind])
- message = build_message(data)
+ message = create_message(data)
speak(self.room, message, auth)
end
@@ -116,7 +116,7 @@ module Integrations
res.code == 200 ? res["rooms"] : []
end
- def build_message(push)
+ def create_message(push)
ref = Gitlab::Git.ref_name(push[:ref])
before = push[:before]
after = push[:after]
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/gitlab_slack_application.rb b/app/models/integrations/gitlab_slack_application.rb
new file mode 100644
index 00000000000..b0f54f39e8c
--- /dev/null
+++ b/app/models/integrations/gitlab_slack_application.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+module Integrations
+ class GitlabSlackApplication < BaseSlackNotification
+ attribute :alert_events, default: false
+ attribute :commit_events, default: false
+ attribute :confidential_issues_events, default: false
+ attribute :confidential_note_events, default: false
+ attribute :deployment_events, default: false
+ attribute :issues_events, default: false
+ attribute :job_events, default: false
+ attribute :merge_requests_events, default: false
+ attribute :note_events, default: false
+ attribute :pipeline_events, default: false
+ attribute :push_events, default: false
+ attribute :tag_push_events, default: false
+ attribute :vulnerability_events, default: false
+ attribute :wiki_page_events, default: false
+
+ has_one :slack_integration, foreign_key: :integration_id, inverse_of: :integration
+ delegate :bot_access_token, :bot_user_id, to: :slack_integration, allow_nil: true
+
+ def update_active_status
+ update(active: !!slack_integration)
+ end
+
+ def title
+ s_('Integrations|GitLab for Slack app')
+ end
+
+ def description
+ s_('Integrations|Enable slash commands and notifications for a Slack workspace.')
+ end
+
+ def self.to_param
+ 'gitlab_slack_application'
+ end
+
+ override :show_active_box?
+ def show_active_box?
+ false
+ end
+
+ override :test
+ def test(_data)
+ failures = test_notification_channels
+
+ { success: failures.blank?, result: failures }
+ end
+
+ # The form fields of this integration are editable only after the Slack App installation
+ # flow has been completed, which causes the integration to become activated/enabled.
+ override :editable?
+ def editable?
+ activated?
+ end
+
+ override :fields
+ def fields
+ return [] unless editable?
+
+ super
+ end
+
+ override :sections
+ def sections
+ return [] unless editable?
+
+ [
+ {
+ type: SECTION_TYPE_TRIGGER,
+ title: s_('Integrations|Trigger'),
+ description: s_('Integrations|An event will be triggered when one of the following items happen.')
+ },
+ {
+ type: SECTION_TYPE_CONFIGURATION,
+ title: s_('Integrations|Notification settings'),
+ description: s_('Integrations|Configure the scope of notifications.')
+ }
+ ]
+ end
+
+ override :configurable_events
+ def configurable_events
+ return [] unless editable?
+
+ super
+ end
+
+ override :requires_webhook?
+ def requires_webhook?
+ false
+ end
+
+ def upgrade_needed?
+ slack_integration.present? && slack_integration.upgrade_needed?
+ end
+
+ private
+
+ override :notify
+ def notify(message, opts)
+ channels = Array(opts[:channel])
+ return false if channels.empty?
+
+ payload = {
+ attachments: message.attachments,
+ text: message.pretext,
+ unfurl_links: false,
+ unfurl_media: false
+ }
+
+ successes = channels.map do |channel|
+ notify_slack_channel!(channel, payload)
+ end
+
+ successes.any?
+ end
+
+ def notify_slack_channel!(channel, payload)
+ response = api_client.post(
+ 'chat.postMessage',
+ payload.merge(channel: channel)
+ )
+
+ log_error('Slack API error when notifying', api_response: response.parsed_response) unless response['ok']
+
+ response['ok']
+ rescue *Gitlab::HTTP::HTTP_ERRORS => e
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e,
+ {
+ integration_id: id,
+ slack_integration_id: slack_integration.id
+ }
+ )
+
+ false
+ end
+
+ def api_client
+ @slack_api ||= ::Slack::API.new(slack_integration)
+ end
+
+ def test_notification_channels
+ return if unique_channels.empty?
+ return s_('Integrations|GitLab for Slack app must be reinstalled to enable notifications') unless bot_access_token
+
+ test_payload = {
+ text: 'Test',
+ user: bot_user_id
+ }
+
+ not_found_channels = unique_channels.first(10).select do |channel|
+ test_payload[:channel] = channel
+
+ response = ::Slack::API.new(slack_integration).post('chat.postEphemeral', test_payload)
+ response['error'] == 'channel_not_found'
+ end
+
+ return if not_found_channels.empty?
+
+ format(
+ s_(
+ 'Integrations|Unable to post to %{channel_list}, ' \
+ 'please add the GitLab Slack app to any private Slack channels'
+ ),
+ channel_list: not_found_channels.to_sentence
+ )
+ end
+
+ override :metrics_key_prefix
+ def metrics_key_prefix
+ 'i_integrations_gitlab_for_slack_app'
+ end
+ end
+end
diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb
new file mode 100644
index 00000000000..9fa6dc19f11
--- /dev/null
+++ b/app/models/integrations/google_play.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+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
+ validates :service_account_key, presence: true, json_schema: {
+ 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
+
+ field :service_account_key, api_only: true
+
+ def title
+ s_('GooglePlay|Google Play')
+ end
+
+ def description
+ s_('GooglePlay|Use GitLab to build and release an app in Google Play.')
+ end
+
+ def help
+ variable_list = [
+ '<code>SUPPLY_PACKAGE_NAME</code>',
+ '<code>SUPPLY_JSON_KEY_DATA</code>'
+ ]
+
+ # rubocop:disable Layout/LineLength
+ texts = [
+ 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: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe
+ ]
+ # rubocop:enable Layout/LineLength
+
+ texts.join('<br><br>'.html_safe)
+ end
+
+ def self.to_param
+ 'google_play'
+ end
+
+ def self.supported_events
+ []
+ end
+
+ def sections
+ [
+ {
+ type: SECTION_TYPE_GOOGLE_PLAY,
+ title: s_('Integrations|Integration details'),
+ description: help
+ }
+ ]
+ end
+
+ def test(*_args)
+ client.list_reviews(package_name)
+ { success: true }
+ rescue Google::Apis::ClientError => error
+ { success: false, message: error }
+ end
+
+ def ci_variables
+ return [] unless activated?
+
+ [
+ { 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
+ 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: [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 d96a848c72e..2520d3bfc9c 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -17,12 +17,19 @@ module Integrations
SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
+ AUTH_TYPE_BASIC = 0
+ AUTH_TYPE_PAT = 1
+
SNOWPLOW_EVENT_CATEGORY = self.name
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
- validates :username, presence: true, if: :activated?
+ validates :username, presence: true, if: ->(object) { object.activated? && !object.personal_access_token_authorization? }
validates :password, presence: true, if: :activated?
+ validates :jira_auth_type, presence: true, inclusion: { in: [AUTH_TYPE_BASIC, AUTH_TYPE_PAT] }, 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?
+ validate :validate_jira_cloud_auth_type_is_basic, if: :activated?
validates :jira_issue_transition_id,
format: {
@@ -58,19 +65,44 @@ module Integrations
help: -> { s_('JiraService|If different from the Web URL') },
exposes_secrets: true
+ field :jira_auth_type,
+ type: 'select',
+ required: true,
+ section: SECTION_TYPE_CONNECTION,
+ title: -> { s_('JiraService|Authentication type') },
+ choices: -> {
+ [
+ [s_('JiraService|Basic'), AUTH_TYPE_BASIC],
+ [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT]
+ ]
+ }
+
field :username,
section: SECTION_TYPE_CONNECTION,
- required: true,
- title: -> { s_('JiraService|Username or email') },
- help: -> { s_('JiraService|Username for the server version or an email for the cloud version') }
+ required: false,
+ title: -> { s_('JiraService|Email or username') },
+ help: -> { s_('JiraService|Only required for Basic authentication. Email for Jira Cloud or username for Jira Data Center and Jira Server') }
field :password,
section: SECTION_TYPE_CONNECTION,
required: true,
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') }
+ non_empty_password_title: -> { s_('JiraService|New API token, password, or Jira personal access token') },
+ non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') },
+ help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') },
+ 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 +122,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)
@@ -119,16 +151,23 @@ module Integrations
def options
url = URI.parse(client_url)
- {
- username: username&.strip,
- password: password,
- site: URI.join(url, '/').to_s.delete_suffix('/'), # Intended to find the root
+ options = {
+ site: URI.join(url, '/').to_s.chomp('/'), # Find the root URL
context_path: (url.path.presence || '/').delete_suffix('/'),
auth_type: :basic,
- use_cookies: true,
- additional_cookies: ['OBBasicAuth=fromDialog'],
use_ssl: url.scheme == 'https'
}
+
+ if personal_access_token_authorization?
+ options[:default_headers] = { 'Authorization' => "Bearer #{password}" }
+ else
+ options[:username] = username&.strip
+ options[:password] = password
+ options[:use_cookies] = true
+ options[:additional_cookies] = ['OBBasicAuth=fromDialog']
+ end
+
+ options
end
def client
@@ -166,6 +205,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')
}
]
@@ -323,8 +367,18 @@ module Integrations
jira_issue_transition_automatic || jira_issue_transition_id.present?
end
+ def personal_access_token_authorization?
+ jira_auth_type == AUTH_TYPE_PAT
+ end
+
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
@@ -391,8 +445,6 @@ module Integrations
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
-
optional_arguments = {
project: project,
namespace: group || project&.namespace
@@ -606,7 +658,6 @@ module Integrations
# If API-based detection methods fail here then
# we can only assume it's either Cloud or Server
# based on the URL being *.atlassian.net
-
if self.class.valid_jira_cloud_url?(client_url)
data_fields.deployment_cloud!
else
@@ -626,6 +677,17 @@ module Integrations
description
end
+
+ def validate_jira_cloud_auth_type_is_basic
+ return unless self.class.valid_jira_cloud_url?(client_url) && jira_auth_type != AUTH_TYPE_BASIC
+
+ errors.add(:base,
+ format(
+ s_('JiraService|For Jira Cloud, the authentication type must be %{basic}'),
+ basic: s_('JiraService|Basic')
+ )
+ )
+ end
end
end
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index 30a8ba973c1..e075400d9b5 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -4,18 +4,22 @@ module Integrations
class MattermostSlashCommands < BaseSlashCommands
include Ci::TriggersHelper
- prop_accessor :token
+ field :token,
+ type: 'password',
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: ''
def testable?
false
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
@@ -37,10 +41,6 @@ module Integrations
[[], e.message]
end
- def chat_responder
- ::Gitlab::Chat::Responder::Mattermost
- end
-
private
def command(params)
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 2f0995e9ab0..2dc0fd7d011 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -30,12 +30,9 @@ module Integrations
help: -> { s_('PrometheusService|The contents of the credentials.json file of your service account.') },
required: false
- # We need to allow the self-monitoring project to connect to the internal
- # Prometheus instance.
# Since the internal Prometheus instance is usually a localhost URL, we need
# to allow localhost URLs when the following conditions are true:
- # 1. project is the self-monitoring project.
- # 2. api_url is the internal Prometheus URL.
+ # 1. api_url is the internal Prometheus URL.
with_options presence: true do
validates :api_url, public_url: true, if: ->(object) { object.manual_configuration? && !object.allow_local_api_url? }
validates :api_url, url: true, if: ->(object) { object.manual_configuration? && object.allow_local_api_url? }
@@ -99,8 +96,7 @@ module Integrations
end
def allow_local_api_url?
- allow_local_requests_from_web_hooks_and_services? ||
- (self_monitoring_project? && internal_prometheus_url?)
+ allow_local_requests_from_web_hooks_and_services? || internal_prometheus_url?
end
def configured?
@@ -127,10 +123,6 @@ module Integrations
delegate :allow_local_requests_from_web_hooks_and_services?, to: :current_settings, private: true
- def self_monitoring_project?
- project && project.id == current_settings.self_monitoring_project_id
- end
-
def internal_prometheus_url?
api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri
end
diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb
index 72e3c4a8cbc..343c8d68166 100644
--- a/app/models/integrations/slack_slash_commands.rb
+++ b/app/models/integrations/slack_slash_commands.rb
@@ -4,6 +4,12 @@ module Integrations
class SlackSlashCommands < BaseSlashCommands
include Ci::TriggersHelper
+ field :token,
+ type: 'password',
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ placeholder: ''
+
def title
'Slack slash commands'
end
@@ -23,10 +29,6 @@ module Integrations
end
end
- def chat_responder
- ::Gitlab::Chat::Responder::Slack
- end
-
private
def format(text)
diff --git a/app/models/integrations/slack_workspace/api_scope.rb b/app/models/integrations/slack_workspace/api_scope.rb
new file mode 100644
index 00000000000..3c4d25bff10
--- /dev/null
+++ b/app/models/integrations/slack_workspace/api_scope.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackWorkspace
+ class ApiScope < ApplicationRecord
+ self.table_name = 'slack_api_scopes'
+
+ def self.find_or_initialize_by_names(names)
+ found = where(name: names).to_a
+ missing_names = names - found.pluck(:name)
+
+ if missing_names.any?
+ insert_all(missing_names.map { |name| { name: name } })
+ missing = where(name: missing_names)
+ found += missing
+ end
+
+ found
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/slack_workspace/integration_api_scope.rb b/app/models/integrations/slack_workspace/integration_api_scope.rb
new file mode 100644
index 00000000000..d33c8e0d816
--- /dev/null
+++ b/app/models/integrations/slack_workspace/integration_api_scope.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackWorkspace
+ class IntegrationApiScope < ApplicationRecord
+ self.table_name = 'slack_integrations_scopes'
+
+ belongs_to :slack_api_scope, class_name: 'Integrations::SlackWorkspace::ApiScope'
+ belongs_to :slack_integration
+
+ # Efficient scope propagation
+ def self.update_scopes(integration_ids, scopes)
+ return if integration_ids.empty?
+
+ scope_ids = scopes.pluck(:id)
+
+ attrs = scope_ids.flat_map do |scope_id|
+ integration_ids.map { |si_id| { slack_integration_id: si_id, slack_api_scope_id: scope_id } }
+ end
+
+ # We don't know which ones to preserve - so just delete them all in a single query
+ transaction do
+ where(slack_integration_id: integration_ids).delete_all
+ insert_all(attrs) unless attrs.empty?
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb
new file mode 100644
index 00000000000..e0a63b5ae6a
--- /dev/null
+++ b/app/models/integrations/squash_tm.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Integrations
+ class SquashTm < Integration
+ include HasWebHook
+
+ field :url,
+ placeholder: 'https://your-instance.squashcloud.io/squash/plugin/xsquash4gitlab/webhook/issue',
+ title: -> { s_('SquashTmIntegration|Squash TM webhook URL') },
+ exposes_secrets: true,
+ required: true
+
+ field :token,
+ type: 'password',
+ title: -> { s_('SquashTmIntegration|Secret token (optional)') },
+ non_empty_password_title: -> { s_('ProjectService|Enter new token') },
+ non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') },
+ required: false
+
+ with_options if: :activated? do
+ validates :url, presence: true, public_url: true
+ validates :token, length: { maximum: 255 }, allow_blank: true
+ end
+
+ def title
+ 'Squash TM'
+ end
+
+ def description
+ s_("SquashTmIntegration|Update Squash TM requirements when GitLab issues are modified.")
+ end
+
+ def help
+ docs_link = ActionController::Base.helpers.link_to(
+ _('Learn more.'),
+ Rails.application.routes.url_helpers.help_page_url('user/project/integrations/squash_tm'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+
+ Kernel.format(
+ s_('SquashTmIntegration|Update Squash TM requirements when GitLab issues are modified. %{docs_link}'),
+ { docs_link: docs_link.html_safe }
+ ).html_safe
+ end
+
+ def self.supported_events
+ %w[issue confidential_issue]
+ end
+
+ def self.to_param
+ 'squash_tm'
+ end
+
+ def self.default_test_event
+ 'issue'
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ execute_web_hook!(data, "#{data[:object_kind]} Hook")
+ end
+
+ def test(data)
+ result = execute_web_hook!(data, "Test Configuration Hook")
+
+ { success: result.payload[:http_status] == 200, result: result.message }
+ rescue StandardError => error
+ { success: false, result: error.message }
+ end
+
+ override :hook_url
+ def hook_url
+ format("#{url}%s", ('?token={token}' unless token.blank?))
+ end
+
+ def url_variables
+ { 'token' => token }.compact
+ end
+ end
+end
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 bea86168c8d..b7125617034 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -39,6 +39,9 @@ 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)
+ ForbiddenColumnUsed = Class.new(StandardError)
+
SORTING_PREFERENCE_FIELD = :issues_sort
MAX_BRANCH_TEMPLATE = 255
@@ -52,18 +55,37 @@ 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
-
- has_internal_id :iid, scope: :project, track_if: -> { !importing? }
+ 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
+ # is higher than the actual issues.max(iid) value for a given project. For instance
+ # in case of an import where a batch of IIDs may be prealocated
+ #
+ # TODO: remove this once the UpdateIssuesInternalIdScope migration completes
+ if issue
+ [
+ InternalId.where(project: issue.project, usage: :issues).pick(:last_value).to_i,
+ issue.namespace&.issues&.maximum(:iid).to_i
+ ].max
+ else
+ [
+ InternalId.where(**scope, usage: :issues).pick(:last_value).to_i,
+ where(**scope).maximum(:iid).to_i
+ ].max
+ end
+ end
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -97,6 +119,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
@@ -104,17 +127,41 @@ class Issue < ApplicationRecord
accepts_nested_attributes_for :sentry_issue
accepts_nested_attributes_for :incident_management_issuable_escalation_status, update_only: true
- validates :project, presence: true
- validates :issue_type, presence: true
+ validates :project, presence: true, if: -> { !namespace || namespace.is_a?(Namespaces::ProjectNamespace) }
validates :namespace, presence: true
validates :work_item_type, presence: true
+ validates :confidential, inclusion: { in: [true, false], message: 'must be a boolean' }
validate :allowed_work_item_type_change, on: :update, if: :work_item_type_id_changed?
validate :due_date_after_start_date
validate :parent_link_confidentiality
+ # using a custom validation since we are overwriting the `issue_type` method to use the work_item_types table
+ validate :issue_type_attribute_present
enum issue_type: WorkItems::Type.base_types
+ # TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699
+ WorkItems::Type.base_types.each do |base_type, _value|
+ define_method "#{base_type}?".to_sym do
+ error_message = <<~ERROR
+ `#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column,
+ its usage is forbidden. You should use the `work_item_types` table instead.
+
+ # Before
+
+ issue.requirement? => true
+
+ # After
+
+ issue.work_item_type.requirement? => true
+
+ More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
+ ERROR
+
+ raise ForbiddenColumnUsed, error_message
+ end
+ end
+
alias_method :issuing_parent, :project
alias_attribute :issuing_parent_id, :project_id
@@ -136,7 +183,7 @@ class Issue < ApplicationRecord
scope :order_due_date_asc, -> { reorder(arel_table[:due_date].asc.nulls_last) }
scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) }
- scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
+ scope :order_closest_future_date, -> { reorder(Arel.sql("CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC")) }
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
scope :order_severity_asc, -> do
build_keyset_order_on_joined_column(
@@ -162,15 +209,15 @@ class Issue < ApplicationRecord
scope :order_closed_at_desc, -> { reorder(arel_table[:closed_at].desc.nulls_last) }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
- scope :with_web_entity_associations, -> { preload(:author, project: [:project_feature, :route, namespace: :route]) }
+ scope :with_web_entity_associations, -> { preload(:author, :namespace, project: [:project_feature, :route, namespace: :route]) }
scope :preload_awardable, -> { preload(:award_emoji) }
scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) }
scope :with_api_entity_associations, -> {
- preload(:timelogs, :closed_by, :assignees, :author, :labels, :issuable_severity,
- milestone: { project: [:route, { namespace: :route }] },
- project: [:project_feature, :route, { namespace: :route }],
+ preload(:work_item_type, :timelogs, :closed_by, :assignees, :author, :labels, :issuable_severity,
+ namespace: [{ parent: :route }, :route], milestone: { project: [:route, { namespace: :route }] },
+ project: [:project_namespace, :project_feature, :route, { group: :route }, { namespace: :route }],
duplicated_to: { project: [:project_feature] })
}
scope :with_issue_type, ->(types) { where(issue_type: types) }
@@ -213,8 +260,9 @@ 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_save :ensure_metrics!, unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
after_create_commit :record_create_action, unless: :importing?
@@ -345,7 +393,7 @@ class Issue < ApplicationRecord
end
def self.link_reference_pattern
- @link_reference_pattern ||= super(%r{issues(?:\/incident)?}, Gitlab::Regex.issue)
+ @link_reference_pattern ||= compose_link_reference_pattern(%r{issues(?:\/incident)?}, Gitlab::Regex.issue)
end
def self.reference_valid?(reference)
@@ -450,7 +498,7 @@ class Issue < ApplicationRecord
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
- "#{project.to_reference_base(from, full: full)}#{reference}"
+ "#{namespace.to_reference_base(from, full: full)}#{reference}"
end
def suggested_branch_name
@@ -463,7 +511,7 @@ class Issue < ApplicationRecord
"#{to_branch_name}-#{suffix}"
end
- Uniquify.new(start_counting_from).string(branch_name_generator) do |suggested_branch_name|
+ Gitlab::Utils::Uniquify.new(start_counting_from).string(branch_name_generator) do |suggested_branch_name|
project.repository.branch_exists?(suggested_branch_name)
end
end
@@ -576,6 +624,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
+ return unless project
+
Projects::OpenIssuesCountService.new(project).refresh_cache
end
# rubocop: enable CodeReuse/ServiceClass
@@ -614,7 +666,7 @@ class Issue < ApplicationRecord
end
def supports_assignee?
- issue_type_supports?(:assignee)
+ work_item_type_with_default.supports_assignee?
end
def supports_time_tracking?
@@ -655,13 +707,13 @@ class Issue < ApplicationRecord
elsif project.personal? && project.team.owner?(user)
true
elsif confidential? && !assignee_or_author?(user)
- project.team.member?(user, Gitlab::Access::REPORTER)
+ project.member?(user, Gitlab::Access::REPORTER)
elsif hidden?
false
elsif project.public? || (project.internal? && !user.external?)
project.feature_available?(:issues, user)
else
- project.team.member?(user)
+ project.member?(user)
end
end
@@ -670,6 +722,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
+ return unless project
+
key = Gitlab::Routing.url_helpers.realtime_changes_project_issue_path(project, self)
Gitlab::EtagCaching::Store.new.touch(key)
end
@@ -684,8 +740,60 @@ 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
+
+ def issue_type
+ if ::Feature.enabled?(:issue_type_uses_work_item_types_table)
+ work_item_type_with_default.base_type
+ else
+ super
+ end
+ 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
+ return unless (changes.keys & %w[issue_type work_item_type_id]).any?
+
+ # Do not replace the use of attributes with `issue_type` here
+ if attributes['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: attributes['issue_type'],
+ work_item_type_id: work_item_type_id
+ )
+ end
+ end
+
+ def issue_type_attribute_present
+ return if attributes['issue_type'].present?
+
+ errors.add(:issue_type, 'Must be present')
+ end
+
def due_date_after_start_date
return unless start_date.present? && due_date.present?
@@ -711,6 +819,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
+ 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
@@ -722,18 +834,19 @@ class Issue < ApplicationRecord
confidential_changed?(from: true, to: false)
end
- override :ensure_metrics
- def ensure_metrics
+ def ensure_metrics!
Issue::Metrics.record!(self)
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
@@ -749,7 +862,9 @@ class Issue < ApplicationRecord
def ensure_work_item_type
return if work_item_type_id.present? || work_item_type_id_change&.last.present?
- self.work_item_type = WorkItems::Type.default_by_type(issue_type)
+ # 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
+ self.work_item_type = WorkItems::Type.default_by_type(attributes['issue_type'])
end
def allowed_work_item_type_change
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/key.rb b/app/models/key.rb
index 596186276bb..2ea71bfcd6d 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -92,7 +92,7 @@ class Key < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def update_last_used_at
- Keys::LastUsedService.new(self).execute
+ Keys::LastUsedService.new(self).execute_async
end
# rubocop: enable CodeReuse/ServiceClass
diff --git a/app/models/label.rb b/app/models/label.rb
index aa53c0e0f3f..32b399ac461 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -9,6 +9,7 @@ class Label < ApplicationRecord
include Sortable
include FromUnion
include Presentable
+ include EachBatch
cache_markdown_field :description, pipeline: :single_line
@@ -66,6 +67,10 @@ class Label < ApplicationRecord
.with_preloaded_container
end
+ def self.pluck_titles
+ pluck(:title)
+ end
+
def self.prioritized(project)
joins(:priorities)
.where(label_priorities: { project_id: project })
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
index f28e8f81b40..7f64606e97b 100644
--- a/app/models/loose_foreign_keys/deleted_record.rb
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -9,23 +9,23 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel
self.ignored_columns = %i[partition]
partitioned_by :partition, strategy: :sliding_list,
- next_partition_if: -> (active_partition) do
- oldest_record_in_partition = LooseForeignKeys::DeletedRecord
- .select(:id, :created_at)
- .for_partition(active_partition.value)
- .order(:id)
- .limit(1)
- .take
-
- oldest_record_in_partition.present? &&
- oldest_record_in_partition.created_at < PARTITION_DURATION.ago
- end,
- detach_partition_if: -> (partition) do
- !LooseForeignKeys::DeletedRecord
- .for_partition(partition.value)
- .status_pending
- .exists?
- end
+ next_partition_if: -> (active_partition) do
+ oldest_record_in_partition = LooseForeignKeys::DeletedRecord
+ .select(:id, :created_at)
+ .for_partition(active_partition.value)
+ .order(:id)
+ .limit(1)
+ .take
+
+ oldest_record_in_partition.present? &&
+ oldest_record_in_partition.created_at < PARTITION_DURATION.ago
+ end,
+ detach_partition_if: -> (partition) do
+ !LooseForeignKeys::DeletedRecord
+ .for_partition(partition.value)
+ .status_pending
+ .exists?
+ end
scope :for_table, -> (table) { where(fully_qualified_table_name: table) }
scope :for_partition, -> (partition) { where(partition: partition) }
diff --git a/app/models/member.rb b/app/models/member.rb
index e97c9e929ac..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
@@ -320,6 +321,12 @@ class Member < ApplicationRecord
end
end
+ def filter_by_user_type(value)
+ return unless ::User.user_types.key?(value)
+
+ left_join_users.merge(::User.where(user_type: value))
+ end
+
def sort_by_attribute(method)
case method.to_s
when 'access_level_asc' then reorder(access_level: :asc)
@@ -353,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
@@ -566,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/member_role.rb b/app/models/members/member_role.rb
deleted file mode 100644
index 42ce228c318..00000000000
--- a/app/models/members/member_role.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
- include IgnorableColumns
- ignore_column :download_code, remove_with: '15.9', remove_after: '2023-01-22'
-
- has_many :members
- belongs_to :namespace
-
- validates :namespace, presence: true
- validates :base_access_level, presence: true
- validate :belongs_to_top_level_namespace
- validate :validate_namespace_locked, on: :update
- validate :attributes_locked_after_member_associated, on: :update
-
- validates_associated :members
-
- before_destroy :prevent_delete_after_member_associated
-
- private
-
- def belongs_to_top_level_namespace
- return if !namespace || namespace.root?
-
- errors.add(:namespace, s_("MemberRole|must be top-level namespace"))
- end
-
- def validate_namespace_locked
- return unless namespace_id_changed?
-
- errors.add(:namespace, s_("MemberRole|can't be changed"))
- end
-
- def attributes_locked_after_member_associated
- return unless members.present?
-
- errors.add(:base, s_("MemberRole|cannot be changed because it is already assigned to a user. "\
- "Please create a new Member Role instead"))
- end
-
- def prevent_delete_after_member_associated
- return unless members.present?
-
- errors.add(:base, s_("MemberRole|cannot be deleted because it is already assigned to a user. "\
- "Please disassociate the member role from all users before deletion."))
-
- throw :abort # rubocop:disable Cop/BanCatchThrow
- end
-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 ba7e4b39989..1fef155e6ea 100644
--- a/app/models/members_preloader.rb
+++ b/app/models/members_preloader.rb
@@ -8,12 +8,14 @@ class MembersPreloader
end
def preload_all
- ActiveRecord::Associations::Preloader.new.preload(members, :user)
- ActiveRecord::Associations::Preloader.new.preload(members, :source)
- ActiveRecord::Associations::Preloader.new.preload(members, :created_by)
- ActiveRecord::Associations::Preloader.new.preload(members, user: :status)
- ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations)
- ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn)
+ ActiveRecord::Associations::Preloader.new(
+ records: members,
+ associations: [
+ :source,
+ :created_by,
+ { user: [:status, :webauthn_registrations] }
+ ]
+ ).call
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index f3488f6ea60..7b1d4b97d3b 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -36,24 +36,18 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
- ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
- 'Ci::CompareMetricsReportsService' => ->(project) { true },
- 'Ci::CompareCodequalityReportsService' => ->(project) { true }
- }.freeze
-
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
- if mr
- mr.target_project&.merge_requests&.maximum(:iid)
- elsif scope[:project]
- where(target_project: scope[:project]).maximum(:iid)
- end
- end
+ init: ->(mr, scope) do
+ if mr
+ mr.target_project&.merge_requests&.maximum(:iid)
+ elsif scope[:project]
+ where(target_project: scope[:project]).maximum(:iid)
+ end
+ end
has_many :merge_request_diffs,
-> { regular }, inverse_of: :merge_request
@@ -92,7 +86,7 @@ class MergeRequest < ApplicationRecord
fallback || super || MergeRequestDiff.new(merge_request_id: id)
end
- belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline"
+ belongs_to :head_pipeline, class_name: "Ci::Pipeline", inverse_of: :merge_requests_as_head_pipeline
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -123,6 +117,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,
@@ -141,7 +136,7 @@ class MergeRequest < ApplicationRecord
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
after_save :keep_around_commit, unless: :importing?
- after_commit :ensure_metrics, on: [:create, :update], unless: :importing?
+ after_commit :ensure_metrics!, on: [:create, :update], unless: :importing?
after_commit :expire_etag_cache, unless: :importing?
# When this attribute is true some MR validation is ignored
@@ -156,10 +151,15 @@ class MergeRequest < ApplicationRecord
# when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
+ # Flag to skip triggering mergeRequestMergeStatusUpdated GraphQL subscription.
+ attr_accessor :skip_merge_status_trigger
+
participant :reviewers
- # Keep states definition to be evaluated before the state_machine block to avoid spec failures.
- # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil.
+ # Keep states definition to be evaluated before the state_machine block to
+ # avoid spec failures. If this gets evaluated after, the `merged` and `locked`
+ # states (which are overriden) can be nil.
+ #
def self.available_state_names
super + [:merged, :locked]
end
@@ -195,6 +195,7 @@ class MergeRequest < ApplicationRecord
before_transition any => :merged do |merge_request|
merge_request.merge_error = nil
+ merge_request.metrics.first_contribution = true if merge_request.first_contribution?
end
after_transition any => :opened do |merge_request|
@@ -251,7 +252,9 @@ 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
GraphqlTriggers.merge_request_merge_status_updated(merge_request)
end
@@ -347,11 +350,12 @@ class MergeRequest < ApplicationRecord
end
scope :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> {
- preload_routables
- .preload(:assignees, :author, :unresolved_notes, :labels, :milestone,
- :timelogs, :latest_merge_request_diff, :reviewers,
- target_project: :project_feature,
- metrics: [:latest_closed_by, :merged_by])
+ preload_routables.preload(
+ :assignees, :author, :unresolved_notes, :labels, :milestone,
+ :timelogs, :latest_merge_request_diff, :reviewers,
+ target_project: :project_feature,
+ metrics: [:latest_closed_by, :merged_by]
+ )
}
scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) }
@@ -394,8 +398,10 @@ class MergeRequest < ApplicationRecord
scope :preload_target_project, -> { preload(:target_project) }
scope :preload_target_project_with_namespace, -> { preload(target_project: [:namespace]) }
scope :preload_routables, -> do
- preload(target_project: [:route, { namespace: :route }],
- source_project: [:route, { namespace: :route }])
+ preload(
+ target_project: [:route, { namespace: :route }],
+ source_project: [:route, { namespace: :route }]
+ )
end
scope :preload_author, -> { preload(:author) }
scope :preload_approved_by_users, -> { preload(:approved_by_users) }
@@ -451,7 +457,12 @@ class MergeRequest < ApplicationRecord
def self.total_time_to_merge
join_metrics
- .merge(MergeRequest::Metrics.with_valid_time_to_merge)
+ .where(
+ # Replicating the scope MergeRequest::Metrics.with_valid_time_to_merge
+ MergeRequest::Metrics.arel_table[:merged_at].gt(
+ MergeRequest::Metrics.arel_table[:created_at]
+ )
+ )
.pick(MergeRequest::Metrics.time_to_merge_expression)
end
@@ -558,7 +569,7 @@ class MergeRequest < ApplicationRecord
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("merge_requests", Gitlab::Regex.merge_request)
+ @link_reference_pattern ||= compose_link_reference_pattern('merge_requests', Gitlab::Regex.merge_request)
end
def self.reference_valid?(reference)
@@ -1011,8 +1022,7 @@ class MergeRequest < ApplicationRecord
return true if target_project == source_project
return true unless source_project_missing?
- errors.add :validate_fork,
- 'Source project is not a fork of the target project'
+ errors.add :validate_fork, 'Source project is not a fork of the target project'
end
def validate_reviewer_size_length
@@ -1179,8 +1189,10 @@ class MergeRequest < ApplicationRecord
alias_method :wip_title, :draft_title
def mergeable?(skip_ci_check: false, skip_discussions_check: false)
- return false unless mergeable_state?(skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check)
+ return false unless mergeable_state?(
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ )
check_mergeability
@@ -1201,10 +1213,12 @@ class MergeRequest < ApplicationRecord
end
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
- additional_checks = execute_merge_checks(params: {
- skip_ci_check: skip_ci_check,
- skip_discussions_check: skip_discussions_check
- })
+ additional_checks = execute_merge_checks(
+ params: {
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ }
+ )
additional_checks.success?
end
@@ -1693,7 +1707,7 @@ class MergeRequest < ApplicationRecord
def compare_reports(service_class, current_user = nil, report_type = nil, additional_params = {})
with_reactive_cache(service_class.name, current_user&.id, report_type) do |data|
unless service_class.new(project, current_user, id: id, report_type: report_type, additional_params: additional_params)
- .latest?(comparison_base_pipeline(service_class.name), actual_head_pipeline, data)
+ .latest?(comparison_base_pipeline(service_class), actual_head_pipeline, data)
raise InvalidateReactiveCache
end
@@ -1729,7 +1743,7 @@ class MergeRequest < ApplicationRecord
raise NameError, service_class unless service_class < Ci::CompareReportsBaseService
current_user = User.find_by(id: current_user_id)
- service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(identifier), actual_head_pipeline)
+ service_class.new(project, current_user, id: id, report_type: report_type).execute(comparison_base_pipeline(service_class), actual_head_pipeline)
end
MAX_RECENT_DIFF_HEAD_SHAS = 100
@@ -1870,8 +1884,9 @@ class MergeRequest < ApplicationRecord
end
end
- def use_merge_base_pipeline_for_comparison?(service_class)
- ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON[service_class]&.call(project)
+ # Overridden in EE
+ def use_merge_base_pipeline_for_comparison?(_)
+ false
end
def comparison_base_pipeline(service_class)
@@ -1901,7 +1916,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def first_contribution?
- return false if project.team.max_member_access(author_id) > Gitlab::Access::GUEST
+ return metrics&.first_contribution if merged? & metrics.present?
!project.merge_requests.merged.exists?(author_id: author_id)
end
@@ -1944,8 +1959,7 @@ class MergeRequest < ApplicationRecord
super.merge(label_url_method: :project_merge_requests_url)
end
- override :ensure_metrics
- def ensure_metrics
+ def ensure_metrics!
MergeRequest::Metrics.record!(self)
end
diff --git a/app/models/merge_request/diff_llm_summary.rb b/app/models/merge_request/diff_llm_summary.rb
new file mode 100644
index 00000000000..5e7d80712e2
--- /dev/null
+++ b/app/models/merge_request/diff_llm_summary.rb
@@ -0,0 +1,13 @@
+# rubocop:disable Style/ClassAndModuleChildren
+# frozen_string_literal: true
+
+class MergeRequest::DiffLlmSummary < ApplicationRecord
+ belongs_to :merge_request_diff
+ belongs_to :user, optional: true
+
+ validates :provider, presence: true
+ validates :content, presence: true, length: { maximum: 2056 }
+
+ enum provider: { openai: 0 }
+end
+# rubocop:enable Style/ClassAndModuleChildren
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 87d8704561f..70216144035 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -2,6 +2,7 @@
class MergeRequest::Metrics < ApplicationRecord
include IgnorableColumns
+ include DatabaseEventTracking
belongs_to :merge_request, inverse_of: :metrics
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
@@ -24,16 +25,19 @@ class MergeRequest::Metrics < ApplicationRecord
end
def record!(mr)
+ inserted_columns = %i[merge_request_id target_project_id updated_at created_at]
sql = <<~SQL
- INSERT INTO #{self.table_name} (merge_request_id, target_project_id, updated_at, created_at)
+ INSERT INTO #{self.table_name} (#{inserted_columns.join(', ')})
VALUES (#{mr.id}, #{mr.target_project_id}, NOW(), NOW())
ON CONFLICT (merge_request_id)
DO UPDATE SET
target_project_id = EXCLUDED.target_project_id,
updated_at = NOW()
+ RETURNING id, #{inserted_columns.join(', ')}
SQL
- connection.execute(sql)
+ result = connection.execute(sql).first
+ new(result).publish_database_create_event
end
end
@@ -47,6 +51,31 @@ class MergeRequest::Metrics < ApplicationRecord
with_valid_time_to_merge
.pick(time_to_merge_expression)
end
+
+ SNOWPLOW_ATTRIBUTES = %i[
+ id
+ merge_request_id
+ latest_build_started_at
+ latest_build_finished_at
+ first_deployed_to_production_at
+ merged_at
+ created_at
+ updated_at
+ pipeline_id
+ merged_by_id
+ latest_closed_by_id
+ latest_closed_at
+ first_comment_at
+ first_commit_at
+ last_commit_at
+ diff_size
+ modified_paths_size
+ commits_count
+ first_approved_at
+ first_reassigned_at
+ added_lines
+ removed_lines
+ ].freeze
end
MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics')
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 1395b8ff162..0e699d7a81d 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -622,10 +622,12 @@ class MergeRequestDiff < ApplicationRecord
end
def diffs_in_batch_collection(batch_page, batch_size, diff_options:)
- Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self,
- batch_page,
- batch_size,
- diff_options: diff_options)
+ Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(
+ self,
+ batch_page,
+ batch_size,
+ diff_options: diff_options
+ )
end
def encode_in_base64?(diff_text)
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index 7e2efa2049b..fc08dd4d9c8 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -80,7 +80,7 @@ class MergeRequestDiffCommit < ApplicationRecord
def self.prepare_commits_for_bulk_insert(commits)
user_tuples = Set.new
hashes = commits.map do |commit|
- hash = commit.to_hash.except(:parent_ids)
+ hash = commit.to_hash.except(:parent_ids, :referenced_by)
TRIM_USER_KEYS.each do |key|
hash[key] = MergeRequest::DiffCommitUser.prepare(hash[key])
diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb
index 5c53cfd8c27..54cb6b7888b 100644
--- a/app/models/merge_requests_closing_issues.rb
+++ b/app/models/merge_requests_closing_issues.rb
@@ -17,10 +17,11 @@ class MergeRequestsClosingIssues < ApplicationRecord
scope :accessible_by, ->(user) do
joins(:merge_request)
.joins('INNER JOIN project_features ON merge_requests.target_project_id = project_features.project_id')
- .where('project_features.merge_requests_access_level >= :access OR EXISTS(:authorizations)',
- access: ProjectFeature::ENABLED,
- authorizations: user.authorizations_for_projects(min_access_level: Gitlab::Access::REPORTER, related_project_column: "merge_requests.target_project_id")
- )
+ .where(
+ 'project_features.merge_requests_access_level >= :access OR EXISTS(:authorizations)',
+ access: ProjectFeature::ENABLED,
+ authorizations: user.authorizations_for_projects(min_access_level: Gitlab::Access::REPORTER, related_project_column: "merge_requests.target_project_id")
+ )
end
class << self
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index b0676c25f8e..d300b938fc0 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -8,6 +8,8 @@ class Milestone < ApplicationRecord
include FromUnion
include Importable
include IidRoutes
+ include UpdatedAtFilterable
+ include EachBatch
prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
@@ -26,6 +28,7 @@ class Milestone < ApplicationRecord
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ scope :by_iid, ->(iid) { where(iid: iid) }
scope :active, -> { with_state(:active) }
scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
scope :not_started, -> { active.where('milestones.start_date > CURRENT_DATE') }
@@ -112,7 +115,7 @@ class Milestone < ApplicationRecord
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
+ @link_reference_pattern ||= compose_link_reference_pattern('milestones', /(?<milestone>\d+)/)
end
def self.upcoming_ids(projects, groups)
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..6f4728a1d98 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -3,25 +3,35 @@
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
+ belongs_to :ci_build, class_name: 'Ci::Build', optional: true
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, :ci_build) }
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 +56,34 @@ 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
+ def package_version
+ iid
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
+ def from_ci?
+ ci_build_id.present?
end
- def package_name
- "#{PACKAGE_PREFIX}#{id}"
- end
+ class << self
+ def with_project_id_and_eid(project_id, eid)
+ return unless project_id.present? && eid.present?
- def package_version
- '-'
- end
+ find_by(project_id: project_id, eid: eid)
+ end
- class << self
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/candidate_metadata.rb b/app/models/ml/candidate_metadata.rb
index 06b893c211f..1191051b1a3 100644
--- a/app/models/ml/candidate_metadata.rb
+++ b/app/models/ml/candidate_metadata.rb
@@ -4,9 +4,9 @@ module Ml
class CandidateMetadata < ApplicationRecord
validates :candidate, presence: true
validates :name,
- length: { maximum: 250 },
- presence: true,
- uniqueness: { scope: :candidate, message: ->(candidate, _) { "'#{candidate.name}' already taken" } }
+ length: { maximum: 250 },
+ presence: true,
+ uniqueness: { scope: :candidate, message: ->(candidate, _) { "'#{candidate.name}' already taken" } }
validates :value, length: { maximum: 5000 }, presence: true
belongs_to :candidate, class_name: 'Ml::Candidate'
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/ml/experiment_metadata.rb b/app/models/ml/experiment_metadata.rb
index 93496807e1a..37cb2714268 100644
--- a/app/models/ml/experiment_metadata.rb
+++ b/app/models/ml/experiment_metadata.rb
@@ -4,9 +4,9 @@ module Ml
class ExperimentMetadata < ApplicationRecord
validates :experiment, presence: true
validates :name,
- length: { maximum: 250 },
- presence: true,
- uniqueness: { scope: :experiment, message: ->(exp, _) { "'#{exp.name}' already taken" } }
+ length: { maximum: 250 },
+ presence: true,
+ uniqueness: { scope: :experiment, message: ->(exp, _) { "'#{exp.name}' already taken" } }
validates :value, length: { maximum: 5000 }, presence: true
belongs_to :experiment, class_name: 'Ml::Experiment'
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9d9b09e3562..7c6fa24cd4d 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -16,6 +16,7 @@ class Namespace < ApplicationRecord
include EachBatch
include BlocksUnsafeSerialization
include Ci::NamespaceSettings
+ include Referable
# Tells ActiveRecord not to store the full class name, in order to save some space
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69794
@@ -35,12 +36,6 @@ class Namespace < ApplicationRecord
SHARED_RUNNERS_SETTINGS = [SR_DISABLED_AND_UNOVERRIDABLE, SR_DISABLED_WITH_OVERRIDE, SR_DISABLED_AND_OVERRIDABLE, SR_ENABLED].freeze
URL_MAX_LENGTH = 255
- # This date is just a placeholder until namespace storage enforcement timeline is confirmed at which point
- # this should be replaced, see https://about.gitlab.com/pricing/faq-efficient-free-tier/#user-limits-on-gitlab-saas-free-tier
- MIN_STORAGE_ENFORCEMENT_DATE = 3.months.from_now.to_date
- # https://gitlab.com/gitlab-org/gitlab/-/issues/367531
- MIN_STORAGE_ENFORCEMENT_USAGE = 5.gigabytes
-
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -51,7 +46,8 @@ class Namespace < ApplicationRecord
has_one :namespace_statistics
has_one :namespace_route, foreign_key: :namespace_id, autosave: false, inverse_of: :namespace, class_name: 'Route'
has_many :namespace_members, foreign_key: :member_namespace_id, inverse_of: :member_namespace, class_name: 'Member'
- has_many :member_roles
+
+ has_one :namespace_ldap_settings, inverse_of: :namespace, class_name: 'Namespaces::LdapSetting', autosave: true
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
@@ -97,6 +93,7 @@ class Namespace < ApplicationRecord
validates :path,
presence: true,
length: { maximum: URL_MAX_LENGTH }
+ validate :container_registry_namespace_path_validation
validates :path, namespace_path: true, if: ->(n) { !n.project_namespace? }
# Project path validator is used for project namespaces for now to assure
@@ -127,19 +124,18 @@ class Namespace < ApplicationRecord
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :avatar_url, to: :owner, allow_nil: true
delegate :prevent_sharing_groups_outside_hierarchy, :prevent_sharing_groups_outside_hierarchy=,
- to: :namespace_settings, allow_nil: true
+ to: :namespace_settings, allow_nil: true
delegate :show_diff_preview_in_email, :show_diff_preview_in_email?, :show_diff_preview_in_email=,
- to: :namespace_settings
+ to: :namespace_settings
delegate :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=,
- to: :namespace_settings
+ to: :namespace_settings
delegate :allow_runner_registration_token,
- :allow_runner_registration_token?,
- :allow_runner_registration_token=,
- to: :namespace_settings
+ :allow_runner_registration_token=,
+ to: :namespace_settings
delegate :maven_package_requests_forwarding,
- :pypi_package_requests_forwarding,
- :npm_package_requests_forwarding,
- to: :package_settings
+ :pypi_package_requests_forwarding,
+ :npm_package_requests_forwarding,
+ to: :package_settings
before_save :update_new_emails_created_column, if: -> { emails_disabled_changed? }
before_create :sync_share_with_group_lock_with_parent
@@ -244,27 +240,42 @@ class Namespace < ApplicationRecord
def clean_path(path, limited_to: Namespace.all)
slug = Gitlab::Slug::Path.new(path).generate
path = Namespaces::RandomizedSuffixPath.new(slug)
- Uniquify.new.string(path) { |s| limited_to.find_by_path_or_name(s) }
+ Gitlab::Utils::Uniquify.new.string(path) { |s| limited_to.find_by_path_or_name(s) }
end
def clean_name(value)
value.scan(Gitlab::Regex.group_name_regex_chars).join(' ')
end
- def find_by_pages_host(host)
- gitlab_host = "." + Settings.pages.host.downcase
- host = host.downcase
- return unless host.ends_with?(gitlab_host)
+ def top_most
+ by_parent(nil)
+ end
- name = host.delete_suffix(gitlab_host)
- Namespace.top_most.by_path(name)
+ def reference_prefix
+ User.reference_prefix
end
- def top_most
- by_parent(nil)
+ def reference_pattern
+ User.reference_pattern
end
end
+ def to_reference_base(from = nil, full: false)
+ return full_path if full || cross_namespace_reference?(from)
+ return path if cross_project_reference?(from)
+ end
+
+ def to_reference(*)
+ "#{self.class.reference_prefix}#{full_path}"
+ end
+
+ def container_registry_namespace_path_validation
+ return if Feature.disabled?(:restrict_special_characters_in_namespace_path, self)
+ return if !path_changed? || path.match?(Gitlab::Regex.oci_repository_path_regex)
+
+ errors.add(:path, Gitlab::Regex.oci_repository_path_regex_message)
+ end
+
def package_settings
package_setting_relation || build_package_setting_relation
end
@@ -286,11 +297,15 @@ class Namespace < ApplicationRecord
end
def any_project_has_container_registry_tags?
- all_projects.includes(:container_repositories).any?(&:has_container_registry_tags?)
+ first_project_with_container_registry_tags.present?
end
def first_project_with_container_registry_tags
- all_projects.find(&:has_container_registry_tags?)
+ if ContainerRegistry::GitlabApiClient.supports_gitlab_api?
+ ContainerRegistry::GitlabApiClient.one_project_with_container_registry_tag(full_path)
+ else
+ all_projects.includes(:container_repositories).find(&:has_container_registry_tags?)
+ end
end
def send_update_instructions
@@ -381,12 +396,8 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
- if Feature.enabled?(:recursive_approach_for_all_projects)
- namespace = user_namespace? ? self : self_and_descendant_ids
- Project.where(namespace: namespace)
- else
- Project.inside_path(full_path)
- end
+ namespace = user_namespace? ? self : self_and_descendant_ids
+ Project.where(namespace: namespace)
end
def has_parent?
@@ -473,18 +484,6 @@ class Namespace < ApplicationRecord
ContainerRepository.for_project_id(all_projects)
end
- def pages_virtual_domain
- cache = if Feature.enabled?(:cache_pages_domain_api, root_ancestor)
- ::Gitlab::Pages::CacheControl.for_namespace(root_ancestor.id)
- end
-
- Pages::VirtualDomain.new(
- projects: all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment),
- trim_prefix: full_path,
- cache: cache
- )
- end
-
def any_project_with_pages_deployed?
all_projects.with_pages_deployed.any?
end
@@ -577,12 +576,6 @@ class Namespace < ApplicationRecord
Feature.enabled?(:block_issue_repositioning, self, type: :ops)
end
- def storage_enforcement_date
- return Date.current if Feature.enabled?(:namespace_storage_limit_bypass_date_check, self)
-
- MIN_STORAGE_ENFORCEMENT_DATE
- end
-
def certificate_based_clusters_enabled?
cluster_enabled_granted? || certificate_based_clusters_enabled_ff?
end
@@ -599,8 +592,48 @@ class Namespace < ApplicationRecord
namespace_settings&.all_ancestors_have_runner_registration_enabled?
end
+ def allow_runner_registration_token?
+ !!namespace_settings&.allow_runner_registration_token?
+ end
+
+ def all_projects_with_pages
+ all_projects.with_pages_deployed.includes(
+ :route,
+ :project_setting,
+ :project_feature,
+ pages_metadatum: :pages_deployment
+ )
+ end
+
private
+ def cross_namespace_reference?(from)
+ return false if from == self
+
+ comparable_namespace_id = project_namespace? ? parent_id : id
+
+ case from
+ when Project
+ from.namespace_id != comparable_namespace_id
+ when Namespaces::ProjectNamespace
+ from.parent_id != comparable_namespace_id
+ when Namespace
+ parent != from
+ when User
+ true
+ end
+ end
+
+ # Check if a reference is being done cross-project
+ def cross_project_reference?(from)
+ case from
+ when Project
+ from.project_namespace_id != id
+ else
+ from && self != from
+ end
+ end
+
def update_new_emails_created_column
return if namespace_settings.nil?
return if namespace_settings.emails_enabled == !emails_disabled
@@ -630,10 +663,6 @@ class Namespace < ApplicationRecord
end
end
- def all_projects_with_pages
- all_projects.with_pages_deployed
- end
-
def parent_changed?
parent_id_changed?
end
diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb
index cd7d4fc409a..e08c08f9ced 100644
--- a/app/models/namespace/aggregation_schedule.rb
+++ b/app/models/namespace/aggregation_schedule.rb
@@ -12,11 +12,11 @@ class Namespace::AggregationSchedule < ApplicationRecord
after_create :schedule_root_storage_statistics
- def self.default_lease_timeout
- if Feature.enabled?(:remove_namespace_aggregator_delay)
- 30.minutes.to_i
+ def default_lease_timeout
+ if Feature.enabled?(:reduce_aggregation_schedule_lease, namespace.root_ancestor)
+ 2.minutes.to_i
else
- 1.hour.to_i
+ 30.minutes.to_i
end
end
@@ -27,7 +27,7 @@ class Namespace::AggregationSchedule < ApplicationRecord
.perform_async(namespace_id)
Namespaces::RootStatisticsWorker
- .perform_in(self.class.default_lease_timeout, namespace_id)
+ .perform_in(default_lease_timeout, namespace_id)
end
end
end
@@ -36,7 +36,7 @@ class Namespace::AggregationSchedule < ApplicationRecord
# Used by ExclusiveLeaseGuard
def lease_timeout
- self.class.default_lease_timeout
+ default_lease_timeout
end
# Used by ExclusiveLeaseGuard
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 77974a0f36b..0443e1d9231 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -45,8 +45,9 @@ class Namespace::RootStorageStatistics < ApplicationRecord
attributes_from_project_statistics.merge!(
attributes_from_personal_snippets,
attributes_from_namespace_statistics,
- attributes_for_container_registry_size
- ) { |key, v1, v2| v1 + v2 }
+ attributes_for_container_registry_size,
+ attributes_for_forks_statistics
+ ) { |_, v1, v2| v1 + v2 }
end
def attributes_for_container_registry_size
@@ -58,6 +59,32 @@ class Namespace::RootStorageStatistics < ApplicationRecord
}.with_indifferent_access
end
+ def attributes_for_forks_statistics
+ return {} unless ::Feature.enabled?(:root_storage_statistics_calculate_forks, namespace)
+
+ visibility_levels_to_storage_size_columns = {
+ Gitlab::VisibilityLevel::PRIVATE => :private_forks_storage_size,
+ Gitlab::VisibilityLevel::INTERNAL => :internal_forks_storage_size,
+ Gitlab::VisibilityLevel::PUBLIC => :public_forks_storage_size
+ }
+
+ defaults = {
+ private_forks_storage_size: 0,
+ internal_forks_storage_size: 0,
+ public_forks_storage_size: 0
+ }
+
+ defaults.merge(for_forks_statistics.transform_keys { |k| visibility_levels_to_storage_size_columns[k] })
+ end
+
+ def for_forks_statistics
+ all_projects
+ .joins([:statistics, :fork_network])
+ .where('fork_networks.root_project_id != projects.id')
+ .group('projects.visibility_level')
+ .sum('project_statistics.storage_size')
+ end
+
def attributes_from_project_statistics
from_project_statistics
.take
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index aeb4d7a5694..e7f6db38047 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
@@ -63,6 +64,8 @@ class NamespaceSetting < ApplicationRecord
end
def all_ancestors_have_runner_registration_enabled?
+ return false unless Gitlab::CurrentSettings.valid_runner_registrars.include?('group')
+
return true unless namespace.has_parent?
!self.class.where(namespace_id: namespace.ancestors, runner_registration_enabled: false).exists?
diff --git a/app/models/namespaces/ldap_setting.rb b/app/models/namespaces/ldap_setting.rb
new file mode 100644
index 00000000000..73125d347cc
--- /dev/null
+++ b/app/models/namespaces/ldap_setting.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class LdapSetting < ApplicationRecord
+ belongs_to :namespace, inverse_of: :namespace_ldap_settings
+ validates :namespace, presence: true
+
+ self.primary_key = :namespace_id
+ self.table_name = 'namespace_ldap_settings'
+ end
+end
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 0e9760832af..9006f104c64 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -117,17 +117,13 @@ 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 parent_id.nil?
+ if association(:parent).loaded? && parent.present?
+ # This case is possible when parent has not been persisted or we're inside a transaction.
+ parent.root_ancestor
+ elsif parent_id.nil?
+ # There is no parent, so we are the root ancestor.
self
else
Namespace.find_by(id: traversal_ids.first)
@@ -215,6 +211,16 @@ module Namespaces
hierarchy_order == :desc ? traversal_ids : traversal_ids.reverse
end
+ def parent=(obj)
+ super(obj)
+ set_traversal_ids
+ end
+
+ def parent_id=(id)
+ super(id)
+ set_traversal_ids
+ end
+
private
attr_accessor :transient_traversal_ids
@@ -232,11 +238,11 @@ module Namespaces
end
def set_traversal_ids
+ return if id.blank?
+
# This is a temporary guard and will be removed.
return if is_a?(Namespaces::ProjectNamespace)
- return unless Feature.enabled?(:set_traversal_ids_on_save, root_ancestor)
-
self.transient_traversal_ids = if parent_id
parent.traversal_ids + [id]
else
@@ -244,7 +250,7 @@ module Namespaces
end
# Clear root_ancestor memo if changed.
- if read_attribute(traversal_ids)&.first != transient_traversal_ids.first
+ if read_attribute(:traversal_ids)&.first != transient_traversal_ids.first
clear_memoization(:root_ancestor)
end
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 843de9bce33..792964a6c7f 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -27,9 +27,11 @@ module Namespaces
def self_and_ancestors(include_self: true, upto: nil, hierarchy_order: nil)
return super unless use_traversal_ids_for_ancestor_scopes?
- self_and_ancestors_from_inner_join(include_self: include_self,
- upto: upto, hierarchy_order:
- hierarchy_order)
+ self_and_ancestors_from_inner_join(
+ include_self: include_self,
+ upto: upto, hierarchy_order:
+ hierarchy_order
+ )
end
def self_and_ancestor_ids(include_self: true)
diff --git a/app/models/note.rb b/app/models/note.rb
index a64f7311725..ac2b54629ae 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -60,6 +60,9 @@ class Note < ApplicationRecord
# Attribute used to store the attributes that have been changed by quick actions.
attr_writer :commands_changes
+ # Attribute used to store the quick action command names.
+ attr_accessor :command_names
+
# Attribute used to determine whether keep_around_commits will be skipped for diff notes.
attr_accessor :skip_keep_around_commits
@@ -84,6 +87,7 @@ class Note < ApplicationRecord
inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
+ has_one :note_metadata, inverse_of: :note, class_name: 'Notes::NoteMetadata'
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
has_many :diff_note_positions
@@ -92,6 +96,8 @@ class Note < ApplicationRecord
delegate :name, :email, to: :author, prefix: true
delegate :title, to: :noteable, allow_nil: true
+ accepts_nested_attributes_for :note_metadata
+
validates :note, presence: true
validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }
validates :project, presence: true, if: :for_project_noteable?
@@ -165,11 +171,20 @@ class Note < ApplicationRecord
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
- includes(:author, :noteable, :updated_by,
- project: [:project_members, :namespace, { group: [:group_members] }])
+ includes(
+ :author, :noteable, :updated_by,
+ project: [:project_members, :namespace, { group: [:group_members] }]
+ )
end
scope :with_metadata, -> { includes(:system_note_metadata) }
- scope :with_web_entity_associations, -> { preload(:project, :author, :noteable) }
+
+ 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) }
@@ -288,6 +303,10 @@ class Note < ApplicationRecord
def cherry_picked_merge_requests(shas)
where(noteable_type: 'MergeRequest', commit_id: shas).select(:noteable_id)
end
+
+ def with_web_entity_associations
+ preload(:project, :author, :noteable)
+ end
end
# rubocop: disable CodeReuse/ServiceClass
@@ -330,6 +349,10 @@ class Note < ApplicationRecord
noteable_type == "Issue"
end
+ def for_work_item?
+ noteable.is_a?(WorkItem)
+ end
+
def for_merge_request?
noteable_type == "MergeRequest"
end
@@ -382,8 +405,6 @@ class Note < ApplicationRecord
project.merge_requests.by_commit_sha(commit_id)
elsif for_merge_request?
MergeRequest.id_in(noteable_id)
- else
- nil
end
end
diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb
index 4238de0a2f8..e4936de7b40 100644
--- a/app/models/note_diff_file.rb
+++ b/app/models/note_diff_file.rb
@@ -19,9 +19,11 @@ class NoteDiffFile < ApplicationRecord
def raw_diff_file
raw_diff = Gitlab::Git::Diff.new(to_hash)
- Gitlab::Diff::File.new(raw_diff,
- repository: project.repository,
- diff_refs: original_position.diff_refs,
- unique_identifier: id)
+ Gitlab::Diff::File.new(
+ raw_diff,
+ repository: project.repository,
+ diff_refs: original_position.diff_refs,
+ unique_identifier: id
+ )
end
end
diff --git a/app/models/notes/note_metadata.rb b/app/models/notes/note_metadata.rb
new file mode 100644
index 00000000000..54c3688170f
--- /dev/null
+++ b/app/models/notes/note_metadata.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Notes
+ class NoteMetadata < ApplicationRecord
+ self.table_name = :note_metadata
+
+ EMAIL_PARTICIPANT_LENGTH = 255
+
+ belongs_to :note, inverse_of: :note_metadata
+
+ alias_attribute :external_author, :email_participant
+
+ before_save :ensure_email_participant_length
+
+ private
+
+ def ensure_email_participant_length
+ return unless email_participant.present?
+
+ self.email_participant = email_participant.truncate(EMAIL_PARTICIPANT_LENGTH)
+ end
+ end
+end
diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb
index 8e79a750793..601381f1c65 100644
--- a/app/models/oauth_access_token.rb
+++ b/app/models/oauth_access_token.rb
@@ -4,6 +4,8 @@ class OauthAccessToken < Doorkeeper::AccessToken
belongs_to :resource_owner, class_name: 'User'
belongs_to :application, class_name: 'Doorkeeper::Application'
+ validates :expires_in, presence: true
+
alias_attribute :user, :resource_owner
scope :latest_per_application, -> { select('distinct on(application_id) *').order(application_id: :desc, created_at: :desc) }
diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb
index 269283df826..afbd671f82e 100644
--- a/app/models/onboarding/completion.rb
+++ b/app/models/onboarding/completion.rb
@@ -5,66 +5,66 @@ module Onboarding
include Gitlab::Utils::StrongMemoize
include Gitlab::Experiment::Dsl
- ACTION_ISSUE_IDS = {
- trial_started: 2,
- required_mr_approvals_enabled: 11,
- code_owners_enabled: 10
- }.freeze
-
ACTION_PATHS = [
:pipeline_created,
+ :trial_started,
+ :required_mr_approvals_enabled,
+ :code_owners_enabled,
:issue_created,
:git_write,
:merge_request_created,
- :user_added
+ :user_added,
+ :license_scanning_run,
+ :secure_dependency_scanning_run,
+ :secure_dast_run
].freeze
- def initialize(namespace, current_user = nil)
- @namespace = namespace
+ def initialize(project, current_user = nil)
+ @project = project
+ @namespace = project.namespace
@current_user = current_user
end
def percentage
return 0 unless onboarding_progress
- attributes = onboarding_progress.attributes.symbolize_keys
-
total_actions = action_columns.count
- completed_actions = action_columns.count { |column| attributes[column].present? }
+ completed_actions = action_columns.count { |column| completed?(column) }
(completed_actions.to_f / total_actions * 100).round
end
+ def completed?(column)
+ if column == :code_added
+ repository.commit_count > 1 || repository.branch_count > 1
+ else
+ attributes[column].present?
+ end
+ end
+
private
- def onboarding_progress
- strong_memoize(:onboarding_progress) do
- ::Onboarding::Progress.find_by(namespace: namespace)
- end
+ def repository
+ project.repository
end
+ strong_memoize_attr :repository
- def action_columns
- strong_memoize(:action_columns) do
- tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
- end
+ def attributes
+ onboarding_progress.attributes.symbolize_keys
end
+ strong_memoize_attr :attributes
- def tracked_actions
- ACTION_ISSUE_IDS.keys + ACTION_PATHS + deploy_section_tracked_actions
+ def onboarding_progress
+ ::Onboarding::Progress.find_by(namespace: namespace)
end
+ strong_memoize_attr :onboarding_progress
- 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
+ def action_columns
+ [:code_added] +
+ ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) }
end
+ strong_memoize_attr :action_columns
- attr_reader :namespace, :current_user
+ attr_reader :project, :namespace, :current_user
end
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 0df8c87f73f..6876af09c2c 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -72,7 +72,7 @@ module Operations
end
def link_reference_pattern
- @link_reference_pattern ||= super("feature_flags", %r{(?<feature_flag>\d+)/edit})
+ @link_reference_pattern ||= compose_link_reference_pattern('feature_flags', %r{(?<feature_flag>\d+)/edit})
end
def reference_postfix
diff --git a/app/models/organization.rb b/app/models/organization.rb
new file mode 100644
index 00000000000..cfbbbf1183e
--- /dev/null
+++ b/app/models/organization.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Organization < ApplicationRecord
+ DEFAULT_ORGANIZATION_ID = 1
+
+ scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) }
+
+ before_destroy :check_if_default_organization
+
+ validates :name,
+ presence: true,
+ length: { maximum: 255 },
+ uniqueness: { case_sensitive: false }
+
+ def default?
+ id == DEFAULT_ORGANIZATION_ID
+ end
+
+ private
+
+ def check_if_default_organization
+ return unless default?
+
+ raise ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization')
+ end
+end
diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb
index 9c615c20250..2b8d0a4f51e 100644
--- a/app/models/packages/debian.rb
+++ b/app/models/packages/debian.rb
@@ -10,6 +10,10 @@ module Packages
LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze
+ 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 eb1b03a8e9d..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
- }
+ 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] },
- 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?
- 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/dependency.rb b/app/models/packages/dependency.rb
index ad3944b5f21..c39b46dcc20 100644
--- a/app/models/packages/dependency.rb
+++ b/app/models/packages/dependency.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
class Packages::Dependency < ApplicationRecord
+ include EachBatch
+
has_many :dependency_links, class_name: 'Packages::DependencyLink'
validates :name, :version_pattern, presence: true
@@ -41,6 +43,11 @@ class Packages::Dependency < ApplicationRecord
pluck(:id, :name)
end
+ def self.orphaned
+ subquery = Packages::DependencyLink.where(Packages::DependencyLink.arel_table[:dependency_id].eq(Packages::Dependency.arel_table[:id]))
+ where_not_exists(subquery)
+ end
+
def orphaned?
self.dependency_links.empty?
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..7a7c66d7a45
--- /dev/null
+++ b/app/models/packages/npm/metadata_cache.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class MetadataCache < ApplicationRecord
+ include FileStoreMounter
+
+ belongs_to :project, inverse_of: :npm_metadata_caches
+
+ validates :file, :object_storage_key, :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 }
+
+ mount_file_store_uploader MetadataCacheUploader
+
+ before_validation :set_object_storage_key
+ attr_readonly :object_storage_key
+
+ def self.find_or_build(package_name:, project_id:)
+ find_or_initialize_by(
+ package_name: package_name,
+ project_id: project_id
+ )
+ end
+
+ private
+
+ def set_object_storage_key
+ return unless package_name && project_id
+
+ self.object_storage_key = Gitlab::HashedPath.new(
+ 'packages', 'metadata_caches', 'npm', OpenSSL::Digest::SHA256.hexdigest(package_name),
+ root_hash: project_id
+ ).to_s
+ end
+ end
+ end
+end
diff --git a/app/models/packages/npm/metadatum.rb b/app/models/packages/npm/metadatum.rb
index 7388c4bdbd2..ccbf056ec7b 100644
--- a/app/models/packages/npm/metadatum.rb
+++ b/app/models/packages/npm/metadatum.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class Packages::Npm::Metadatum < ApplicationRecord
+ MAX_PACKAGE_JSON_SIZE = 20_000
+ MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING = 5_000
+ NUM_FIELDS_FOR_ERROR_TRACKING = 5
+
belongs_to :package, -> { where(package_type: :npm) }, inverse_of: :npm_metadatum
validates :package, presence: true
@@ -9,6 +13,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
@@ -18,7 +24,7 @@ class Packages::Npm::Metadatum < ApplicationRecord
end
def ensure_package_json_size
- return if package_json.to_s.size < 20000
+ return if package_json.to_s.size < MAX_PACKAGE_JSON_SIZE
errors.add(:package_json, _('structure is too large'))
end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 970538b45e7..c58ad92d7a6 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? }
@@ -155,6 +156,7 @@ class Packages::Package < ApplicationRecord
scope :preload_npm_metadatum, -> { preload(:npm_metadatum) }
scope :preload_nuget_metadatum, -> { preload(:nuget_metadatum) }
scope :preload_pypi_metadatum, -> { preload(:pypi_metadatum) }
+ scope :preload_conan_metadatum, -> { preload(:conan_metadatum) }
scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
@@ -179,6 +181,7 @@ class Packages::Package < ApplicationRecord
scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') }
scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') }
scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') }
+ scope :with_npm_scope, ->(scope) { npm.where("name ILIKE :package_name", package_name: "@#{sanitize_sql_like(scope)}/%") }
scope :order_project_path, -> do
keyset_order = keyset_pagination_order(join_class: Project, column_name: :path, direction: :asc)
@@ -222,6 +225,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
@@ -288,9 +297,14 @@ class Packages::Package < ApplicationRecord
end
# Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937
+ # TODO: rename the method https://gitlab.com/gitlab-org/gitlab/-/issues/410352
def original_build_info
strong_memoize(:original_build_info) do
- build_infos.first
+ if Feature.enabled?(:packages_display_last_pipeline, project)
+ build_infos.last
+ else
+ build_infos.first
+ end
end
end
@@ -353,6 +367,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 +430,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?
@@ -426,3 +439,5 @@ class Packages::Package < ApplicationRecord
end
end
end
+
+Packages::Package.prepend_mod
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/packages/rpm/repository_file.rb b/app/models/packages/rpm/repository_file.rb
index 614ec9b3e56..bbd435691d2 100644
--- a/app/models/packages/rpm/repository_file.rb
+++ b/app/models/packages/rpm/repository_file.rb
@@ -13,7 +13,7 @@ module Packages
enum status: { default: 0, pending_destruction: 1, processing: 2, error: 3 }
- belongs_to :project, inverse_of: :repository_files
+ belongs_to :project, inverse_of: :rpm_repository_files
validates :project, presence: true
validates :file, presence: true
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index a1ba48f3ab0..864ea04c019 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -49,19 +49,32 @@ module Pages
if project.pages_namespace_url == project.pages_url
'/'
else
- project.full_path.delete_prefix(trim_prefix) + '/'
+ "#{project.full_path.delete_prefix(trim_prefix)}/"
end
end
strong_memoize_attr :prefix
+ def unique_host
+ return unless project.project_setting.pages_unique_domain_enabled?
+
+ project.pages_unique_host
+ end
+ strong_memoize_attr :unique_host
+
+ def root_directory
+ return unless deployment
+
+ deployment.root_directory
+ end
+ strong_memoize_attr :root_directory
+
private
attr_reader :project, :trim_prefix, :domain
def deployment
- strong_memoize(:deployment) do
- project.pages_metadatum.pages_deployment
- end
+ project.pages_metadatum.pages_deployment
end
+ strong_memoize_attr :deployment
end
end
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/pages_domain.rb b/app/models/pages_domain.rb
index 909658214fd..10ac10295fc 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -15,7 +15,6 @@ class PagesDomain < ApplicationRecord
belongs_to :project
has_many :acme_orders, class_name: "PagesDomainAcmeOrder"
- has_many :serverless_domain_clusters, class_name: 'Serverless::DomainCluster', inverse_of: :pages_domain
after_initialize :set_verification_code
before_validation :clear_auto_ssl_failure, unless: :auto_ssl_enabled
@@ -173,6 +172,10 @@ class PagesDomain < ApplicationRecord
"#{VERIFICATION_KEY}=#{verification_code}"
end
+ def verification_record
+ "#{verification_domain} TXT #{keyed_verification_code}"
+ end
+
def certificate=(certificate)
super(certificate)
@@ -209,20 +212,6 @@ class PagesDomain < ApplicationRecord
self.certificate_source = 'gitlab_provided' if attribute_changed?(:key)
end
- def pages_virtual_domain
- return unless pages_deployed?
-
- cache = if Feature.enabled?(:cache_pages_domain_api, project.root_namespace)
- ::Gitlab::Pages::CacheControl.for_domain(id)
- end
-
- Pages::VirtualDomain.new(
- projects: [project],
- domain: self,
- cache: cache
- )
- end
-
def clear_auto_ssl_failure
self.auto_ssl_failed = false
end
@@ -237,14 +226,14 @@ class PagesDomain < ApplicationRecord
end
end
- private
-
def pages_deployed?
return false unless project
project.pages_metadatum&.deployed?
end
+ private
+
def set_verification_code
return if self.verification_code.present?
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index f99c4c6c39d..75afff6a2fa 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -9,10 +9,13 @@ class PersonalAccessToken < ApplicationRecord
include Gitlab::SQL::Pattern
extend ::Gitlab::Utils::Override
- add_authentication_token_field :token, digest: true
+ add_authentication_token_field :token,
+ digest: true,
+ format_with_prefix: :prefix_from_application_current_settings
# PATs are 20 characters + optional configurable settings prefix (0..20)
TOKEN_LENGTH_RANGE = (20..40).freeze
+ MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS = 365
serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
@@ -39,13 +42,14 @@ 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) }
validates :scopes, presence: true
validate :validate_scopes
+ validate :expires_at_before_instance_max_expiry_date, on: :create
def revoke!
update!(revoked: true)
@@ -55,6 +59,19 @@ class PersonalAccessToken < ApplicationRecord
!revoked? && !expired?
end
+ # fall back to default value until background migration has updated all
+ # existing PATs and we can add a validation
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/369123
+ def expires_at=(value)
+ datetime = if Feature.enabled?(:default_pat_expiration)
+ value.presence || MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
+ else
+ value
+ end
+
+ super(datetime)
+ end
+
override :simple_sorts
def self.simple_sorts
super.merge(
@@ -72,11 +89,6 @@ class PersonalAccessToken < ApplicationRecord
fuzzy_search(query, [:name])
end
- override :format_token
- def format_token(token)
- "#{self.class.token_prefix}#{token}"
- end
-
def project_access_token?
user&.project_bot?
end
@@ -107,6 +119,19 @@ class PersonalAccessToken < ApplicationRecord
def add_admin_mode_scope
self.scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE.to_s]
end
+
+ def prefix_from_application_current_settings
+ self.class.token_prefix
+ end
+
+ def expires_at_before_instance_max_expiry_date
+ return unless Feature.enabled?(:default_pat_expiration)
+ return unless expires_at
+
+ if expires_at > MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
+ errors.add(:expires_at, _('must expire in 365 days'))
+ end
+ end
end
PersonalAccessToken.prepend_mod_with('PersonalAccessToken')
diff --git a/app/models/preloaders/commit_status_preloader.rb b/app/models/preloaders/commit_status_preloader.rb
index 535dd24ba6b..79c2549e371 100644
--- a/app/models/preloaders/commit_status_preloader.rb
+++ b/app/models/preloaders/commit_status_preloader.rb
@@ -9,10 +9,11 @@ module Preloaders
end
def execute(relations)
- preloader = ActiveRecord::Associations::Preloader.new
-
CLASSES.each do |klass|
- preloader.preload(objects(klass), associations(klass, relations))
+ ActiveRecord::Associations::Preloader.new(
+ records: objects(klass),
+ associations: associations(klass, relations)
+ ).call
end
end
diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb
index b6e73c1cd02..7ee0ec0ca43 100644
--- a/app/models/preloaders/labels_preloader.rb
+++ b/app/models/preloaders/labels_preloader.rb
@@ -19,17 +19,32 @@ module Preloaders
end
def preload_all
- preloader = ActiveRecord::Associations::Preloader.new
+ ActiveRecord::Associations::Preloader.new(
+ records: project_labels,
+ associations: { project: [:project_feature, namespace: :route] }
+ ).call
- preloader.preload(labels, parent_container: :route)
- preloader.preload(labels.select { |l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] })
- preloader.preload(labels.select { |l| l.is_a? GroupLabel }, { group: :route })
+ ActiveRecord::Associations::Preloader.new(
+ 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/project_policy_preloader.rb b/app/models/preloaders/project_policy_preloader.rb
index fe9db3464c7..e16eabf40a1 100644
--- a/app/models/preloaders/project_policy_preloader.rb
+++ b/app/models/preloaders/project_policy_preloader.rb
@@ -10,7 +10,10 @@ module Preloaders
def execute
return if projects.is_a?(ActiveRecord::NullRelation)
- ActiveRecord::Associations::Preloader.new.preload(projects, { group: :route, namespace: :owner })
+ ActiveRecord::Associations::Preloader.new(
+ records: projects,
+ associations: { group: :route, namespace: :owner }
+ ).call
::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
end
diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb
index 6192f79ce2c..ccb9d2eab98 100644
--- a/app/models/preloaders/project_root_ancestor_preloader.rb
+++ b/app/models/preloaders/project_root_ancestor_preloader.rb
@@ -19,7 +19,7 @@ module Preloaders
root_ancestors_by_id = root_query.group_by(&:source_id)
- ActiveRecord::Associations::Preloader.new.preload(@projects, :namespace)
+ ActiveRecord::Associations::Preloader.new(records: @projects, associations: :namespace).call
@projects.each do |project|
root_ancestor = root_ancestors_by_id[project.id]&.first
project.namespace.root_ancestor = root_ancestor if root_ancestor.present?
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/user_max_access_level_in_groups_preloader.rb b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb
index 0c747ad9c84..16d46facb96 100644
--- a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb
+++ b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb
@@ -46,14 +46,10 @@ module Preloaders
end
def all_memberships
- if Feature.enabled?(:include_memberships_from_group_shares_in_preloader)
- [
- direct_memberships.select(*GroupMember.cached_column_list),
- memberships_from_group_shares
- ]
- else
- [direct_memberships]
- end
+ [
+ direct_memberships.select(*GroupMember.cached_column_list),
+ memberships_from_group_shares
+ ]
end
def direct_memberships
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 43ec26be786..224193fba08 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -19,6 +19,7 @@ class Project < ApplicationRecord
include Presentable
include HasRepository
include HasWiki
+ include WebHooks::HasWebHooks
include CanMoveRepositoryStorage
include Routable
include GroupDescendant
@@ -41,7 +42,7 @@ class Project < ApplicationRecord
include BlocksUnsafeSerialization
include Subquery
include IssueParent
- include WebHooks::HasWebHooks
+ include UpdatedAtFilterable
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -89,6 +90,14 @@ class Project < ApplicationRecord
DEFAULT_SQUASH_COMMIT_TEMPLATE = '%{title}'
+ PROJECT_FEATURES_DEFAULTS = {
+ issues: gitlab_config_features.issues,
+ merge_requests: gitlab_config_features.merge_requests,
+ builds: gitlab_config_features.builds,
+ wiki: gitlab_config_features.wiki,
+ snippets: gitlab_config_features.snippets
+ }.freeze
+
cache_markdown_field :description, pipeline: :description
attribute :packages_enabled, default: true
@@ -101,18 +110,14 @@ class Project < ApplicationRecord
attribute :autoclose_referenced_issues, default: true
attribute :ci_config_path, default: -> { Gitlab::CurrentSettings.default_ci_config_path }
- default_value_for :issues_enabled, gitlab_config_features.issues
- default_value_for :merge_requests_enabled, gitlab_config_features.merge_requests
- default_value_for :builds_enabled, gitlab_config_features.builds
- default_value_for :wiki_enabled, gitlab_config_features.wiki
- default_value_for :snippets_enabled, gitlab_config_features.snippets
-
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption) ? :optional : :required },
- prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ format_with_prefix: :runners_token_prefix,
+ require_prefix_for_validation: true
# Storage specific hooks
after_initialize :use_hashed_storage
+ after_initialize :set_project_feature_defaults, if: :new_record?
before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? }
before_validation :ensure_project_namespace_in_sync
@@ -128,7 +133,6 @@ class Project < ApplicationRecord
after_create -> { create_or_load_association(:pages_metadatum) }
after_create :set_timestamps_for_create
after_create :check_repository_absence!
- after_update :update_forks_visibility_level
before_destroy :remove_private_deploy_keys
after_destroy :remove_exports
after_save :update_project_statistics, if: :saved_change_to_namespace_id?
@@ -165,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
@@ -183,11 +188,13 @@ class Project < ApplicationRecord
has_one :confluence_integration, class_name: 'Integrations::Confluence'
has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker'
has_one :datadog_integration, class_name: 'Integrations::Datadog'
+ has_one :container_registry_data_repair_detail, class_name: 'ContainerRegistry::DataRepairDetail'
has_one :discord_integration, class_name: 'Integrations::Discord'
has_one :drone_ci_integration, class_name: 'Integrations::DroneCi'
has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush'
has_one :ewm_integration, class_name: 'Integrations::Ewm'
has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki'
+ has_one :google_play_integration, class_name: 'Integrations::GooglePlay'
has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
has_one :harbor_integration, class_name: 'Integrations::Harbor'
has_one :irker_integration, class_name: 'Integrations::Irker'
@@ -208,6 +215,7 @@ class Project < ApplicationRecord
has_one :shimo_integration, class_name: 'Integrations::Shimo'
has_one :slack_integration, class_name: 'Integrations::Slack'
has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands'
+ has_one :squash_tm_integration, class_name: 'Integrations::SquashTm'
has_one :teamcity_integration, class_name: 'Integrations::Teamcity'
has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit'
has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams'
@@ -215,6 +223,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,
@@ -238,14 +247,24 @@ class Project < ApplicationRecord
has_many :fork_network_projects, through: :fork_network, source: :projects
# Packages
- has_many :packages, class_name: 'Packages::Package'
- has_many :package_files, through: :packages, class_name: 'Packages::PackageFile'
+ has_many :packages,
+ class_name: 'Packages::Package'
+ has_many :package_files,
+ through: :packages, class_name: 'Packages::PackageFile'
# repository_files must be destroyed by ruby code in order to properly remove carrierwave uploads
- has_many :repository_files, inverse_of: :project, class_name: 'Packages::Rpm::RepositoryFile',
- dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :rpm_repository_files,
+ inverse_of: :project,
+ class_name: 'Packages::Rpm::RepositoryFile',
+ dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
- has_many :debian_distributions, class_name: 'Packages::Debian::ProjectDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project
+ 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
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -259,12 +278,15 @@ class Project < ApplicationRecord
has_one :project_setting, inverse_of: :project, autosave: true
has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
has_one :service_desk_setting, class_name: 'ServiceDeskSetting'
+ 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
has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project
has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
has_many :issues, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :work_items # the issues relation will handle any destroys
has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus'
has_many :incident_management_timeline_event_tags, inverse_of: :project, class_name: 'IncidentManagement::TimelineEventTag'
has_many :labels, class_name: 'ProjectLabel', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -342,6 +364,7 @@ class Project < ApplicationRecord
has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace'
has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project
has_many :cluster_agents, class_name: 'Clusters::Agent'
+ has_many :ci_access_project_authorizations, class_name: 'Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization'
has_many :prometheus_metrics
has_many :prometheus_alerts, inverse_of: :project
@@ -371,7 +394,6 @@ class Project < ApplicationRecord
inverse_of: :project
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project
-
has_many :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :project
has_many :pending_builds, class_name: 'Ci::PendingBuild'
has_many :builds, class_name: 'Ci::Build', inverse_of: :project
@@ -476,6 +498,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
@@ -484,7 +507,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
@@ -496,7 +519,6 @@ class Project < ApplicationRecord
delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci_outbound, allow_nil: true
delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true
- delegate :opt_in_jwt, :opt_in_jwt=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
@@ -719,6 +741,11 @@ class Project < ApplicationRecord
topic ? with_topic(topic) : none
end
+ scope :pending_data_repair_analysis, -> do
+ left_outer_joins(:container_registry_data_repair_detail)
+ .where(container_registry_data_repair_details: { project_id: nil })
+ end
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
@@ -874,7 +901,7 @@ class Project < ApplicationRecord
def reference_pattern
%r{
(?<!#{Gitlab::PathRegex::PATH_START_CHAR})
- ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
+ ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})/)?
(?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
}xo
end
@@ -950,27 +977,44 @@ class Project < ApplicationRecord
.where(pending_delete: false)
.where(archived: false)
end
+
+ def project_features_defaults
+ PROJECT_FEATURES_DEFAULTS
+ end
+
+ def by_pages_enabled_unique_domain(domain)
+ without_deleted
+ .joins(:project_setting)
+ .find_by(project_setting: {
+ pages_unique_domain_enabled: true,
+ pages_unique_domain: domain
+ })
+ end
end
def initialize(attributes = nil)
- # We can't use default_value_for because the database has a default
- # value of 0 for visibility_level. If someone attempts to create a
- # private project, default_value_for will assume that the
- # visibility_level hasn't changed and will use the application
- # setting default, which could be internal or public. For projects
- # inside a private group, those levels are invalid.
- #
- # To fix the problem, we assign the actual default in the application if
- # no explicit visibility has been initialized.
+ # We assign the actual snippet default if no explicit visibility has been initialized.
attributes ||= {}
unless visibility_attribute_present?(attributes)
attributes[:visibility_level] = Gitlab::CurrentSettings.default_project_visibility
end
+ @init_attributes = attributes
+
super
end
+ # Remove along with ProjectFeaturesCompatibility module
+ def set_project_feature_defaults
+ self.class.project_features_defaults.each do |attr, value|
+ # If the deprecated _enabled or the accepted _access_level attribute is specified, we don't need to set the default
+ next unless @init_attributes[:"#{attr}_enabled"].nil? && @init_attributes[:"#{attr}_access_level"].nil?
+
+ public_send("#{attr}_enabled=", value) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
def parent_loaded?
association(:namespace).loaded?
end
@@ -1077,8 +1121,10 @@ class Project < ApplicationRecord
end
def preload_protected_branches
- preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels])
+ ActiveRecord::Associations::Preloader.new(
+ records: [self],
+ associations: { protected_branches: [:push_access_levels, :merge_access_levels] }
+ ).call
end
# returns all ancestor-groups upto but excluding the given namespace
@@ -1089,11 +1135,7 @@ class Project < ApplicationRecord
end
def ancestors(hierarchy_order: nil)
- if Feature.enabled?(:linear_project_ancestors, self)
- group&.self_and_ancestors(hierarchy_order: hierarchy_order) || Group.none
- else
- ancestors_upto(hierarchy_order: hierarchy_order)
- end
+ group&.self_and_ancestors(hierarchy_order: hierarchy_order) || Group.none
end
def ancestors_upto_ids(...)
@@ -1140,10 +1182,6 @@ class Project < ApplicationRecord
auto_devops_config[:scope] != :project && !auto_devops_config[:status]
end
- def has_packages?(package_type)
- packages.where(package_type: package_type).exists?
- end
-
def packages_cleanup_policy
super || build_packages_cleanup_policy
end
@@ -1154,10 +1192,6 @@ class Project < ApplicationRecord
{ scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) }
end
- def unlink_forks_upon_visibility_decrease_enabled?
- Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self)
- end
-
# LFS and hashed repository storage are required for using Design Management.
def design_management_enabled?
lfs_enabled? && hashed_storage?(:repository)
@@ -1171,21 +1205,16 @@ class Project < ApplicationRecord
@repository ||= Gitlab::GlRepository::PROJECT.repository_for(self)
end
+ def design_management_repository
+ super || create_design_management_repository
+ end
+
def design_repository
strong_memoize(:design_repository) do
Gitlab::GlRepository::DESIGN.repository_for(self)
end
end
- # Because we use default_value_for we need to be sure
- # packages_enabled= method does exist even if we rollback migration.
- # Otherwise many tests from spec/migrations will fail.
- def packages_enabled=(value)
- if has_attribute?(:packages_enabled)
- write_attribute(:packages_enabled, value)
- end
- end
-
def cleanup
@repository = nil
end
@@ -1239,12 +1268,16 @@ class Project < ApplicationRecord
latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find job #{job_name}")
end
- def latest_pipeline(ref = default_branch, sha = nil)
+ def latest_pipelines(ref = default_branch, sha = nil)
ref = ref.presence || default_branch
sha ||= commit(ref)&.sha
- return unless sha
+ return ci_pipelines.none unless sha
+
+ ci_pipelines.newest_first(ref: ref, sha: sha)
+ end
- ci_pipelines.newest_first(ref: ref, sha: sha).take
+ def latest_pipeline(ref = default_branch, sha = nil)
+ latest_pipelines(ref, sha).take
end
def merge_base_commit(first_commit_id, second_commit_id)
@@ -1272,6 +1305,18 @@ class Project < ApplicationRecord
import_state&.human_status_name || 'none'
end
+ def beautified_import_status_name
+ if import_finished?
+ return 'completed' unless import_checksums.present?
+
+ fetched = import_checksums['fetched']
+ imported = import_checksums['imported']
+ fetched.keys.any? { |key| fetched[key] != imported[key] } ? 'partially completed' : 'completed'
+ else
+ import_status
+ end
+ end
+
def add_import_job
job_id =
if forked?
@@ -1314,6 +1359,11 @@ class Project < ApplicationRecord
super(value&.delete("\0"))
end
+ # Used by Import/Export to export commit notes
+ def commit_notes
+ notes.where(noteable_type: "Commit")
+ end
+
def import_url=(value)
if Gitlab::UrlSanitizer.valid?(value)
import_url = Gitlab::UrlSanitizer.new(value)
@@ -1355,7 +1405,7 @@ class Project < ApplicationRecord
end
def import?
- external_import? || forked? || gitlab_project_import? || jira_import? || bare_repository_import? || gitlab_project_migration?
+ external_import? || forked? || gitlab_project_import? || jira_import? || gitlab_project_migration?
end
def external_import?
@@ -1366,10 +1416,6 @@ class Project < ApplicationRecord
Gitlab::UrlSanitizer.new(import_url).masked_url
end
- def bare_repository_import?
- import_type == 'bare_repository'
- end
-
def jira_import?
import_type == 'jira' && latest_jira_import.present?
end
@@ -1545,7 +1591,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)
@@ -1556,7 +1602,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)
@@ -1590,7 +1636,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?
@@ -1630,9 +1676,7 @@ class Project < ApplicationRecord
end
def disabled_integrations
- disabled_integrations = []
- disabled_integrations << 'apple_app_store' unless Feature.enabled?(:apple_app_store_integration, self)
- disabled_integrations
+ %w[shimo zentao]
end
def find_or_initialize_integration(name)
@@ -1835,11 +1879,11 @@ class Project < ApplicationRecord
repository.update!(shard_name: repository_storage, disk_path: disk_path)
end
- def create_repository(force: false)
+ def create_repository(force: false, default_branch: nil)
# Forked import is handled asynchronously
return if forked? && !force
- repository.create_repository
+ repository.create_repository(default_branch)
repository.after_create
true
@@ -1935,19 +1979,6 @@ class Project < ApplicationRecord
create_repository(force: true) unless repository_exists?
end
- # update visibility_level of forks
- def update_forks_visibility_level
- return if unlink_forks_upon_visibility_decrease_enabled?
- return unless visibility_level < visibility_level_before_last_save
-
- forks.each do |forked_project|
- if forked_project.visibility_level > visibility_level
- forked_project.visibility_level = visibility_level
- forked_project.save!
- end
- end
- end
-
def allowed_to_share_with_group?
!namespace.share_with_group_lock
end
@@ -2039,7 +2070,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
@@ -2080,7 +2111,11 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def open_merge_requests_count(_current_user = nil)
- Projects::OpenMergeRequestsCountService.new(self).count
+ BatchLoader.for(self).batch do |projects, loader|
+ ::Projects::BatchOpenMergeRequestsCountService.new(projects)
+ .refresh_cache_and_retrieve_data
+ .each { |project, count| loader.call(project, count) }
+ end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -2107,23 +2142,13 @@ class Project < ApplicationRecord
ensure_runners_token!
end
- override :format_runners_token
- def format_runners_token(token)
- "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
- end
-
def pages_deployed?
pages_metadatum&.deployed?
end
- def pages_namespace_url
- # The host in URL always needs to be downcased
- Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
- "#{prefix}#{pages_subdomain}."
- end.downcase
- end
+ def pages_url(with_unique_domain: false)
+ return pages_unique_url if with_unique_domain && pages_unique_domain_enabled?
- def pages_url
url = pages_namespace_url
url_path = full_path.partition('/').last
namespace_url = "#{Settings.pages.protocol}://#{url_path}".downcase
@@ -2141,6 +2166,18 @@ class Project < ApplicationRecord
"#{url}/#{url_path}"
end
+ def pages_unique_url
+ 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
+
def pages_subdomain
full_path.partition('/').first
end
@@ -2214,7 +2251,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
@@ -2376,6 +2413,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)
@@ -2396,6 +2435,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
@@ -2809,15 +2849,15 @@ class Project < ApplicationRecord
end
def all_protected_branches
- if Feature.enabled?(:group_protected_branches)
+ if allow_protected_branches_for_group?
@all_protected_branches ||= ProtectedBranch.from_union([protected_branches, group_protected_branches])
else
protected_branches
end
end
- def self_monitoring?
- Gitlab::CurrentSettings.self_monitoring_project_id == id
+ def allow_protected_branches_for_group?
+ Feature.enabled?(:group_protected_branches, group) || Feature.enabled?(:allow_protected_branches_for_group, group)
end
def deploy_token_create_url(opts = {})
@@ -2868,11 +2908,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
@@ -2918,6 +2958,12 @@ class Project < ApplicationRecord
).exists?
end
+ def has_namespaced_npm_packages?
+ packages.with_npm_scope(root_namespace.path)
+ .not_pending_destruction
+ .exists?
+ end
+
def default_branch_or_main
return default_branch if default_branch
@@ -2971,7 +3017,7 @@ class Project < ApplicationRecord
end
def ci_inbound_job_token_scope_enabled?
- return false unless ci_cd_settings
+ return true unless ci_cd_settings
ci_cd_settings.inbound_job_token_scope_enabled?
end
@@ -3060,6 +3106,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
@@ -3119,8 +3169,31 @@ class Project < ApplicationRecord
false
end
+ def crm_enabled?
+ return false unless group
+
+ group.crm_enabled?
+ end
+
+ def frozen_outbound_job_token_scopes?
+ Feature.enabled?(:frozen_outbound_job_token_scopes, self) && Feature.disabled?(:frozen_outbound_job_token_scopes_override, self)
+ end
+ strong_memoize_attr :frozen_outbound_job_token_scopes?
+
private
+ def pages_unique_domain_enabled?
+ Feature.enabled?(:pages_unique_domain, self) &&
+ project_setting.pages_unique_domain_enabled?
+ end
+
+ def pages_url_for(domain)
+ # The host in URL always needs to be downcased
+ Gitlab.config.pages.url.sub(%r{^https?://}) do |prefix|
+ "#{prefix}#{domain}."
+ end.downcase
+ end
+
# overridden in EE
def project_group_links_with_preload
project_group_links
@@ -3224,6 +3297,8 @@ class Project < ApplicationRecord
case from
when Project
namespace_id != from.namespace_id
+ when Namespaces::ProjectNamespace
+ namespace_id != from.parent_id
when Namespace
namespace != from
when User
@@ -3233,9 +3308,14 @@ class Project < ApplicationRecord
# Check if a reference is being done cross-project
def cross_project_reference?(from)
- return true if from.is_a?(Namespace)
-
- from && self != from
+ case from
+ when Namespaces::ProjectNamespace
+ project_namespace_id != from.id
+ when Namespace
+ true
+ else
+ from && self != from
+ end
end
def update_project_statistics
@@ -3401,6 +3481,10 @@ class Project < ApplicationRecord
project_setting.emails_enabled = !emails_disabled
end
end
+
+ def runners_token_prefix
+ RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ end
end
Project.prepend_mod_with('Project')
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index 8741a341ad3..aa65f27870d 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -2,6 +2,7 @@
class ProjectCiCdSetting < ApplicationRecord
include ChronicDurationAttribute
+ include IgnorableColumns
belongs_to :project, inverse_of: :ci_cd_settings
@@ -20,12 +21,10 @@ class ProjectCiCdSetting < ApplicationRecord
attribute :forward_deployment_enabled, default: true
attribute :separated_caches, default: true
- default_value_for :inbound_job_token_scope_enabled do |settings|
- Feature.enabled?(:ci_inbound_job_token_scope, settings.project)
- end
-
chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval
+ ignore_column :opt_in_jwt, remove_with: '16.2', remove_after: '2023-07-01'
+
def keep_latest_artifacts_available?
# The project level feature can only be enabled when the feature is enabled instance wide
Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact? && keep_latest_artifact?
diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb
index b0da586988a..8d3d45715ca 100644
--- a/app/models/project_custom_attribute.rb
+++ b/app/models/project_custom_attribute.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ProjectCustomAttribute < ApplicationRecord
+ include EachBatch
+
belongs_to :project
validates :project, :key, :value, presence: true
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 23b0665cb74..772a82fa173 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -163,6 +163,12 @@ class ProjectFeature < ApplicationRecord
end
end
+ def public_packages?
+ return false unless Gitlab.config.packages.enabled
+
+ package_registry_access_level == PUBLIC || project.public?
+ end
+
private
def set_pages_access_level
@@ -200,7 +206,7 @@ class ProjectFeature < ApplicationRecord
override :resource_member?
def resource_member?(user, feature)
- project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
+ project.member?(user, ProjectFeature.required_minimum_access_level(feature))
end
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 db86bb5e1fb..1256ef0f2fc 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -10,6 +10,27 @@ class ProjectSetting < ApplicationRecord
scope :for_projects, ->(projects) { where(project_id: projects) }
+ attr_encrypted :cube_api_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
+ attr_encrypted :jitsu_administrator_password,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
+ attr_encrypted :product_analytics_clickhouse_connection_string,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
enum squash_option: {
never: 0,
always: 1,
@@ -25,6 +46,10 @@ class ProjectSetting < ApplicationRecord
validates :target_platforms, inclusion: { in: ALLOWED_TARGET_PLATFORMS }
validates :suggested_reviewers_enabled, inclusion: { in: [true, false] }
+ validates :pages_unique_domain,
+ uniqueness: { if: -> { pages_unique_domain.present? } },
+ presence: { if: :require_unique_domain? }
+
validate :validates_mr_default_target_self
attribute :legacy_open_source_license_available, default: -> do
@@ -61,6 +86,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
@@ -68,6 +97,11 @@ class ProjectSetting < ApplicationRecord
errors.add :mr_default_target_self, _('This setting is allowed for forked projects only')
end
end
+
+ def require_unique_domain?
+ pages_unique_domain_enabled ||
+ pages_unique_domain_in_database.present?
+ end
end
ProjectSetting.prepend_mod
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 a93aea55781..c7f5132fbc7 100644
--- a/app/models/projects/data_transfer.rb
+++ b/app/models/projects/data_transfer.rb
@@ -4,12 +4,28 @@
# This class ensures that we keep 1 record per project per month.
module Projects
class DataTransfer < ApplicationRecord
+ include AfterCommitQueue
+ include CounterAttribute
+
self.table_name = 'project_data_transfers'
belongs_to :project
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
+ counter_attribute :packages_egress, returns_current: true
+ counter_attribute :registry_egress, returns_current: true
def self.beginning_of_month(time = Time.current)
time.utc.beginning_of_month
diff --git a/app/models/projects/forks/divergence_counts.rb b/app/models/projects/forks/details.rb
index 7d630b00083..9e09ef09022 100644
--- a/app/models/projects/forks/divergence_counts.rb
+++ b/app/models/projects/forks/details.rb
@@ -3,8 +3,11 @@
module Projects
module Forks
# Class for calculating the divergence of a fork with the source project
- class DivergenceCounts
+ class Details
+ include Gitlab::Utils::StrongMemoize
+
LATEST_COMMITS_COUNT = 10
+ LEASE_TIMEOUT = 15.minutes.to_i
EXPIRATION_TIME = 8.hours
def initialize(project, ref)
@@ -20,32 +23,55 @@ module Projects
{ ahead: ahead, behind: behind }
end
+ def exclusive_lease
+ key = ['project_details', project.id, ref].join(':')
+ uuid = Gitlab::ExclusiveLease.get_uuid(key)
+
+ Gitlab::ExclusiveLease.new(key, uuid: uuid, timeout: LEASE_TIMEOUT)
+ end
+ strong_memoize_attr :exclusive_lease
+
+ def syncing?
+ exclusive_lease.exists?
+ end
+
+ def has_conflicts?
+ !(attrs && attrs[:has_conflicts]).nil?
+ end
+
+ def update!(params)
+ Rails.cache.write(cache_key, params, expires_in: EXPIRATION_TIME)
+
+ @attrs = nil
+ end
+
private
attr_reader :project, :fork_repo, :source_repo, :ref
def cache_key
- @cache_key ||= ['project_forks', project.id, ref, 'divergence_counts']
+ @cache_key ||= ['project_fork_details', project.id, ref].join(':')
end
def divergence_counts
- fork_sha = fork_repo.commit(ref).sha
- source_sha = source_repo.commit.sha
+ sha = fork_repo.commit(ref)&.sha
+ source_sha = source_repo.commit&.sha
- cached_source_sha, cached_fork_sha, counts = Rails.cache.read(cache_key)
- return counts if cached_source_sha == source_sha && cached_fork_sha == fork_sha
+ return if sha.blank? || source_sha.blank?
- counts = calculate_divergence_counts(fork_sha, source_sha)
+ return attrs[:counts] if attrs.present? && attrs[:source_sha] == source_sha && attrs[:sha] == sha
- Rails.cache.write(cache_key, [source_sha, fork_sha, counts], expires_in: EXPIRATION_TIME)
+ counts = calculate_divergence_counts(sha, source_sha)
+
+ update!({ sha: sha, source_sha: source_sha, counts: counts })
counts
end
- def calculate_divergence_counts(fork_sha, source_sha)
+ def calculate_divergence_counts(sha, source_sha)
# If the upstream latest commit exists in the fork repo, then
# it's possible to calculate divergence counts within the fork repository.
- return fork_repo.diverging_commit_count(fork_sha, source_sha) if fork_repo.commit(source_sha)
+ return fork_repo.diverging_commit_count(sha, source_sha) if fork_repo.commit(source_sha)
# Otherwise, we need to find a commit that exists both in the fork and upstream
# in order to use this commit as a base for calculating divergence counts.
@@ -67,6 +93,10 @@ module Projects
[ahead, behind]
end
+
+ def attrs
+ @attrs ||= Rails.cache.read(cache_key)
+ end
end
end
end
diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb
index 9bdf10d7c0e..2771c5131b2 100644
--- a/app/models/projects/import_export/relation_export.rb
+++ b/app/models/projects/import_export/relation_export.rb
@@ -51,12 +51,16 @@ module Projects
transition queued: :started
end
+ event :retry do
+ transition started: :queued
+ end
+
event :finish do
transition started: :finished
end
event :fail_op do
- transition [:queued, :started] => :failed
+ transition [:queued, :started, :failed] => :failed
end
end
@@ -65,6 +69,14 @@ module Projects
project_tree_relation_names + EXTRA_RELATION_LIST
end
+
+ def mark_as_failed(export_error)
+ sanitized_error = Gitlab::UrlSanitizer.sanitize(export_error)
+
+ fail_op
+
+ update_column(:export_error, sanitized_error)
+ end
end
end
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index b3331b99a6b..09a0cfc91dc 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -4,10 +4,13 @@ class ProtectedBranch < ApplicationRecord
include ProtectedRef
include Gitlab::SQL::Pattern
include FromUnion
+ include EachBatch
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) }
@@ -26,7 +29,7 @@ class ProtectedBranch < ApplicationRecord
# Maintainers, owners and admins are allowed to create the default branch
if project.empty_repo? && project.default_branch_protected?
- return true if user.admin? || project.team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
+ return true if user.admin? || user.can?(:admin_project, project)
end
super
@@ -37,38 +40,13 @@ class ProtectedBranch < ApplicationRecord
return true if project.empty_repo? && project.default_branch_protected?
return false if ref_name.blank?
- dry_run = Feature.disabled?(:rely_on_protected_branches_cache, project)
-
- new_cache_result = new_cache(project, ref_name, dry_run: dry_run)
-
- return new_cache_result unless new_cache_result.nil?
-
- deprecated_cache(project, ref_name)
- end
-
- def self.new_cache(project, ref_name, dry_run: true)
- ProtectedBranches::CacheService.new(project).fetch(ref_name, dry_run: dry_run) do # rubocop: disable CodeReuse/ServiceClass
- self.matching(ref_name, protected_refs: protected_refs(project)).present?
- end
- end
-
- # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/370608
- # ----------------------------------------------------------------
- CACHE_EXPIRE_IN = 1.hour
-
- def self.deprecated_cache(project, ref_name)
- Rails.cache.fetch(protected_ref_cache_key(project, ref_name), expires_in: CACHE_EXPIRE_IN) do
+ ProtectedBranches::CacheService.new(project).fetch(ref_name) do # rubocop: disable CodeReuse/ServiceClass
self.matching(ref_name, protected_refs: protected_refs(project)).present?
end
end
- def self.protected_ref_cache_key(project, ref_name)
- "protected_ref-#{project.cache_key}-#{Digest::SHA1.hexdigest(ref_name)}"
- end
- # End of deprecation --------------------------------------------
-
def self.allow_force_push?(project, ref_name)
- if Feature.enabled?(:group_protected_branches)
+ 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)
@@ -83,6 +61,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|
@@ -92,11 +74,7 @@ class ProtectedBranch < ApplicationRecord
end
def self.protected_refs(project)
- if Feature.enabled?(:group_protected_branches)
- project.all_protected_branches
- else
- project.protected_branches
- end
+ project.all_protected_branches
end
# overridden in EE
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 66fe57be25f..c86ca5723fa 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -21,6 +21,12 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord
end
end
+ def humanize
+ return "Deploy key" if deploy_key.present?
+
+ super
+ end
+
def check_access(user)
if user && deploy_key.present?
return user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user)
diff --git a/app/models/protected_ref/access_level.rb b/app/models/protected_ref/access_level.rb
new file mode 100644
index 00000000000..ffd3b480b70
--- /dev/null
+++ b/app/models/protected_ref/access_level.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ProtectedRef
+ class AccessLevel
+ extend ProtectedRefAccess::ClassMethods
+ end
+end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index e89cb3aabc7..5d215a364b7 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -2,6 +2,7 @@
class ProtectedTag < ApplicationRecord
include ProtectedRef
+ include EachBatch
validates :name, uniqueness: { scope: :project_id }
validates :project, presence: true
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index abb233d3800..5837f3a5afb 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -12,35 +12,39 @@ class ProtectedTag::CreateAccessLevel < ApplicationRecord
validate :validate_deploy_key_membership
def type
- if deploy_key.present?
- :deploy_key
- else
- super
- end
- end
+ return :deploy_key if deploy_key.present?
- def check_access(user)
- return false if access_level == Gitlab::Access::NO_ACCESS
+ super
+ end
- if user && deploy_key.present?
- return user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user)
- end
+ def humanize
+ return "Deploy key" if deploy_key.present?
super
end
+ def check_access(current_user)
+ super do
+ break enabled_deploy_key_for_user?(current_user) if deploy_key?
+ end
+ end
+
private
+ def deploy_key?
+ type == :deploy_key
+ end
+
def validate_deploy_key_membership
return unless deploy_key
-
return if project.deploy_keys_projects.where(deploy_key: deploy_key).exists?
errors.add(:deploy_key, 'is not enabled for this project')
end
- def enabled_deploy_key_for_user?(deploy_key, user)
- deploy_key.user_id == user.id &&
+ def enabled_deploy_key_for_user?(current_user)
+ current_user.can?(:read_project, project) &&
+ deploy_key.user_id == current_user.id &&
DeployKey.with_write_access_for_project(protected_tag.project, deploy_key: deploy_key).any?
end
end
diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb
index e02486fbc5b..67d765a15c0 100644
--- a/app/models/releases/link.rb
+++ b/app/models/releases/link.rb
@@ -37,15 +37,9 @@ module Releases
url.start_with?(release.project.web_url)
end
- # `external?` is deprecated in 15.9 and will be removed in 16.0.
- def external?
- !internal?
- end
-
def hook_attrs
{
id: id,
- external: external?, # `external` is deprecated in 15.9 and will be removed in 16.0.
link_type: link_type,
name: name,
url: url
diff --git a/app/models/repository.rb b/app/models/repository.rb
index f951418c0bf..e942157993b 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -48,7 +48,7 @@ class Repository
# For example, for entry `:commit_count` there's a method called `commit_count` which
# stores its data in the `commit_count` cache key.
CACHED_METHODS = %i(size commit_count readme_path contribution_guide
- changelog license_blob license_licensee license_gitaly gitignore
+ changelog license_blob license_gitaly gitignore
gitlab_ci_yml branch_names tag_names branch_count
tag_count avatar exists? root_ref merged_branch_names
has_visible_content? issue_template_names_hash merge_request_template_names_hash
@@ -60,7 +60,7 @@ class Repository
METHOD_CACHES_FOR_FILE_TYPES = {
readme: %i(readme_path),
changelog: :changelog,
- license: %i(license_blob license_licensee license_gitaly),
+ license: %i(license_blob license_gitaly),
contributing: :contribution_guide,
gitignore: :gitignore,
gitlab_ci: :gitlab_ci_yml,
@@ -161,7 +161,8 @@ class Repository
first_parent: !!opts[:first_parent],
order: opts[:order],
literal_pathspec: opts.fetch(:literal_pathspec, true),
- trailers: opts[:trailers]
+ trailers: opts[:trailers],
+ include_referenced_by: opts[:include_referenced_by]
}
commits = Gitlab::Git::Commit.where(options)
@@ -198,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|
@@ -655,24 +656,13 @@ class Repository
end
def license
- if Feature.enabled?(:license_from_gitaly)
- license_gitaly
- else
- license_licensee
- end
+ license_gitaly
end
- def license_licensee
- return unless exists?
-
- raw_repository.license(false)
- end
- cache_method :license_licensee
-
def license_gitaly
return unless exists?
- raw_repository.license(true)
+ raw_repository.license
end
cache_method :license_gitaly
@@ -720,8 +710,6 @@ class Repository
if last_commit
blob_at(last_commit.sha, path)
- else
- nil
end
end
@@ -844,6 +832,26 @@ class Repository
commit_files(user, **options)
end
+ def move_dir_files(user, path, previous_path, **options)
+ regex = Regexp.new("^#{Regexp.escape(previous_path + '/')}", 'i')
+ files = ls_files(options[:branch_name])
+
+ options[:actions] = files.each_with_object([]) do |item, list|
+ next unless item =~ regex
+
+ list.push(
+ action: :move,
+ file_path: "#{path}/#{item[regex.match(item)[0].size..]}",
+ previous_path: item,
+ infer_content: true
+ )
+ end
+
+ return if options[:actions].blank?
+
+ commit_files(user, **options)
+ end
+
def delete_file(user, path, **options)
options[:actions] = [{ action: :delete, file_path: path }]
@@ -948,19 +956,19 @@ class Repository
end
def merged_to_root_ref?(branch_or_name)
+ return unless head_commit
+
branch = Gitlab::Git::Branch.find(self, branch_or_name)
if branch
same_head = branch.target == root_ref_sha
merged = ancestor?(branch.target, root_ref_sha)
!same_head && merged
- else
- nil
end
end
def root_ref_sha
- @root_ref_sha ||= commit(root_ref).sha
+ @root_ref_sha ||= head_commit.sha
end
# If this method is not provided a set of branch names to check merge status,
diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb
new file mode 100644
index 00000000000..2cddfc393e3
--- /dev/null
+++ b/app/models/resource_events/abuse_report_event.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ class AbuseReportEvent < ApplicationRecord
+ belongs_to :abuse_report, optional: false
+ belongs_to :user
+
+ validates :action, presence: true
+
+ enum action: {
+ ban_user: 1,
+ block_user: 2,
+ delete_user: 3,
+ close_report: 4,
+ ban_user_and_close_report: 5,
+ block_user_and_close_report: 6,
+ delete_user_and_close_report: 7
+ }
+
+ enum reason: {
+ spam: 1,
+ offensive: 2,
+ phishing: 3,
+ crypto: 4,
+ credentials: 5,
+ copyright: 6,
+ malware: 7,
+ other: 8,
+ unconfirmed: 9
+ }
+ end
+end
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..393e2aa8942
--- /dev/null
+++ b/app/models/resource_events/issue_assignment_event.rb
@@ -0,0 +1,18 @@
+# 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 }
+
+ def self.issuable_id_column
+ :issue_id
+ end
+ 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..778b9101858
--- /dev/null
+++ b/app/models/resource_events/merge_request_assignment_event.rb
@@ -0,0 +1,18 @@
+# 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 }
+
+ def self.issuable_id_column
+ :merge_request_id
+ end
+ end
+end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index efffc1bd6dc..13610d37a74 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -29,9 +29,8 @@ class ResourceLabelEvent < ResourceEvent
labels = events.map(&:label).compact
project_labels, group_labels = labels.partition { |label| label.is_a? ProjectLabel }
- preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(project_labels, { project: :project_feature })
- preloader.preload(group_labels, :group)
+ ActiveRecord::Associations::Preloader.new(records: project_labels, associations: { project: :project_feature }).call
+ ActiveRecord::Associations::Preloader.new(records: group_labels, associations: :group).call
end
def issuable
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index def7e91af3f..d305a4ace51 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class ResourceMilestoneEvent < ResourceTimeboxEvent
- include IgnorableColumns
-
belongs_to :milestone
scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
@@ -10,8 +8,6 @@ class ResourceMilestoneEvent < ResourceTimeboxEvent
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states)
- ignore_columns %i[reference reference_html cached_markdown_version], remove_with: '13.1', remove_after: '2020-06-22'
-
def milestone_title
milestone&.title
end
@@ -24,3 +20,5 @@ class ResourceMilestoneEvent < ResourceTimeboxEvent
MilestoneNote
end
end
+
+ResourceMilestoneEvent.prepend_mod
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 1a0a65df6a3..580e4cd277c 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
@@ -105,9 +103,18 @@ class SentNotification < ApplicationRecord
self.reply_key
end
- def create_reply(message, dryrun: false)
+ def create_reply(message, external_author = nil, dryrun: false)
klass = dryrun ? Notes::BuildService : Notes::CreateService
- klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute
+ params = reply_params.merge(
+ note: message
+ )
+
+ params[:external_author] = external_author if external_author.present?
+
+ klass.new(self.project,
+ self.recipient,
+ params
+ ).execute
end
private
diff --git a/app/models/serverless/domain.rb b/app/models/serverless/domain.rb
deleted file mode 100644
index 164f93afa9a..00000000000
--- a/app/models/serverless/domain.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class Domain
- include ActiveModel::Model
-
- REGEXP = %r{^(?<scheme>https?://)?(?<function_name>[^.]+)-(?<cluster_left>\h{2})a1(?<cluster_middle>\h{10})f2(?<cluster_right>\h{2})(?<environment_id>\h+)-(?<environment_slug>[^.]+)\.(?<pages_domain_name>.+)}.freeze
- UUID_LENGTH = 14
-
- attr_accessor :function_name, :serverless_domain_cluster, :environment
-
- validates :function_name, presence: true, allow_blank: false
- validates :serverless_domain_cluster, presence: true
- validates :environment, presence: true
-
- def self.generate_uuid
- SecureRandom.hex(UUID_LENGTH / 2)
- end
-
- def uri
- URI("https://#{function_name}-#{serverless_domain_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{serverless_domain_cluster.domain}")
- end
-
- def knative_uri
- URI("http://#{function_name}.#{namespace}.#{serverless_domain_cluster.knative.hostname}")
- end
-
- private
-
- def namespace
- serverless_domain_cluster.cluster.kubernetes_namespace_for(environment)
- end
-
- def serverless_domain_cluster_uuid
- [
- serverless_domain_cluster.uuid[0..1],
- 'a1',
- serverless_domain_cluster.uuid[2..-3],
- 'f2',
- serverless_domain_cluster.uuid[-2..]
- ].join
- end
- end
-end
diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb
deleted file mode 100644
index 561bfc65b2b..00000000000
--- a/app/models/serverless/domain_cluster.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class DomainCluster < ApplicationRecord
- self.table_name = 'serverless_domain_cluster'
-
- HEX_REGEXP = %r{\A\h+\z}.freeze
-
- belongs_to :pages_domain
- belongs_to :knative, class_name: 'Clusters::Applications::Knative', foreign_key: 'clusters_applications_knative_id'
- belongs_to :creator, class_name: 'User', optional: true
-
- attr_encrypted :key,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm'
-
- validates :pages_domain, :knative, presence: true
- validates :uuid, presence: true, uniqueness: true, length: { is: ::Serverless::Domain::UUID_LENGTH },
- format: { with: HEX_REGEXP, message: 'only allows hex characters' }
-
- after_initialize :set_uuid, if: :new_record?
-
- delegate :domain, to: :pages_domain
- delegate :cluster, to: :knative
-
- def self.for_uuid(uuid)
- joins(:pages_domain, :knative)
- .includes(:pages_domain, :knative)
- .find_by(uuid: uuid)
- end
-
- private
-
- def set_uuid
- self.uuid = ::Serverless::Domain.generate_uuid
- end
- end
-end
diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb
deleted file mode 100644
index 5d4f8e0c9e2..00000000000
--- a/app/models/serverless/function.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class Function
- attr_accessor :name, :namespace
-
- def initialize(project, name, namespace)
- @project = project
- @name = name
- @namespace = namespace
- end
-
- def id
- @project.id.to_s + "/" + @name + "/" + @namespace
- end
-
- def self.find_by_id(id)
- array = id.split("/")
- project = Project.find_by_id(array[0])
- name = array[1]
- namespace = array[2]
-
- self.new(project, name, namespace)
- end
- end
-end
diff --git a/app/models/serverless/lookup_path.rb b/app/models/serverless/lookup_path.rb
deleted file mode 100644
index c09b3718651..00000000000
--- a/app/models/serverless/lookup_path.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class LookupPath
- attr_reader :serverless_domain
-
- delegate :serverless_domain_cluster, to: :serverless_domain
- delegate :knative, to: :serverless_domain_cluster
- delegate :certificate, to: :serverless_domain_cluster
- delegate :key, to: :serverless_domain_cluster
-
- def initialize(serverless_domain)
- @serverless_domain = serverless_domain
- end
-
- def source
- {
- type: 'serverless',
- service: serverless_domain.knative_uri.host,
- cluster: {
- hostname: knative.hostname,
- address: knative.external_ip,
- port: 443,
- cert: certificate,
- key: key
- }
- }
- end
- end
-end
diff --git a/app/models/serverless/virtual_domain.rb b/app/models/serverless/virtual_domain.rb
deleted file mode 100644
index d6a23a4c0ce..00000000000
--- a/app/models/serverless/virtual_domain.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class VirtualDomain
- attr_reader :serverless_domain
-
- delegate :serverless_domain_cluster, to: :serverless_domain
- delegate :pages_domain, to: :serverless_domain_cluster
- delegate :certificate, to: :pages_domain
- delegate :key, to: :pages_domain
-
- def initialize(serverless_domain)
- @serverless_domain = serverless_domain
- end
-
- def lookup_paths
- [
- ::Serverless::LookupPath.new(serverless_domain)
- ]
- end
- end
-end
diff --git a/app/models/airflow.rb b/app/models/service_desk.rb
index 2e5642a2639..cb9c924c01f 100644
--- a/app/models/airflow.rb
+++ b/app/models/service_desk.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
-module Airflow
+
+module ServiceDesk
def self.table_name_prefix
- 'airflow_'
+ 'service_desk_'
end
end
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/custom_email_verification.rb b/app/models/service_desk/custom_email_verification.rb
new file mode 100644
index 00000000000..482a10447ed
--- /dev/null
+++ b/app/models/service_desk/custom_email_verification.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ class CustomEmailVerification < ApplicationRecord
+ TIMEFRAME = 30.minutes
+ STATES = { started: 0, finished: 1, failed: 2 }.freeze
+
+ enum error: {
+ incorrect_token: 0,
+ incorrect_from: 1,
+ mail_not_received_within_timeframe: 2,
+ invalid_credentials: 3,
+ smtp_host_issue: 4
+ }
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: false,
+ encode_iv: false
+
+ belongs_to :project
+ belongs_to :triggerer, class_name: 'User', optional: true
+
+ validates :project, presence: true
+ validates :state, presence: true
+
+ delegate :service_desk_setting, to: :project
+
+ state_machine :state do
+ state :started do
+ validates :token, presence: true, length: { is: 12 }
+ validates :triggerer, presence: true
+ validates :triggered_at, presence: true
+ validates :error, absence: true
+ end
+
+ state :finished do
+ validates :token, absence: true
+ validates :error, absence: true
+ end
+
+ state :failed do
+ validates :token, absence: true
+ validates :error, presence: true
+ end
+
+ event :mark_as_started do
+ transition all => :started
+ end
+
+ event :mark_as_finished do
+ transition started: :finished
+ end
+
+ event :mark_as_failed do
+ transition all => :failed
+ end
+
+ before_transition any => :started do |verification, transition|
+ triggerer = transition.args.first
+
+ verification.triggerer = triggerer
+ verification.token = verification.class.generate_token
+ verification.triggered_at = Time.current
+ verification.error = nil
+ end
+
+ before_transition started: :finished do |verification|
+ verification.token = nil
+ end
+
+ before_transition started: :failed do |verification, transition|
+ error = transition.args.first
+
+ verification.error = error
+ verification.token = nil
+ end
+
+ # Supress warning:
+ # both enum and its state_machine have defined a different default for "state".
+ # State machine uses `nil` and the enum should use the same.
+ def owner_class_attribute_default
+ nil
+ end
+ end
+
+ # Needs to be below `state_machine` definition to suppress
+ # its method override warnings
+ enum state: STATES
+
+ class << self
+ def generate_token
+ SecureRandom.alphanumeric(12)
+ end
+ end
+
+ def accepted_until
+ return unless started?
+ return unless triggered_at.present?
+
+ TIMEFRAME.since(triggered_at)
+ end
+
+ def in_timeframe?
+ return false unless started?
+
+ !!accepted_until&.future?
+ end
+ end
+end
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index 5152746abb4..4216ad7e70f 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -2,59 +2,59 @@
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
+
validates :project_id, presence: true
validate :valid_issue_template
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: :custom_email_enabled?
- validates :custom_email_smtp_address,
- presence: true,
- hostname: { allow_numeric_hostname: true, require_valid_tld: true },
- if: :custom_email_enabled?
- validates :custom_email_smtp_username,
- presence: true,
- if: :custom_email_enabled?
- validates :custom_email_smtp_port,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 },
- if: :custom_email_enabled?
+ presence: true,
+ devise_email: true,
+ if: :needs_custom_email_credentials?
scope :with_project_key, ->(key) { where(project_key: key) }
- 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_credential
+ project&.service_desk_custom_email_credential
+ end
+
+ def custom_email_verification
+ project&.service_desk_custom_email_verification
+ end
+
+ def custom_email_address_for_verification
+ return unless custom_email.present?
+
+ custom_email.sub("@", "#{CUSTOM_EMAIL_VERIFICATION_SUBADDRESS}@")
end
def issue_template_content
@@ -102,6 +102,10 @@ class ServiceDeskSetting < ApplicationRecord
setting.project.full_path_slug == project_slug
end
end
+
+ def needs_custom_email_credentials?
+ custom_email_enabled? || custom_email_verification.present?
+ end
end
ServiceDeskSetting.prepend_mod
diff --git a/app/models/slack_integration.rb b/app/models/slack_integration.rb
new file mode 100644
index 00000000000..22e911aeacd
--- /dev/null
+++ b/app/models/slack_integration.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+class SlackIntegration < ApplicationRecord
+ include EachBatch
+
+ ALL_FEATURES = %i[commands notifications].freeze
+
+ SCOPE_COMMANDS = 'commands'
+ SCOPE_CHAT_WRITE = 'chat:write'
+ SCOPE_CHAT_WRITE_PUBLIC = 'chat:write.public'
+
+ # These scopes are requested when installing the app, additional scopes
+ # will need reauthorization.
+ # https://api.slack.com/authentication/oauth-v2#asking
+ SCOPES = [SCOPE_COMMANDS, SCOPE_CHAT_WRITE, SCOPE_CHAT_WRITE_PUBLIC].freeze
+
+ belongs_to :integration
+
+ attr_encrypted :bot_access_token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
+ has_many :slack_integrations_scopes,
+ class_name: '::Integrations::SlackWorkspace::IntegrationApiScope'
+
+ has_many :slack_api_scopes,
+ class_name: '::Integrations::SlackWorkspace::ApiScope',
+ through: :slack_integrations_scopes
+
+ scope :with_bot, -> { where.not(bot_user_id: nil) }
+ scope :by_team, ->(team_id) { where(team_id: team_id) }
+
+ validates :team_id, presence: true
+ validates :team_name, presence: true
+ validates :alias, presence: true,
+ uniqueness: { scope: :team_id, message: 'This alias has already been taken' },
+ length: 2..4096
+ validates :user_id, presence: true
+ validates :integration, presence: true
+
+ after_commit :update_active_status_of_integration, on: [:create, :destroy]
+
+ def update_active_status_of_integration
+ integration.update_active_status
+ end
+
+ def feature_available?(feature_name)
+ case feature_name
+ when :commands
+ # The slash commands feature requires 'commands' scope.
+ # All records will support this scope, as this was the original feature.
+ true
+ when :notifications
+ scoped_to?(SCOPE_CHAT_WRITE, SCOPE_CHAT_WRITE_PUBLIC)
+ else
+ false
+ end
+ end
+
+ def upgrade_needed?
+ !all_features_supported?
+ end
+
+ def all_features_supported?
+ ALL_FEATURES.all? { |feature| feature_available?(feature) } # rubocop: disable Gitlab/FeatureAvailableUsage
+ end
+
+ def authorized_scope_names=(names)
+ names = Array.wrap(names).flat_map { |name| name.split(',') }.map(&:strip)
+
+ scopes = ::Integrations::SlackWorkspace::ApiScope.find_or_initialize_by_names(names)
+ self.slack_api_scopes = scopes
+ end
+
+ def authorized_scope_names
+ slack_api_scopes.pluck(:name)
+ end
+
+ private
+
+ def scoped_to?(*names)
+ return false if names.empty?
+
+ names.to_set <= all_scopes
+ end
+
+ def all_scopes
+ @all_scopes = authorized_scope_names.to_set
+ end
+end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 9ec685c5580..3c40f4beedc 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -19,6 +19,7 @@ class Snippet < ApplicationRecord
include AfterCommitQueue
extend ::Gitlab::Utils::Override
include CreatedAtFilterable
+ include EachBatch
MAX_FILE_COUNT = 10
@@ -156,7 +157,7 @@ class Snippet < ApplicationRecord
def for_project_with_user(project, user = nil)
return none unless project.snippets_visible?(user)
- if user && project.team.member?(user)
+ if project.member?(user)
project.snippets
else
project.snippets.public_to_user(user)
@@ -183,7 +184,7 @@ class Snippet < ApplicationRecord
end
def link_reference_pattern
- @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
+ @link_reference_pattern ||= compose_link_reference_pattern('snippets', /(?<snippet>\d+)/)
end
def find_by_id_and_project(id:, project:)
@@ -203,14 +204,7 @@ class Snippet < ApplicationRecord
end
def initialize(attributes = {})
- # We can't use default_value_for because the database has a default
- # value of 0 for visibility_level. If someone attempts to create a
- # private snippet, default_value_for will assume that the
- # visibility_level hasn't changed and will use the application
- # setting default, which could be internal or public.
- #
- # To fix the problem, we assign the actual snippet default if no
- # explicit visibility has been initialized.
+ # We assign the actual snippet default if no explicit visibility has been initialized.
attributes ||= {}
unless visibility_attribute_present?(attributes)
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index bb8527d8c01..0e0534d45ae 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -26,8 +26,7 @@ class SystemNoteMetadata < ApplicationRecord
title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date start_date_or_due_date pinned_embed cherry_pick health_status approved unapproved
- status alert_issue_added relate unrelate new_alert_added severity
- attention_requested attention_request_removed contact timeline_event
+ status alert_issue_added relate unrelate new_alert_added severity contact timeline_event
issue_type relate_to_child unrelate_from_child relate_to_parent unrelate_from_parent
].freeze
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..e1b5076e3d8 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -13,6 +13,7 @@ class Todo < ApplicationRecord
# and giving it back again.
WAIT_FOR_DELETE = 1.hour
+ # Actions
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
@@ -76,10 +77,11 @@ 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 }) }
+ scope :with_preloaded_user, -> { preload(:user) }
enum resolved_by_action: { system_done: 0, api_all_done: 1, api_done: 2, mark_all_done: 3, mark_done: 4 }, _prefix: :resolved_by
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
deleted file mode 100644
index ba6c1ee6af1..00000000000
--- a/app/models/u2f_registration.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-# Registration information for U2F (universal 2nd factor) devices, like Yubikeys
-
-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
-
- begin
- response = U2F::RegisterResponse.load_from_json(params[:device_response])
- registration_data = u2f.register!(challenges, response)
- registration.update(certificate: registration_data.certificate,
- key_handle: registration_data.key_handle,
- public_key: registration_data.public_key,
- counter: registration_data.counter,
- user: user,
- name: params[:name])
- rescue JSON::ParserError, NoMethodError, ArgumentError
- registration.errors.add(:base, _('Your U2F device did not send a valid JSON response.'))
- rescue U2F::Error => e
- registration.errors.add(:base, e.message)
- end
-
- registration
- end
-
- def self.authenticate(user, app_id, json_response, challenges)
- response = U2F::SignResponse.load_from_json(json_response)
- registration = user.u2f_registrations.find_by_key_handle(response.key_handle)
- u2f = U2F::U2F.new(app_id)
-
- if registration
- u2f.authenticate!(challenges, response, Base64.decode64(registration.public_key), registration.counter)
- registration.update(counter: response.counter)
- true
- end
- 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 f3e8f14adf5..dc70ff2e232 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
@@ -28,6 +27,7 @@ class User < ApplicationRecord
include UpdateHighestRole
include HasUserType
include Gitlab::Auth::Otp::Fortinet
+ include Gitlab::Auth::Otp::DuoAuth
include RestrictedSignup
include StripAttribute
include EachBatch
@@ -71,6 +71,7 @@ class User < ApplicationRecord
attribute :notified_of_own_activity, default: false
attribute :preferred_language, default: -> { Gitlab::CurrentSettings.default_preferred_language }
attribute :theme_id, default: -> { gitlab_config.default_theme }
+ attribute :color_scheme_id, default: -> { Gitlab::CurrentSettings.default_syntax_highlighting_theme }
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
@@ -79,14 +80,14 @@ class User < ApplicationRecord
algorithm: 'aes-256-cbc'
devise :two_factor_authenticatable,
- otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
+ otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
devise :two_factor_backupable_pbkdf2
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
- :validatable, :omniauthable, :confirmable, :registerable
+ :validatable, :omniauthable, :confirmable, :registerable
# Must be included after `devise`
include EncryptedUserPassword
@@ -101,8 +102,6 @@ class User < ApplicationRecord
MINIMUM_DAYS_CREATED = 7
- ignore_columns %i[linkedin twitter skype website_url location organization], remove_with: '15.10', remove_after: '2023-02-22'
-
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -133,11 +132,11 @@ class User < ApplicationRecord
# Namespace for personal projects
has_one :namespace,
- -> { where(type: Namespaces::UserNamespace.sti_name) },
- dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
- foreign_key: :owner_id,
- inverse_of: :owner,
- autosave: true # rubocop:disable Cop/ActiveRecordDependent
+ -> { where(type: Namespaces::UserNamespace.sti_name) },
+ dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
+ foreign_key: :owner_id,
+ inverse_of: :owner,
+ autosave: true # rubocop:disable Cop/ActiveRecordDependent
# Profile
has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -150,7 +149,6 @@ class User < ApplicationRecord
has_many :emails
has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
- has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :webauthn_registrations
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :saved_replies, class_name: '::Users::SavedReply'
@@ -173,18 +171,18 @@ class User < ApplicationRecord
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
has_many :owned_or_maintainers_groups,
- -> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
- through: :group_members,
- source: :group
+ -> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
+ through: :group_members,
+ source: :group
alias_attribute :masters_groups, :maintainers_groups
has_many :developer_maintainer_owned_groups,
- -> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
- through: :group_members,
- source: :group
+ -> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
+ through: :group_members,
+ source: :group
has_many :reporter_developer_maintainer_owned_groups,
- -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
- through: :group_members,
- source: :group
+ -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
+ through: :group_members,
+ source: :group
has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, class_name: 'GroupMember'
has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group
@@ -220,6 +218,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
@@ -227,7 +226,9 @@ class User < ApplicationRecord
has_many :notification_settings
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id
+ has_many :audit_events, foreign_key: :author_id, inverse_of: :user
+ has_many :alert_assignees, class_name: '::AlertManagement::AlertAssignee', inverse_of: :assignee
has_many :issue_assignees, inverse_of: :assignee
has_many :merge_request_assignees, inverse_of: :assignee, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_request_reviewers, inverse_of: :reviewer, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -264,6 +265,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
@@ -289,7 +292,7 @@ class User < ApplicationRecord
validate :check_password_weakness, if: :encrypted_password_changed?
validates :namespace, presence: true
- validate :namespace_move_dir_allowed, if: :username_changed?
+ validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record?
validate :unique_email, if: :email_changed?
validate :notification_email_verified, if: :notification_email_changed?
@@ -345,26 +348,28 @@ class User < ApplicationRecord
# User's role
enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
- delegate :notes_filter_for,
- :set_notes_filter,
- :first_day_of_week, :first_day_of_week=,
- :timezone, :timezone=,
- :time_display_relative, :time_display_relative=,
- :time_format_in_24h, :time_format_in_24h=,
- :show_whitespace_in_diffs, :show_whitespace_in_diffs=,
- :view_diffs_file_by_file, :view_diffs_file_by_file=,
- :tab_width, :tab_width=,
- :sourcegraph_enabled, :sourcegraph_enabled=,
- :gitpod_enabled, :gitpod_enabled=,
- :setup_for_company, :setup_for_company=,
- :render_whitespace_in_code, :render_whitespace_in_code=,
- :markdown_surround_selection, :markdown_surround_selection=,
- :markdown_automatic_lists, :markdown_automatic_lists=,
- :diffs_deletion_color, :diffs_deletion_color=,
- :diffs_addition_color, :diffs_addition_color=,
- :use_legacy_web_ide, :use_legacy_web_ide=,
- :use_new_navigation, :use_new_navigation=,
- to: :user_preference
+ delegate :notes_filter_for,
+ :set_notes_filter,
+ :first_day_of_week, :first_day_of_week=,
+ :timezone, :timezone=,
+ :time_display_relative, :time_display_relative=,
+ :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=,
+ :setup_for_company, :setup_for_company=,
+ :render_whitespace_in_code, :render_whitespace_in_code=,
+ :markdown_surround_selection, :markdown_surround_selection=,
+ :markdown_automatic_lists, :markdown_automatic_lists=,
+ :diffs_deletion_color, :diffs_deletion_color=,
+ :diffs_addition_color, :diffs_addition_color=,
+ :use_new_navigation, :use_new_navigation=,
+ :pinned_nav_items, :pinned_nav_items=,
+ :achievements_enabled, :achievements_enabled=,
+ :enabled_following, :enabled_following=,
+ to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
@@ -373,7 +378,6 @@ class User < ApplicationRecord
delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true
delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true
delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true
- delegate :requires_credit_card_verification, :requires_credit_card_verification=, to: :user_detail, allow_nil: true
delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true
delegate :twitter, :twitter=, to: :user_detail, allow_nil: true
delegate :skype, :skype=, to: :user_detail, allow_nil: true
@@ -513,28 +517,27 @@ class User < ApplicationRecord
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
- where('EXISTS (?)',
- ::PersonalAccessToken
- .where('personal_access_tokens.user_id = users.id')
- .without_impersonation
- .expiring_and_not_notified(at).select(1))
+ where('EXISTS (?)', ::PersonalAccessToken
+ .where('personal_access_tokens.user_id = users.id')
+ .without_impersonation
+ .expiring_and_not_notified(at).select(1)
+ )
end
scope :with_personal_access_tokens_expired_today, -> do
- where('EXISTS (?)',
- ::PersonalAccessToken
- .select(1)
- .where('personal_access_tokens.user_id = users.id')
- .without_impersonation
- .expired_today_and_not_notified)
+ where('EXISTS (?)', ::PersonalAccessToken
+ .select(1)
+ .where('personal_access_tokens.user_id = users.id')
+ .without_impersonation
+ .expired_today_and_not_notified
+ )
end
scope :with_ssh_key_expiring_soon, -> do
includes(:expiring_soon_and_unnotified_keys)
- .where('EXISTS (?)',
- ::Key
- .select(1)
- .where('keys.user_id = users.id')
- .expiring_soon_and_not_notified)
+ .where('EXISTS (?)', ::Key
+ .select(1)
+ .where('keys.user_id = users.id')
+ .expiring_soon_and_not_notified)
end
scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) }
scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
@@ -614,13 +617,12 @@ class User < ApplicationRecord
def self.with_two_factor
where(otp_required_for_login: true)
- .or(where_exists(U2fRegistration.where(U2fRegistration.arel_table[:user_id].eq(arel_table[:id]))))
.or(where_exists(WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(arel_table[:id]))))
end
def self.without_two_factor
where
- .missing(:u2f_registrations, :webauthn_registrations)
+ .missing(:webauthn_registrations)
.where(otp_required_for_login: false)
end
@@ -922,6 +924,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}"
@@ -1025,17 +1038,32 @@ class User < ApplicationRecord
password_allowed
end
+ # Override Devise Rememberable#remember_me!
+ #
+ # In Devise this method sets `remember_created_at` and writes the session token
+ # to the session cookie. When remember me is disabled this method ensures these
+ # values aren't set.
def remember_me!
- super if ::Gitlab::Database.read_write?
+ super if ::Gitlab::Database.read_write? && ::Gitlab::CurrentSettings.remember_me_enabled?
end
def forget_me!
super if ::Gitlab::Database.read_write?
end
+ # Override Devise Rememberable#remember_me?
+ #
+ # In Devise this method compares the remember me token received from the user session
+ # and compares to the stored value. When remember me is disabled this method ensures
+ # the upstream comparison does not happen.
+ def remember_me?(token, generated_at)
+ return false unless ::Gitlab::CurrentSettings.remember_me_enabled?
+
+ super
+ end
+
def disable_two_factor!
transaction do
- self.u2f_registrations.destroy_all # rubocop:disable Cop/DestroyAll
self.disable_webauthn!
self.disable_two_factor_otp!
self.reset_backup_codes!
@@ -1062,32 +1090,17 @@ class User < ApplicationRecord
end
def two_factor_enabled?
- two_factor_otp_enabled? || two_factor_webauthn_u2f_enabled?
+ two_factor_otp_enabled? || two_factor_webauthn_enabled?
end
def two_factor_otp_enabled?
otp_required_for_login? ||
forti_authenticator_enabled?(self) ||
- forti_token_cloud_enabled?(self)
- end
-
- def two_factor_u2f_enabled?
- return false if Feature.enabled?(:webauthn)
-
- if u2f_registrations.loaded?
- u2f_registrations.any?
- else
- u2f_registrations.exists?
- end
- end
-
- def two_factor_webauthn_u2f_enabled?
- two_factor_u2f_enabled? || two_factor_webauthn_enabled?
+ forti_token_cloud_enabled?(self) ||
+ duo_auth_enabled?(self)
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
@@ -1646,9 +1659,19 @@ class User < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
+ DELETION_DELAY_IN_DAYS = 7.days
+
def delete_async(deleted_by:, params: {})
- block if params[:hard_delete]
- DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h)
+ is_deleting_own_record = deleted_by.id == id
+
+ if is_deleting_own_record && ::Feature.enabled?(:delay_delete_own_user)
+ block
+ DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h)
+ else
+ block if params[:hard_delete]
+
+ DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h)
+ end
end
# rubocop: disable CodeReuse/ServiceClass
@@ -1693,7 +1716,7 @@ class User < ApplicationRecord
end
def follow(user)
- return false if self.id == user.id
+ return false unless following_users_allowed?(user)
begin
followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id)
@@ -1712,24 +1735,33 @@ class User < ApplicationRecord
end
end
+ def following_users_allowed?(user)
+ return false if self.id == user.id
+
+ following_users_enabled? && user.following_users_enabled?
+ end
+
+ def following_users_enabled?
+ return true unless ::Feature.enabled?(:disable_follow_users, self)
+
+ enabled_following
+ end
+
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
end
def manageable_groups(include_groups_with_developer_maintainer_access: false)
- owned_and_maintainer_group_hierarchy = if Feature.enabled?(:linear_user_manageable_groups, self)
- owned_or_maintainers_groups.self_and_descendants
- else
- Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
- end
+ owned_and_maintainer_group_hierarchy = owned_or_maintainers_groups.self_and_descendants
if include_groups_with_developer_maintainer_access
union_sql = ::Gitlab::SQL::Union.new(
@@ -1988,7 +2020,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)
@@ -2017,9 +2049,11 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_project_ids(project_ids)
- Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Project),
- resource_ids: project_ids,
- default_value: Gitlab::Access::NO_ACCESS) do |project_ids|
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: max_member_access_for_resource_key(Project),
+ resource_ids: project_ids,
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do |project_ids|
project_authorizations.where(project: project_ids)
.group(:project_id)
.maximum(:access_level)
@@ -2034,9 +2068,11 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_group_ids(group_ids)
- Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Group),
- resource_ids: group_ids,
- default_value: Gitlab::Access::NO_ACCESS) do |group_ids|
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: max_member_access_for_resource_key(Group),
+ resource_ids: group_ids,
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do |group_ids|
group_members.where(source: group_ids).group(:source_id).maximum(:access_level)
end
end
@@ -2136,7 +2172,15 @@ class User < ApplicationRecord
end
def confirmation_required_on_sign_in?
- !confirmed? && !confirmation_period_valid?
+ return false if confirmed?
+
+ if ::Gitlab::CurrentSettings.email_confirmation_setting_off?
+ false
+ elsif ::Gitlab::CurrentSettings.email_confirmation_setting_soft?
+ !in_confirmation_period?
+ elsif ::Gitlab::CurrentSettings.email_confirmation_setting_hard?
+ true
+ end
end
def impersonated?
@@ -2206,21 +2250,39 @@ class User < ApplicationRecord
namespace_commit_emails.find_by(namespace: project.root_namespace)
end
+ def spam_score
+ abuse_trust_scores.spamcheck.average(:score) || 0.0
+ end
+
+ def trust_scores_for_source(source)
+ abuse_trust_scores.where(source: source)
+ end
+
+ def abuse_metadata
+ {
+ account_age: account_age_in_days,
+ two_factor_enabled: two_factor_enabled? ? 1 : 0
+ }
+ end
+
protected
# override, from Devise::Validatable
def password_required?
- return false if internal? || project_bot?
+ return false if internal? || project_bot? || security_policy_bot?
super
end
# override from Devise::Confirmable
def confirmation_period_valid?
- return false if Feature.disabled?(:soft_email_confirmation)
+ return super if ::Gitlab::CurrentSettings.email_confirmation_setting_soft?
- super
+ # Following devise logic for method, we want to return `true`
+ # See: https://github.com/heartcombo/devise/blob/main/lib/devise/models/confirmable.rb#L191-L218
+ true
end
+ alias_method :in_confirmation_period?, :confirmation_period_valid?
# This is copied from Devise::Models::TwoFactorAuthenticatable#consume_otp!
#
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index 4ebb8ba9f00..9a186cb9038 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -13,6 +13,8 @@ class UserCustomAttribute < ApplicationRecord
BLOCKED_BY = 'blocked_by'
UNBLOCKED_BY = 'unblocked_by'
+ ARKOSE_RISK_BAND = 'arkose_risk_band'
+ AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id'
class << self
def upsert_custom_attributes(custom_attributes)
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 9d3df3d6400..293a20fcc5a 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
class UserDetail < ApplicationRecord
+ include IgnorableColumns
extend ::Gitlab::Utils::Override
+ ignore_column :requires_credit_card_verification, remove_with: '16.1', remove_after: '2023-06-22'
+
REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze
belongs_to :user
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index bc2c6b526b8..90449411f8a 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -20,17 +20,24 @@ class UserPreference < ApplicationRecord
less_than_or_equal_to: Gitlab::TabWidth::MAX
}
validates :diffs_deletion_color, :diffs_addition_color,
- format: { with: ColorsHelper::HEX_COLOR_PATTERN },
- allow_blank: true
- validates :use_legacy_web_ide, allow_nil: false, inclusion: { in: [true, false] }
+ format: { with: ColorsHelper::HEX_COLOR_PATTERN },
+ allow_blank: true
+
+ 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'
+ ignore_columns :time_format_in_24h, remove_with: '16.2', remove_after: '2023-07-22'
+ # 2023-06-22 is after 16.1 release and during 16.2 release https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#ignoring-the-column-release-m
+ ignore_columns :use_legacy_web_ide, remove_with: '16.2', remove_after: '2023-06-22'
attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT }
attribute :time_display_relative, default: true
- attribute :time_format_in_24h, default: false
attribute :render_whitespace_in_code, default: false
+ enum visibility_pipeline_id_type: { id: 0, iid: 1 }
+
class << self
def notes_filters
{
@@ -88,22 +95,6 @@ class UserPreference < ApplicationRecord
end
end
- def time_format_in_24h
- value = read_attribute(:time_format_in_24h)
- return value unless value.nil?
-
- self.class.column_defaults['time_format_in_24h']
- end
-
- def time_format_in_24h=(value)
- if value.nil?
- default = self.class.column_defaults['time_format_in_24h']
- super(default)
- else
- super(value)
- end
- end
-
def render_whitespace_in_code
value = read_attribute(:render_whitespace_in_code)
return value unless value.nil?
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index 0c66f465356..da24ef47a2a 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -17,7 +17,7 @@ class UserStatus < ApplicationRecord
'30_days' => 30.days
}.freeze
- belongs_to :user
+ belongs_to :user, inverse_of: :status
enum availability: { not_set: 0, busy: 1 }
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 4cd0e3fb828..6b23bce6406 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -14,7 +14,7 @@ class UserSyncedAttributesMetadata < ApplicationRecord
def read_only_attributes
return [] unless sync_profile_from_provider?
- self.class.syncable_attributes.select { |key| synced?(key) }
+ SYNCABLE_ATTRIBUTES.select { |key| synced?(key) }
end
def synced?(attribute)
@@ -26,12 +26,11 @@ class UserSyncedAttributesMetadata < ApplicationRecord
end
class << self
- def syncable_attributes
- if Gitlab.config.ldap.enabled && !Gitlab.config.ldap.sync_name
- SYNCABLE_ATTRIBUTES - %i[name]
- else
- SYNCABLE_ATTRIBUTES
- end
+ def syncable_attributes(provider = nil)
+ return SYNCABLE_ATTRIBUTES unless provider && ldap_provider?(provider)
+ return SYNCABLE_ATTRIBUTES if ldap_sync_name?(provider)
+
+ SYNCABLE_ATTRIBUTES - %i[name]
end
end
@@ -40,4 +39,17 @@ class UserSyncedAttributesMetadata < ApplicationRecord
def sync_profile_from_provider?
Gitlab::Auth::OAuth::Provider.sync_profile_from_provider?(provider)
end
+
+ class << self
+ def ldap_provider?(provider)
+ Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
+ end
+
+ def ldap_sync_name?(provider)
+ return false unless provider
+
+ config = Gitlab::Auth::Ldap::Config.new(provider)
+ config.enabled? && config.sync_name
+ end
+ end
end
diff --git a/app/models/users/banned_user.rb b/app/models/users/banned_user.rb
index 615668e2b55..8a62744c7d6 100644
--- a/app/models/users/banned_user.rb
+++ b/app/models/users/banned_user.rb
@@ -5,8 +5,12 @@ module Users
self.primary_key = :user_id
belongs_to :user
+ has_one :credit_card_validation, class_name: '::Users::CreditCardValidation', primary_key: 'user_id',
+ foreign_key: 'user_id', inverse_of: :banned_user
validates :user, presence: true
validates :user_id, uniqueness: { message: N_("banned user already exists") }
end
end
+
+Users::BannedUser.prepend_mod_with('Users::BannedUser')
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 3f9353214ee..896cccfa0e5 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -43,10 +43,9 @@ module Users
verification_reminder: 40, # EE-only
ci_deprecation_warning_for_types_keyword: 41,
security_training_feature_promotion: 42, # EE-only
- storage_enforcement_banner_first_enforcement_threshold: 43, # EE-only
- storage_enforcement_banner_second_enforcement_threshold: 44, # EE-only
- storage_enforcement_banner_third_enforcement_threshold: 45, # EE-only
- storage_enforcement_banner_fourth_enforcement_threshold: 46, # EE-only
+ namespace_storage_pre_enforcement_banner: 43, # EE-only
+ # 44, 45, 46 were unused and removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118330,
+ # they can be replaced.
# 47 and 48 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95446
# 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533
# because the banner was no longer relevant.
@@ -60,12 +59,13 @@ module Users
namespace_storage_limit_banner_warning_threshold: 56, # EE-only
namespace_storage_limit_banner_alert_threshold: 57, # EE-only
namespace_storage_limit_banner_error_threshold: 58, # EE-only
- project_quality_summary_feedback: 59, # EE-only
+ project_quality_summary_feedback: 59, # EE-only
merge_request_settings_moved_callout: 60,
new_top_level_group_alert: 61,
artifacts_management_page_feedback_banner: 62,
- vscode_web_ide: 63,
- vscode_web_ide_callout: 64
+ # 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233
+ branch_rules_info_callout: 65,
+ create_runner_workflow_banner: 66
}
validates :feature_name,
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 272f31aa9ce..1b0fd8682db 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -7,6 +7,8 @@ module Users
self.table_name = 'user_credit_card_validations'
belongs_to :user
+ belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id,
+ inverse_of: :credit_card_validation
validates :holder_name, length: { maximum: 50 }
validates :network, length: { maximum: 32 }
@@ -14,18 +16,32 @@ module Users
greater_than_or_equal_to: 0, less_than_or_equal_to: 9999
}
+ scope :by_banned_user, -> { joins(:banned_user) }
+ scope :similar_by_holder_name, ->(holder_name) do
+ if holder_name.present?
+ where('lower(holder_name) = lower(:value)', value: holder_name)
+ else
+ none
+ end
+ end
+ scope :similar_to, ->(credit_card_validation) do
+ where(
+ expiration_date: credit_card_validation.expiration_date,
+ last_digits: credit_card_validation.last_digits,
+ network: credit_card_validation.network
+ )
+ end
+
def similar_records
- self.class.where(
- expiration_date: expiration_date,
- last_digits: last_digits,
- network: network
- ).order(credit_card_validated_at: :desc).includes(:user)
+ self.class.similar_to(self).order(credit_card_validated_at: :desc).includes(:user)
end
def similar_holder_names_count
- return 0 unless holder_name
+ self.class.similar_by_holder_name(holder_name).count
+ end
- self.class.where('lower(holder_name) = lower(:value)', value: holder_name).count
+ def used_by_banned_user?
+ self.class.by_banned_user.similar_to(self).similar_by_holder_name(holder_name).exists?
end
end
end
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 2552407fa4c..1cc9f1f50ad 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -11,10 +11,9 @@ module Users
enum feature_name: {
invite_members_banner: 1,
approaching_seat_count_threshold: 2, # EE-only
- storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only
- storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only
- storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only
- storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only
+ namespace_storage_pre_enforcement_banner: 3, # EE-only
+ # 4,5,6 were unused and removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118330,
+ # they can be replaced.
preview_user_over_limit_free_plan_alert: 7, # EE-only
user_reached_limit_free_plan_alert: 8, # EE-only
free_group_limited_alert: 9, # EE-only
@@ -24,14 +23,16 @@ module Users
namespace_storage_limit_banner_error_threshold: 13, # EE-only
usage_quota_trial_alert: 14, # EE-only
preview_usage_quota_free_plan_alert: 15, # EE-only
- enforcement_at_limit_alert: 16 # EE-only
+ enforcement_at_limit_alert: 16, # EE-only
+ web_hook_disabled: 17, # EE-only
+ unlimited_members_during_trial_alert: 18 # EE-only
}
validates :group, presence: true
validates :feature_name,
- presence: true,
- uniqueness: { scope: [:user_id, :group_id] },
- inclusion: { in: GroupCallout.feature_names.keys }
+ presence: true,
+ uniqueness: { scope: [:user_id, :group_id] },
+ inclusion: { in: GroupCallout.feature_names.keys }
def source_feature_name
"#{feature_name}_#{group_id}"
diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb
index b9e4e908ddd..52f16a7861f 100644
--- a/app/models/users/phone_number_validation.rb
+++ b/app/models/users/phone_number_validation.rb
@@ -8,28 +8,25 @@ module Users
belongs_to :user, foreign_key: :user_id
belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id
- validates :country,
- presence: true,
- length: { maximum: 3 }
+ validates :country, presence: true, length: { maximum: 3 }
validates :international_dial_code,
- presence: true,
- numericality: {
- only_integer: true,
- greater_than_or_equal_to: 1,
- less_than_or_equal_to: 999
- }
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: 1,
+ less_than_or_equal_to: 999
+ }
validates :phone_number,
- presence: true,
- format: {
- with: /\A\d+\Z/,
- message: -> (object, data) { _('can contain only digits') }
- },
- length: { maximum: 12 }
-
- validates :telesign_reference_xid,
- length: { maximum: 255 }
+ presence: true,
+ format: {
+ with: /\A\d+\Z/,
+ message: -> (object, data) { _('can contain only digits') }
+ },
+ length: { maximum: 12 }
+
+ validates :telesign_reference_xid, length: { maximum: 255 }
scope :for_user, -> (user_id) { where(user_id: user_id) }
diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb
index c73b3a4ee71..3964f202be6 100644
--- a/app/models/users/project_callout.rb
+++ b/app/models/users/project_callout.rb
@@ -12,16 +12,16 @@ module Users
awaiting_members_banner: 1, # EE-only
web_hook_disabled: 2,
ultimate_feature_removal_banner: 3,
- 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
+ namespace_storage_pre_enforcement_banner: 4, # EE-only
+ # 5,6,7 were unused and removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118330,
+ # they can be replaced.
+ license_check_deprecation_alert: 8 # EE-only
}
validates :project, presence: true
validates :feature_name,
- presence: true,
- uniqueness: { scope: [:user_id, :project_id] },
- inclusion: { in: ProjectCallout.feature_names.keys }
+ presence: true,
+ uniqueness: { scope: [:user_id, :project_id] },
+ inclusion: { in: ProjectCallout.feature_names.keys }
end
end
diff --git a/app/models/users/user_follow_user.rb b/app/models/users/user_follow_user.rb
index 5a82a81364a..c9d4bee496c 100644
--- a/app/models/users/user_follow_user.rb
+++ b/app/models/users/user_follow_user.rb
@@ -14,9 +14,13 @@ module Users
followee_count = self.class.where(follower_id: follower_id).limit(MAX_FOLLOWEE_LIMIT).count
return if followee_count < MAX_FOLLOWEE_LIMIT
- errors.add(:base, format(
- _("You can't follow more than %{limit} users. To follow more users, unfollow some others."),
- limit: MAX_FOLLOWEE_LIMIT))
+ errors.add(
+ :base,
+ format(
+ _("You can't follow more than %{limit} users. To follow more users, unfollow some others."),
+ limit: MAX_FOLLOWEE_LIMIT
+ )
+ )
end
end
end
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index 8bb598ee316..700e4e0e0ec 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -7,6 +7,14 @@ class Vulnerability < ApplicationRecord
alias_attribute :vulnerability_id, :id
+ scope :with_projects, -> { includes(:project) }
+
+ # Policy class inferring logic is causing performance
+ # issues therefore we need to explicitly set it.
+ def self.declarative_policy_class
+ :VulnerabilityPolicy
+ end
+
def self.link_reference_pattern
nil
end
diff --git a/app/models/web_ide_terminal.rb b/app/models/web_ide_terminal.rb
index ef70df2405f..fe69ca80c32 100644
--- a/app/models/web_ide_terminal.rb
+++ b/app/models/web_ide_terminal.rb
@@ -39,12 +39,14 @@ class WebIdeTerminal
private
def web_ide_terminal_route_generator(action, options = {})
- options.reverse_merge!(action: action,
- controller: 'projects/web_ide_terminals',
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- id: build.id,
- only_path: true)
+ options.reverse_merge!(
+ action: action,
+ controller: 'projects/web_ide_terminals',
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: build.id,
+ only_path: true
+ )
url_for(options)
end
diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb
index 71b50192e29..c8b2513e702 100644
--- a/app/models/webauthn_registration.rb
+++ b/app/models/webauthn_registration.rb
@@ -3,10 +3,14 @@
# Registration information for WebAuthn credentials
class WebauthnRegistration < ApplicationRecord
+ include IgnorableColumns
+
+ ignore_column :u2f_registration_id, remove_with: '16.2', remove_after: '2023-06-22'
+
belongs_to :user
validates :credential_xid, :public_key, :counter, presence: true
validates :name, length: { minimum: 0, allow_nil: false }
validates :counter,
- numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 57488749b76..39d22ea0e07 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -6,11 +6,10 @@ class Wiki
include Repositories::CanHousekeepRepository
include Gitlab::Utils::StrongMemoize
include GlobalID::Identification
+ include Gitlab::Git::WrapsGitalyErrors
extend ActiveModel::Naming
- DuplicatePageError = Class.new(StandardError)
-
MARKUPS = { # rubocop:disable Style/MultilineIfModifier
markdown: {
name: 'Markdown',
@@ -187,6 +186,8 @@ class Wiki
def has_home_page?
!!find_page(HOMEPAGE)
+ rescue StandardError
+ false
end
def empty?
@@ -287,9 +288,7 @@ class Wiki
def create_page(title, content, format = :markdown, message = nil)
with_valid_format(format) do |default_extension|
- if file_exists_by_regex?(title)
- raise_duplicate_page_error!
- end
+ next duplicated_page_error if file_exists_by_regex?(title)
capture_git_error(:created) do
create_wiki_repository unless repository_exists?
@@ -300,13 +299,9 @@ class Wiki
true
rescue Gitlab::Git::Index::IndexError
- raise_duplicate_page_error!
+ duplicated_page_error
end
end
- rescue DuplicatePageError => e
- @error_message = _("Duplicate page: %{error_message}" % { error_message: e.message })
-
- false
end
def update_page(page, content:, title: nil, format: :markdown, message: nil)
@@ -326,10 +321,17 @@ class Wiki
content,
previous_path: page.path,
**multi_commit_options(:updated, message, title))
+ repository.move_dir_files(
+ user,
+ sluggified_title(title),
+ page.url_path,
+ **multi_commit_options(:moved, message, title))
after_wiki_activity
true
+ rescue Gitlab::Git::Index::IndexError
+ duplicated_page_error
end
end
end
@@ -398,13 +400,11 @@ class Wiki
# Callbacks for synchronous processing after wiki changes.
# These will be executed after any change made through GitLab itself (web UI and API),
# but not for Git pushes.
- def after_wiki_activity
- end
+ def after_wiki_activity; end
# Callbacks for background processing after wiki changes.
# These will be executed after any change to the wiki repository.
- def after_post_receive
- end
+ def after_post_receive; end
override :git_garbage_collect_worker_klass
def git_garbage_collect_worker_klass
@@ -416,12 +416,14 @@ class Wiki
end
def capture_git_error(action, &block)
- yield block
+ wrapped_gitaly_errors(&block)
rescue Gitlab::Git::Index::IndexError,
- Gitlab::Git::CommitError,
- Gitlab::Git::PreReceiveError,
- Gitlab::Git::CommandError,
- ArgumentError => e
+ Gitlab::Git::CommitError,
+ Gitlab::Git::PreReceiveError,
+ Gitlab::Git::CommandError,
+ ArgumentError => e
+
+ @error_message = e.message
Gitlab::ErrorTracking.log_exception(e, action: action, wiki_id: id)
@@ -471,8 +473,9 @@ class Wiki
repository.ls_files(default_branch).any? { |s| s =~ regex }
end
- def raise_duplicate_page_error!
- raise ::Wiki::DuplicatePageError, _('A page with that title already exists')
+ def duplicated_page_error
+ @error_message = _("Duplicate page: A page with that title already exists")
+ false
end
def sluggified_full_path(title, extension)
@@ -491,7 +494,9 @@ class Wiki
escaped_path = RE2::Regexp.escape(sluggified_title(title))
path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)^#{escaped_path}\\.(#{file_extension_regexp})$")
- matched_files = repository.search_files_by_regexp(path_regexp, version, limit: 1)
+ matched_files = capture_git_error(:find) do
+ repository.search_files_by_regexp(path_regexp, version, limit: 1)
+ end
return if matched_files.blank?
Gitlab::EncodingHelper.encode_utf8_no_detect(matched_files.first)
diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb
index 76fe664f23d..e57d186a3e3 100644
--- a/app/models/wiki_directory.rb
+++ b/app/models/wiki_directory.rb
@@ -7,34 +7,48 @@ class WikiDirectory
validates :slug, presence: true
alias_method :to_param, :slug
- # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects,
- # preserving the order of the passed pages.
- #
- # Returns an array with all entries for the toplevel directory.
- #
- # @param [Array<WikiPage>] pages
- # @return [Array<WikiPage, WikiDirectory>]
- #
- def self.group_pages(pages)
- # Build a hash to map paths to created WikiDirectory objects,
- # and recursively create them for each level of the path.
- # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns.
- directories = Hash.new do |_, path|
- directories[path] = new(path).tap do |directory|
- if path.present?
- parent = File.dirname(path)
- parent = '' if parent == '.'
- directories[parent].entries << directory
- directories[parent].entries.delete_if { |item| item.is_a?(WikiPage) && item.slug == directory.slug }
+ class << self
+ # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects,
+ # preserving the order of the passed pages.
+ #
+ # Returns an array with all entries for the toplevel directory.
+ #
+ # @param [Array<WikiPage>] pages
+ # @return [Array<WikiPage, WikiDirectory>]
+ #
+ def group_pages(pages)
+ # Build a hash to map paths to created WikiDirectory objects,
+ # and recursively create them for each level of the path.
+ # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns.
+ directories = Hash.new do |_, path|
+ directories[path] = new(path).tap do |directory|
+ if path.present?
+ parent = File.dirname(path)
+ parent = '' if parent == '.'
+ directories[parent].entries << directory
+ directories[parent].entries.delete_if do |item|
+ item.is_a?(WikiPage) && item.slug.casecmp?(directory.slug)
+ end
+ end
end
end
- end
- pages.each do |page|
- directories[page.directory].entries << page
+ pages.each do |page|
+ next unless directory_for_page?(directories[page.directory], page)
+
+ directories[page.directory].entries << page
+ end
+
+ directories[''].entries
end
- directories[''].entries
+ private
+
+ def directory_for_page?(directory, page)
+ directory.entries.none? do |item|
+ item.is_a?(WikiDirectory) && item.slug.casecmp?(page.slug)
+ end
+ end
end
def initialize(slug, entries = [])
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index b04aa196883..e1468872f52 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -145,10 +145,12 @@ class WikiPage
default_per_page = Kaminari.config.default_per_page
offset = [options[:page].to_i - 1, 0].max * options.fetch(:per_page, default_per_page)
- wiki.repository.commits(wiki.default_branch,
- path: page.path,
- limit: options.fetch(:limit, default_per_page),
- offset: offset)
+ wiki.repository.commits(
+ wiki.default_branch,
+ path: page.path,
+ limit: options.fetch(:limit, default_per_page),
+ offset: offset
+ )
end
def count_versions
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 5ae3fb6cf78..24d1078516e 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -4,7 +4,7 @@ class WorkItem < Issue
include Gitlab::Utils::StrongMemoize
COMMON_QUICK_ACTIONS_COMMANDS = [
- :title, :reopen, :close, :cc, :tableflip, :shrug
+ :title, :reopen, :close, :cc, :tableflip, :shrug, :type
].freeze
self.table_name = 'issues'
@@ -16,15 +16,13 @@ class WorkItem < Issue
has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id
has_many :work_item_children, through: :child_links, class_name: 'WorkItem',
- foreign_key: :work_item_id, source: :work_item
+ foreign_key: :work_item_id, source: :work_item
has_many :work_item_children_by_relative_position, -> { work_item_children_keyset_order },
- through: :child_links, class_name: 'WorkItem',
- foreign_key: :work_item_id, source: :work_item
+ through: :child_links, class_name: 'WorkItem',
+ foreign_key: :work_item_id, source: :work_item
scope :inc_relations_for_permission_check, -> { includes(:author, project: :project_feature) }
- delegate :supports_assignee?, to: :work_item_type
-
class << self
def assignee_association_name
'issue'
@@ -34,6 +32,14 @@ class WorkItem < Issue
'issues.id'
end
+ # def reference_pattern
+ # # no-op: We currently only support link_reference_pattern parsing
+ # end
+
+ def link_reference_pattern
+ @link_reference_pattern ||= compose_link_reference_pattern('work_items', Gitlab::Regex.work_item)
+ end
+
def work_item_children_keyset_order
keyset_order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
@@ -51,7 +57,7 @@ class WorkItem < Issue
)
])
- includes(:child_links).order(keyset_order)
+ includes(:parent_link).order(keyset_order)
end
end
@@ -67,6 +73,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
@@ -85,6 +101,26 @@ class WorkItem < Issue
COMMON_QUICK_ACTIONS_COMMANDS + commands_for_widgets
end
+ # Widgets have a set of quick action params that they must process.
+ # Map them to widget_params so they can be picked up by widget services.
+ def transform_quick_action_params(command_params)
+ common_params = command_params.dup
+ widget_params = {}
+
+ work_item_type.widgets
+ .filter { |widget| widget.respond_to?(:quick_action_params) }
+ .each do |widget|
+ widget.quick_action_params
+ .filter { |param_name| common_params.key?(param_name) }
+ .each do |param_name|
+ widget_params[widget.api_symbol] ||= {}
+ widget_params[widget.api_symbol][param_name] = common_params.delete(param_name)
+ end
+ end
+
+ { common: common_params, widgets: widget_params }
+ end
+
private
override :parent_link_confidentiality
@@ -110,6 +146,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..6725acf8c68
--- /dev/null
+++ b/app/models/work_items/resource_link_event.rb
@@ -0,0 +1,16 @@
+# 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
+
+WorkItems::ResourceLinkEvent.prepend_mod
diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb
index 5d4414e95d8..763b1a79069 100644
--- a/app/models/work_items/widget_definition.rb
+++ b/app/models/work_items/widget_definition.rb
@@ -28,7 +28,10 @@ module WorkItems
progress: 10, # EE-only
status: 11, # EE-only
requirement_legacy: 12, # EE-only
- test_reports: 13 # EE-only
+ test_reports: 13, # EE-only
+ 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/models/work_items/widgets/notifications.rb b/app/models/work_items/widgets/notifications.rb
new file mode 100644
index 00000000000..9a13e5ebbea
--- /dev/null
+++ b/app/models/work_items/widgets/notifications.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ class Notifications < Base
+ delegate :subscribed?, to: :work_item
+ end
+ end
+end
diff --git a/app/policies/abuse_report_policy.rb b/app/policies/abuse_report_policy.rb
new file mode 100644
index 00000000000..f1f994e6a42
--- /dev/null
+++ b/app/policies/abuse_report_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AbuseReportPolicy < ::BasePolicy
+ rule { admin }.policy do
+ enable :read_abuse_report
+ end
+end
diff --git a/app/policies/achievements/user_achievement_policy.rb b/app/policies/achievements/user_achievement_policy.rb
new file mode 100644
index 00000000000..05650a05490
--- /dev/null
+++ b/app/policies/achievements/user_achievement_policy.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+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..d6aaa3e983d 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -35,10 +35,18 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:security_bot) { @user&.security_bot? }
+ desc "User is security policy bot"
+ with_options scope: :user, score: 0
+ condition(:security_policy_bot) { @user&.security_policy_bot? }
+
desc "User is automation bot"
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 +71,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..73e4cbee54a 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -71,13 +71,17 @@ 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
# Authorizing the user to access to protected entities.
# There is a "jailbreak" mode to exceptionally bypass the authorization,
# however, you should NEVER allow it, rather suspect it's a wrong feature/product design.
- rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do
+ rule { ~can?(:jailbreak) & (archived | (protected_ref & ~admin) | protected_environment) }.policy do
prevent :update_build
prevent :update_commit_status
prevent :erase_build
diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb
index 3a674bfef92..7b0d484f9f7 100644
--- a/app/policies/ci/pipeline_schedule_policy.rb
+++ b/app/policies/ci/pipeline_schedule_policy.rb
@@ -23,6 +23,10 @@ module Ci
enable :update_pipeline_schedule
end
+ # `take_ownership_pipeline_schedule` is deprecated, and should not be used. It can be removed in 17.0
+ # once the deprecated field `take_ownership_pipeline_schedule` is removed from the GraphQL type
+ # `PermissionTypes::Ci::PipelineSchedules`.
+ # Use `admin_pipeline_schedule` to decide if a user has the ability to take ownership of a pipeline schedule.
rule { can?(:admin_pipeline_schedule) & ~owner_of_schedule }.policy do
enable :take_ownership_pipeline_schedule
end
diff --git a/app/policies/ci/runner_manager_policy.rb b/app/policies/ci/runner_manager_policy.rb
new file mode 100644
index 00000000000..43e81e373fc
--- /dev/null
+++ b/app/policies/ci/runner_manager_policy.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnerManagerPolicy < BasePolicy
+ with_options scope: :subject, score: 0
+
+ condition(:can_read_runner, scope: :subject) do
+ can?(:read_runner, @subject.runner)
+ end
+
+ rule { anonymous }.prevent_all
+
+ rule { can_read_runner }.policy do
+ enable :read_builds
+ enable :read_runner_manager
+ end
+ end
+end
diff --git a/app/policies/clusters/agent_policy.rb b/app/policies/clusters/agent_policy.rb
index 25e78c84802..ecd83cceb8b 100644
--- a/app/policies/clusters/agent_policy.rb
+++ b/app/policies/clusters/agent_policy.rb
@@ -5,5 +5,19 @@ module Clusters
alias_method :cluster_agent, :subject
delegate { cluster_agent.project }
+
+ # This condition is more expensive than the same permission check in ProjectPolicy,
+ # so having a higher score.
+ condition(:ci_access_authorized_agent, score: 10) do
+ @subject.ci_access_authorized_for?(@user)
+ end
+
+ condition(:user_access_authorized_agent, score: 10) do
+ @subject.user_access_authorized_for?(@user)
+ end
+
+ rule { ci_access_authorized_agent | user_access_authorized_agent }.policy do
+ enable :read_cluster_agent
+ end
end
end
diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb
index 3c5ca4bf4e1..2781e943bae 100644
--- a/app/policies/clusters/instance_policy.rb
+++ b/app/policies/clusters/instance_policy.rb
@@ -9,6 +9,7 @@ module Clusters
enable :update_cluster
enable :admin_cluster
enable :read_prometheus
+ enable :use_k8s_proxies
end
end
end
diff --git a/app/policies/concerns/archived_abilities.rb b/app/policies/concerns/archived_abilities.rb
index b4dfad599c7..7d61f83528e 100644
--- a/app/policies/concerns/archived_abilities.rb
+++ b/app/policies/concerns/archived_abilities.rb
@@ -37,6 +37,7 @@ module ArchivedAbilities
pages
cluster
release
+ timelog
].freeze
class_methods do
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index 8fa09683b06..e000f1514e5 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -53,6 +53,10 @@ module PolicyActor
false
end
+ def security_policy_bot?
+ false
+ end
+
def automation_bot?
false
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index d028738ccc9..b96ad9a73c8 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -22,10 +22,12 @@ class GlobalPolicy < BasePolicy
condition(:project_bot, scope: :user) { @user&.project_bot? }
condition(:migration_bot, scope: :user) { @user&.migration_bot? }
- condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow)
+ condition(:create_runner_workflow_enabled, scope: :user) do
+ Feature.enabled?(:create_runner_workflow_for_admin, @user)
end
+ condition(:service_account, scope: :user) { @user&.service_account? }
+
rule { anonymous }.policy do
prevent :log_in
prevent :receive_notifications
@@ -60,11 +62,15 @@ class GlobalPolicy < BasePolicy
rule { ~can?(:access_api) }.prevent :execute_graphql_mutation
- rule { blocked | (internal & ~migration_bot & ~security_bot) }.policy do
+ rule { blocked | (internal & ~migration_bot & ~security_bot & ~security_policy_bot) }.policy do
prevent :access_git
end
- rule { project_bot }.policy do
+ rule { security_policy_bot }.policy do
+ enable :access_git
+ end
+
+ rule { project_bot | service_account }.policy do
prevent :log_in
prevent :receive_notifications
end
@@ -119,11 +125,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 6cc65248914..285721de387 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -36,7 +36,20 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:request_access_enabled) { @subject.request_access_enabled }
condition(:create_projects_disabled, scope: :subject) do
- @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS
+ next true if @user.nil?
+
+ visibility_levels = if @user.can_admin_all_resources?
+ # admin can create projects even with restricted visibility levels
+ Gitlab::VisibilityLevel.values
+ else
+ Gitlab::VisibilityLevel.allowed_levels
+ end
+
+ allowed_visibility_levels = visibility_levels.select do |level|
+ Project.new(namespace: @subject).visibility_level_allowed?(level)
+ end
+
+ @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS || allowed_visibility_levels.empty?
end
condition(:developer_maintainer_access, scope: :subject) do
@@ -85,11 +98,15 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? }
condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow)
+ Feature.enabled?(:create_runner_workflow_for_namespace, group)
+ end
+
+ condition(:achievements_enabled, scope: :subject) do
+ Feature.enabled?(:achievements, @subject)
end
condition(:group_runner_registration_allowed, scope: :subject) do
- Gitlab::CurrentSettings.valid_runner_registrars.include?('group') && @subject.runner_registration_enabled?
+ @subject.runner_registration_enabled?
end
rule { can?(:read_group) & design_management_enabled }.policy do
@@ -131,9 +148,17 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_group_member
enable :read_custom_emoji
enable :read_counts
+ end
+
+ rule { achievements_enabled }.policy do
enable :read_achievement
end
+ rule { can?(:maintainer_access) & achievements_enabled }.policy do
+ enable :admin_achievement
+ enable :award_achievement
+ end
+
rule { ~public_group & ~has_access }.prevent :read_counts
rule { ~can_read_group_member }.policy do
@@ -147,17 +172,16 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { has_access }.enable :read_namespace
rule { developer }.policy do
- enable :create_metrics_dashboard_annotation
- enable :delete_metrics_dashboard_annotation
- enable :update_metrics_dashboard_annotation
+ enable :admin_metrics_dashboard_annotation
enable :create_custom_emoji
enable :create_package
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
rule { reporter }.policy do
@@ -180,6 +204,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
@@ -191,7 +216,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :maintainer_access
enable :read_upload
enable :destroy_upload
- enable :admin_achievement
end
rule { owner }.policy do
@@ -204,7 +228,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
@@ -246,17 +270,25 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { ~can?(:view_globally) }.prevent :request_access
rule { has_access }.prevent :request_access
- rule { owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock) }.enable :change_share_with_group_lock
+ rule do
+ owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock)
+ 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
@@ -289,10 +321,12 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { resource_access_token_creation_allowed & can?(:read_resource_access_tokens) }.policy do
enable :create_resource_access_tokens
+ enable :manage_resource_access_tokens
end
rule { can?(:project_bot_access) }.policy do
prevent :create_resource_access_tokens
+ prevent :manage_resource_access_tokens
end
rule { can?(:admin_group_member) }.policy do
@@ -313,7 +347,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
@@ -325,8 +359,12 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_observability
end
+ rule { can?(:maintainer_access) & observability_enabled }.policy do
+ enable :admin_observability
+ 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/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb
index c539fc64d3f..1e748c78555 100644
--- a/app/policies/identity_provider_policy.rb
+++ b/app/policies/identity_provider_policy.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class IdentityProviderPolicy < BasePolicy
- desc "Provider is SAML or CAS3"
- condition(:protected_provider, scope: :subject, score: 0) { %w(saml cas3).include?(@subject.to_s) }
+ desc "Provider is SAML"
+ condition(:protected_provider, scope: :subject, score: 0) { @subject.to_s == 'saml' }
rule { anonymous }.prevent_all
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 496708a9737..60ab1785972 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) { subject_container.member?(@user) }
condition(:can_read_issuable) { can?(:"read_#{@subject.to_ability_name}") }
desc "User is the assignee or author"
@@ -14,7 +14,7 @@ class IssuablePolicy < BasePolicy
condition(:is_author) { @subject&.author == @user }
- condition(:is_incident) { @subject.incident? }
+ condition(:is_incident) { @subject.incident_type_issue? }
desc "Issuable is hidden"
condition(:hidden, scope: :subject) { @subject.hidden? }
@@ -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 d1e35793c64..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
@@ -59,6 +60,7 @@ class IssuePolicy < IssuablePolicy
rule { ~can?(:read_issue) }.policy do
prevent :read_design
prevent :create_design
+ prevent :update_design
prevent :destroy_design
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/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb
index 1deeae8241f..bfed61e72d3 100644
--- a/app/policies/namespaces/user_namespace_policy.rb
+++ b/app/policies/namespaces/user_namespace_policy.rb
@@ -11,6 +11,7 @@ module Namespaces
rule { owner | admin }.policy do
enable :owner_access
enable :create_projects
+ enable :import_projects
enable :admin_namespace
enable :read_namespace
enable :read_statistics
@@ -20,9 +21,9 @@ module Namespaces
enable :edit_billing
end
- rule { ~can_create_personal_project }.prevent :create_projects
+ rule { ~can_create_personal_project }.prevent :create_projects, :import_projects
- rule { bot_user_namespace }.prevent :create_projects
+ rule { bot_user_namespace }.prevent :create_projects, :import_projects
rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects
end
diff --git a/app/policies/project_hook_policy.rb b/app/policies/project_hook_policy.rb
index c177fabb1ba..b4590c13670 100644
--- a/app/policies/project_hook_policy.rb
+++ b/app/policies/project_hook_policy.rb
@@ -1,10 +1,9 @@
# frozen_string_literal: true
class ProjectHookPolicy < ::BasePolicy
- delegate(:project)
+ delegate { @subject.project }
rule { can?(:admin_project) }.policy do
- enable :read_web_hook
enable :destroy_web_hook
end
end
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 3d22002e828..47d8d0eef3e 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -38,6 +38,9 @@ class ProjectPolicy < BasePolicy
desc "User is a project bot"
condition(:project_bot) { user.project_bot? && team_member? }
+ desc "User is a security policy bot on the project"
+ condition(:security_policy_bot) { user&.security_policy_bot? && team_member? }
+
desc "Project is public"
condition(:public_project, scope: :subject, score: 0) { project.public? }
@@ -49,6 +52,9 @@ class ProjectPolicy < BasePolicy
desc "User is a member of the group"
condition(:group_member, scope: :subject) { project_group_member? }
+ desc "User is a requester of the group"
+ condition(:group_requester, scope: :subject) { project_group_requester? }
+
desc "Project is archived"
condition(:archived, scope: :subject, score: 0) { project.archived? }
@@ -222,8 +228,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
@@ -234,10 +240,16 @@ class ProjectPolicy < BasePolicy
Gitlab.config.packages.enabled
end
+ condition :terraform_state_disabled do
+ !Gitlab.config.terraform_state.enabled
+ end
+
condition(:create_runner_workflow_enabled) do
- Feature.enabled?(:create_runner_workflow)
+ 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
@@ -274,9 +286,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
@@ -349,10 +358,10 @@ 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
+ enable :export_work_items
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -404,11 +413,15 @@ class ProjectPolicy < BasePolicy
end
rule { infrastructure_disabled }.policy do
- prevent(*create_read_update_admin_destroy(:terraform_state))
prevent(*create_read_update_admin_destroy(:cluster))
prevent(:read_pod_logs)
prevent(:read_prometheus)
prevent(:admin_project_google_cloud)
+ prevent(:admin_project_aws)
+ end
+
+ rule { infrastructure_disabled | terraform_state_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:terraform_state))
end
rule { can?(:metrics_dashboard) }.policy do
@@ -424,10 +437,11 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:package))
end
- rule { owner | admin | guest | group_member }.prevent :request_access
+ rule { owner | admin | guest | group_member | group_requester }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
+ rule { can?(:reporter_access) & can?(:create_work_item) }.enable :import_work_items
rule { can?(:developer_access) }.policy do
enable :create_package
@@ -453,16 +467,17 @@ 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
enable :destroy_release
- enable :create_metrics_dashboard_annotation
- enable :delete_metrics_dashboard_annotation
- enable :update_metrics_dashboard_annotation
+ enable :admin_metrics_dashboard_annotation
enable :read_alert_management_alert
enable :update_alert_management_alert
enable :create_design
+ enable :update_design
enable :move_design
enable :destroy_design
enable :read_terraform_state
@@ -476,7 +491,6 @@ class ProjectPolicy < BasePolicy
enable :update_escalation_status
enable :read_secure_files
enable :update_sentry_issue
- enable :read_airflow_dags
end
rule { can?(:developer_access) & user_confirmed? }.policy do
@@ -527,11 +541,13 @@ 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
enable :admin_secure_files
- enable :read_web_hooks
enable :read_upload
enable :destroy_upload
enable :admin_incident_management_timeline_event_tag
@@ -751,6 +767,7 @@ class ProjectPolicy < BasePolicy
prevent :read_design
prevent :read_design_activity
prevent :create_design
+ prevent :update_design
prevent :destroy_design
prevent :move_design
end
@@ -779,6 +796,7 @@ class ProjectPolicy < BasePolicy
rule { write_package_registry_deploy_token }.policy do
enable :create_package
enable :read_package
+ enable :destroy_package
enable :read_project
end
@@ -812,6 +830,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:admin_project) & resource_access_token_feature_available & resource_access_token_creation_allowed }.policy do
enable :create_resource_access_tokens
+ enable :manage_resource_access_tokens
end
rule { can?(:admin_project) }.policy do
@@ -820,6 +839,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:project_bot_access) }.policy do
prevent :create_resource_access_tokens
+ prevent :manage_resource_access_tokens
end
rule { user_defined_variables_allowed | can?(:maintainer_access) }.policy do
@@ -832,7 +852,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
@@ -858,12 +878,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?
@@ -897,16 +925,19 @@ class ProjectPolicy < BasePolicy
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def project_group_member?
return false if @user.nil?
return false unless user_is_user?
- project.group &&
- (
- project.group.members_with_parents.exists?(user_id: @user.id) ||
- project.group.requesters.exists?(user_id: @user.id)
- )
+ project.group && project.group.member?(@user)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_group_requester?
+ return false if @user.nil?
+ return false unless user_is_user?
+
+ project.group && project.group.requesters.exists?(user_id: @user.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -957,6 +988,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/policies/user_policy.rb b/app/policies/user_policy.rb
index ed5b01e52b4..1078eda38e7 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -37,6 +37,7 @@ class UserPolicy < BasePolicy
rule { (private_profile | blocked_user | unconfirmed_user) & ~(user_is_self | admin) }.prevent :read_user_profile
rule { user_is_self | admin }.enable :disable_two_factor
rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token
+ rule { (user_is_self | admin) & ~blocked }.enable :manage_user_personal_access_token
rule { (user_is_self | admin) & ~blocked }.enable :get_user_associations_count
end
diff --git a/app/presenters/README.md b/app/presenters/README.md
index e2461580107..5b600e8f2b2 100644
--- a/app/presenters/README.md
+++ b/app/presenters/README.md
@@ -165,15 +165,15 @@ however, there is a risk that it accidentally overrides important logic.
For example, [this production incident](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/5498)
was caused by [including `ActionView::Helpers::UrlHelper` in a presenter](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69537/diffs#4b581cff00ef3cc9780efd23682af383de302e7d_3_3).
-The `tag` accesor in `Ci::Build` was accidentally overridden by `ActionView::Helpers::TagHelper#tag`,
-and as a conseuqence, a wrong `tag` value was persited into database.
+The `tag` accessor in `Ci::Build` was accidentally overridden by `ActionView::Helpers::TagHelper#tag`,
+and as a consequence, a wrong `tag` value was persisted into database.
-Starting from GitLab 14.4, we validate the presenters (specifically all of the subclasses of `Gitlab::View::Presenter::Delegated`)
+Starting from GitLab 14.4, we [validate](../../lib/gitlab/utils/delegator_override/validator.rb) the presenters (specifically all of the subclasses of `Gitlab::View::Presenter::Delegated`)
that they do not accidentally override core/backend logic. In such case, a pipeline in merge requests fails with an error message,
here is an example:
```plaintext
-We've detected that a presetner is overriding a specific method(s) on a subject model.
+We've detected that a presenter is overriding a specific method(s) on a subject model.
There is a risk that it accidentally modifies the backend/core logic that leads to production incident.
Please follow https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/presenters/README.md#validate-accidental-overrides
to resolve this error with caution.
@@ -193,7 +193,7 @@ Here are the potential solutions:
### How to use the `Gitlab::Utils::DelegatorOverride` validator
-If a presenter class inhertis from `Gitlab::View::Presenter::Delegated`,
+If a presenter class inherits from `Gitlab::View::Presenter::Delegated`,
you should define what object class is presented:
```ruby
@@ -201,7 +201,7 @@ class WebHookLogPresenter < Gitlab::View::Presenter::Delegated
presents ::WebHookLog, as: :web_hook_log # This defines that the presenter presents `WebHookLog` Active Record model.
```
-These presenters are validated not to accidentaly override the methods in the presented object.
+These presenters are validated not to accidentally override the methods in the presented object.
You can run the validation locally with:
```shell
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 513fcd90cf8..b89e8db334d 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -27,6 +27,10 @@ module Ci
scheduled? && scheduled_at && [0, scheduled_at - Time.now].max
end
+ def failure_message
+ callout_failure_message if build.failed?
+ end
+
private
def tooltip_for_badge(status)
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 9a586a1733f..79c1946f3d2 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -34,9 +34,7 @@ module Ci
def runner_variables
variables
- .sort_and_expand_all(keep_undefined: true,
- expand_file_refs: false,
- expand_raw_refs: false)
+ .sort_and_expand_all(keep_undefined: true, expand_file_refs: false, expand_raw_refs: false)
.to_runner_variables
end
@@ -58,7 +56,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def all_dependencies
dependencies = super
- ActiveRecord::Associations::Preloader.new.preload(dependencies, :job_artifacts_archive)
+ ActiveRecord::Associations::Preloader.new(records: dependencies, associations: :job_artifacts_archive).call
dependencies
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index aa0cd476191..8c9ff49b0e7 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -13,7 +13,6 @@ module Ci
config_error: 'The pipeline failed due to an error on the CI/CD configuration file.',
external_validation_failure: 'The external pipeline validation failed.',
user_not_verified: 'The pipeline failed due to the user not being verified',
- activity_limit_exceeded: 'The pipeline activity limit was exceeded.',
size_limit_exceeded: 'The pipeline size limit was exceeded.',
job_activity_limit_exceeded: 'The pipeline job activity limit was exceeded.',
deployments_limit_exceeded: 'The pipeline deployments limit was exceeded.',
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
index 2cb88179845..2879326ff8a 100644
--- a/app/presenters/commit_presenter.rb
+++ b/app/presenters/commit_presenter.rb
@@ -25,6 +25,10 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated
commit.pipelines.any?
end
+ def tags_for_display
+ commit.referenced_by&.map { |tag_name| Gitlab::Git.ref_name(tag_name) }
+ end
+
def signature_html
return unless commit.has_signature?
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index 2f2fb1aa3ba..a098db7fbbc 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -9,7 +9,7 @@ class EventPresenter < Gitlab::View::Presenter::Delegated
@visible_to_user_cache = ActiveSupport::Cache::MemoryStore.new
end
- # Caching `visible_to_user?` method in the presenter beause it might be called multiple times.
+ # Caching `visible_to_user?` method in the presenter because it might be called multiple times.
delegator_override :visible_to_user?
def visible_to_user?(user = nil)
@visible_to_user_cache.fetch(user&.id) { super(user) }
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
index e60cdf4088c..56d986a9c23 100644
--- a/app/presenters/label_presenter.rb
+++ b/app/presenters/label_presenter.rb
@@ -27,14 +27,18 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
def filter_path(type: :issue)
case context_subject
when Group
- send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
- context_subject,
- label_name: [label.name])
+ send( # rubocop:disable GitlabSecurity/PublicSend
+ "#{type.to_s.pluralize}_group_path",
+ context_subject,
+ label_name: [label.name]
+ )
when Project
- send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
- context_subject.namespace,
- context_subject,
- label_name: [label.name])
+ send( # rubocop:disable GitlabSecurity/PublicSend
+ "namespace_project_#{type.to_s.pluralize}_path",
+ context_subject.namespace,
+ context_subject,
+ label_name: [label.name]
+ )
end
end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 353e0fad6fb..12f4b0496e4 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -57,9 +57,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
notice_now: edit_in_new_fork_notice_now
}
- project_forks_path(merge_request.project,
- namespace_key: current_user.namespace.id,
- continue: continue_params)
+ project_forks_path(merge_request.project, namespace_key: current_user.namespace.id, continue: continue_params)
end
end
@@ -71,9 +69,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
notice_now: edit_in_new_fork_notice_now
}
- project_forks_path(project,
- namespace_key: current_user.namespace.id,
- continue: continue_params)
+ project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params)
end
end
@@ -155,12 +151,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def assign_to_closing_issues_count
# rubocop: disable CodeReuse/ServiceClass
- issues = MergeRequests::AssignIssuesService.new(project: project,
- current_user: current_user,
- params: {
- merge_request: merge_request,
- closes_issues: closing_issues
- }).assignable_issues
+ issues = MergeRequests::AssignIssuesService.new(
+ project: project,
+ current_user: current_user,
+ params: { merge_request: merge_request, closes_issues: closing_issues }
+ ).assignable_issues
+
issues.count
# rubocop: enable CodeReuse/ServiceClass
end
diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb
new file mode 100644
index 00000000000..58ec2aee471
--- /dev/null
+++ b/app/presenters/ml/candidate_details_presenter.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Ml
+ class CandidateDetailsPresenter
+ include Rails.application.routes.url_helpers
+
+ def initialize(candidate)
+ @candidate = candidate
+ end
+
+ def present
+ data = {
+ candidate: {
+ info: {
+ iid: candidate.iid,
+ eid: candidate.eid,
+ path_to_artifact: link_to_artifact,
+ experiment_name: candidate.experiment.name,
+ path_to_experiment: link_to_experiment,
+ path: link_to_details,
+ status: candidate.status,
+ ci_job: job_info
+ },
+ params: candidate.params,
+ metrics: candidate.latest_metrics,
+ metadata: candidate.metadata
+ }
+ }
+
+ Gitlab::Json.generate(data)
+ end
+
+ private
+
+ attr_reader :candidate
+
+ def job_info
+ return unless candidate.from_ci?
+
+ build = candidate.ci_build
+
+ {
+ path: project_job_path(build.project, build),
+ name: build.name,
+ **user_info(build.user) || {},
+ **mr_info(build.pipeline.merge_request) || {}
+ }
+ end
+
+ def user_info(user)
+ return unless user
+
+ {
+ user: {
+ path: user_path(user),
+ username: user.username
+ }
+ }
+ end
+
+ def mr_info(mr)
+ return unless mr
+
+ {
+ merge_request: {
+ path: project_merge_request_path(mr.project, mr),
+ title: mr.title
+ }
+ }
+ end
+
+ def link_to_artifact
+ artifact = candidate.artifact
+
+ return unless artifact.present?
+
+ project_package_path(candidate.project, artifact)
+ end
+
+ def link_to_details
+ project_ml_candidate_path(candidate.project, candidate.iid)
+ end
+
+ def link_to_experiment
+ project_ml_experiment_path(candidate.project, candidate.experiment.iid)
+ end
+ end
+end
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/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb
index d730608cc27..49322ad6b43 100644
--- a/app/presenters/pages_domain_presenter.rb
+++ b/app/presenters/pages_domain_presenter.rb
@@ -13,4 +13,11 @@ class PagesDomainPresenter < Gitlab::View::Presenter::Delegated
::Gitlab::LetsEncrypt.enabled? && auto_ssl_failed
end
+
+ def user_defined_certificate?
+ persisted? &&
+ certificate.present? &&
+ certificate_user_provided? &&
+ errors[:certificate].blank?
+ end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 392a2fcd390..856eba5aadc 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -99,11 +99,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def contribution_guide_path
if project && contribution_guide = repository.contribution_guide
- project_blob_path(
- project,
- tree_join(project.default_branch,
- contribution_guide.name)
- )
+ project_blob_path(project, tree_join(project.default_branch, contribution_guide.name))
end
end
@@ -166,14 +162,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def storage_anchor_data
can_show_quota = can?(current_user, :admin_project, project) && !empty_repo?
- AnchorData.new(true,
- statistic_icon('disk') +
- _('%{strong_start}%{human_size}%{strong_end} Project Storage').html_safe % {
- human_size: storage_counter(statistics.storage_size),
- strong_start: '<strong class="project-stat-value">'.html_safe,
- strong_end: '</strong>'.html_safe
- },
- can_show_quota ? project_usage_quotas_path(project) : nil)
+ AnchorData.new(
+ true,
+ statistic_icon('disk') +
+ _('%{strong_start}%{human_size}%{strong_end} Project Storage').html_safe % {
+ human_size: storage_counter(statistics.storage_size),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ can_show_quota ? project_usage_quotas_path(project) : nil
+ )
end
def releases_anchor_data
@@ -182,14 +180,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
releases_count = project.releases.count
return if releases_count < 1
- AnchorData.new(true,
- statistic_icon('deployments') +
- 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,
- strong_end: '</strong>'.html_safe
- },
- project_releases_path(project))
+ AnchorData.new(
+ true,
+ 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,
+ strong_end: '</strong>'.html_safe
+ },
+ project_releases_path(project)
+ )
end
def environments_anchor_data
@@ -198,67 +198,76 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
environments_count = project.environments.available.count
return if environments_count == 0
- AnchorData.new(true,
- statistic_icon('environment') +
- n_('%{strong_start}%{count}%{strong_end} Environment', '%{strong_start}%{count}%{strong_end} Environments', environments_count).html_safe % {
- count: number_with_delimiter(environments_count),
- strong_start: '<strong class="project-stat-value">'.html_safe,
- strong_end: '</strong>'.html_safe
- },
- project_environments_path(project))
+ AnchorData.new(
+ true,
+ statistic_icon('environment') +
+ n_('%{strong_start}%{count}%{strong_end} Environment', '%{strong_start}%{count}%{strong_end} Environments', environments_count).html_safe % {
+ count: number_with_delimiter(environments_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ project_environments_path(project)
+ )
end
def commits_anchor_data
- AnchorData.new(true,
- statistic_icon('commit') +
- n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % {
- commit_count: number_with_delimiter(statistics.commit_count),
- strong_start: '<strong class="project-stat-value">'.html_safe,
- strong_end: '</strong>'.html_safe
- },
- empty_repo? ? nil : project_commits_path(project, default_branch_or_main))
+ AnchorData.new(
+ true,
+ statistic_icon('commit') +
+ n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % {
+ commit_count: number_with_delimiter(statistics.commit_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ empty_repo? ? nil : project_commits_path(project, default_branch_or_main)
+ )
end
def branches_anchor_data
- AnchorData.new(true,
- statistic_icon('branch') +
- n_('%{strong_start}%{branch_count}%{strong_end} Branch', '%{strong_start}%{branch_count}%{strong_end} Branches', repository.branch_count).html_safe % {
- branch_count: number_with_delimiter(repository.branch_count),
- strong_start: '<strong class="project-stat-value">'.html_safe,
- strong_end: '</strong>'.html_safe
- },
- empty_repo? ? nil : project_branches_path(project))
+ AnchorData.new(
+ true,
+ statistic_icon('branch') +
+ n_('%{strong_start}%{branch_count}%{strong_end} Branch', '%{strong_start}%{branch_count}%{strong_end} Branches', repository.branch_count).html_safe % {
+ branch_count: number_with_delimiter(repository.branch_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ empty_repo? ? nil : project_branches_path(project)
+ )
end
def tags_anchor_data
- AnchorData.new(true,
- statistic_icon('label') +
- n_('%{strong_start}%{tag_count}%{strong_end} Tag', '%{strong_start}%{tag_count}%{strong_end} Tags', repository.tag_count).html_safe % {
- tag_count: number_with_delimiter(repository.tag_count),
- strong_start: '<strong class="project-stat-value">'.html_safe,
- strong_end: '</strong>'.html_safe
- },
- empty_repo? ? nil : project_tags_path(project))
+ AnchorData.new(
+ true,
+ statistic_icon('label') +
+ n_('%{strong_start}%{tag_count}%{strong_end} Tag', '%{strong_start}%{tag_count}%{strong_end} Tags', repository.tag_count).html_safe % {
+ tag_count: number_with_delimiter(repository.tag_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ empty_repo? ? nil : project_tags_path(project)
+ )
end
def upload_anchor_data
strong_memoize(:upload_anchor_data) do
next unless can_current_user_push_to_default_branch?
- AnchorData.new(false,
- statistic_icon('upload') + _('Upload file'),
- '#modal-upload-blob',
- 'js-upload-file-trigger',
- nil,
- nil,
- {
- 'target_branch' => default_branch_or_main,
- 'original_branch' => default_branch_or_main,
- 'can_push_code' => 'true',
- 'path' => project_create_blob_path(project, default_branch_or_main),
- 'project_path' => project.full_path
- }
- )
+ AnchorData.new(
+ false,
+ statistic_icon('upload') + _('Upload file'),
+ '#modal-upload-blob',
+ 'js-upload-file-trigger',
+ nil,
+ nil,
+ {
+ 'target_branch' => default_branch_or_main,
+ 'original_branch' => default_branch_or_main,
+ 'can_push_code' => 'true',
+ 'path' => project_create_blob_path(project, default_branch_or_main),
+ 'project_path' => project.full_path
+ }
+ )
end
end
@@ -266,37 +275,38 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if can_current_user_push_to_default_branch?
new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_main) : project_new_blob_path(project, default_branch_or_main)
- AnchorData.new(false,
- statistic_icon + _('New file'),
- new_file_path,
- 'btn-dashed')
+ AnchorData.new(false, statistic_icon + _('New file'), new_file_path, 'btn-dashed')
end
end
def readme_anchor_data
if can_current_user_push_to_default_branch? && readme_path.nil?
- AnchorData.new(false,
- statistic_icon + _('Add README'),
- empty_repo? ? add_readme_ide_path : add_readme_path)
+ AnchorData.new(false, statistic_icon + _('Add README'), empty_repo? ? add_readme_ide_path : add_readme_path)
elsif readme_path
- AnchorData.new(false,
- statistic_icon('doc-text') + _('README'),
- default_view != 'readme' ? readme_path : '#readme',
- 'btn-default',
- 'doc-text')
+ AnchorData.new(
+ false,
+ statistic_icon('doc-text') + _('README'),
+ default_view != 'readme' ? readme_path : '#readme',
+ 'btn-default',
+ 'doc-text'
+ )
end
end
def changelog_anchor_data
if can_current_user_push_to_default_branch? && repository.changelog.blank?
- AnchorData.new(false,
- statistic_icon + _('Add CHANGELOG'),
- empty_repo? ? add_changelog_ide_path : add_changelog_path)
+ AnchorData.new(
+ false,
+ statistic_icon + _('Add CHANGELOG'),
+ empty_repo? ? add_changelog_ide_path : add_changelog_path
+ )
elsif repository.changelog.present?
- AnchorData.new(false,
- statistic_icon('doc-text') + _('CHANGELOG'),
- changelog_path,
- 'btn-default')
+ AnchorData.new(
+ false,
+ statistic_icon('doc-text') + _('CHANGELOG'),
+ changelog_path,
+ 'btn-default'
+ )
end
end
@@ -304,29 +314,37 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
icon = statistic_icon('scale')
if repository.license_blob.present?
- AnchorData.new(false,
- icon + content_tag(:span, license_short_name, class: 'project-stat-value'),
- license_path,
- 'btn-default',
- nil,
- 'license')
+ AnchorData.new(
+ false,
+ icon + content_tag(:span, license_short_name, class: 'project-stat-value'),
+ license_path,
+ 'btn-default',
+ nil,
+ 'license'
+ )
elsif can_current_user_push_to_default_branch?
- AnchorData.new(false,
- content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'),
- empty_repo? ? add_license_ide_path : add_license_path)
+ AnchorData.new(
+ false,
+ content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'),
+ empty_repo? ? add_license_ide_path : add_license_path
+ )
end
end
def contribution_guide_anchor_data
if can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
- AnchorData.new(false,
- statistic_icon + _('Add CONTRIBUTING'),
- empty_repo? ? add_contribution_guide_ide_path : add_contribution_guide_path)
+ AnchorData.new(
+ false,
+ statistic_icon + _('Add CONTRIBUTING'),
+ empty_repo? ? add_contribution_guide_ide_path : add_contribution_guide_path
+ )
elsif repository.contribution_guide.present?
- AnchorData.new(false,
- statistic_icon('doc-text') + _('CONTRIBUTING'),
- contribution_guide_path,
- 'btn-default')
+ AnchorData.new(
+ false,
+ statistic_icon('doc-text') + _('CONTRIBUTING'),
+ contribution_guide_path,
+ 'btn-default'
+ )
end
end
@@ -335,35 +353,32 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
if auto_devops_enabled?
- AnchorData.new(false,
- statistic_icon('settings') + _('Auto DevOps enabled'),
- project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
- 'btn-default')
+ AnchorData.new(
+ false,
+ statistic_icon('settings') + _('Auto DevOps enabled'),
+ project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
+ 'btn-default'
+ )
else
- AnchorData.new(false,
- statistic_icon + _('Enable Auto DevOps'),
- project_settings_ci_cd_path(project, anchor: 'autodevops-settings'))
+ AnchorData.new(
+ false,
+ statistic_icon + _('Enable Auto DevOps'),
+ project_settings_ci_cd_path(project, anchor: 'autodevops-settings')
+ )
end
elsif auto_devops_enabled?
- AnchorData.new(false,
- _('Auto DevOps enabled'),
- nil)
+ AnchorData.new(false, _('Auto DevOps enabled'), nil)
end
end
def kubernetes_cluster_anchor_data
if can_instantiate_cluster?
if clusters.empty?
- AnchorData.new(false,
- statistic_icon + _('Add Kubernetes cluster'),
- project_clusters_path(project))
+ AnchorData.new(false, statistic_icon + _('Add Kubernetes cluster'), project_clusters_path(project))
else
cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
- AnchorData.new(false,
- _('Kubernetes'),
- cluster_link,
- 'btn-default')
+ AnchorData.new(false, _('Kubernetes'), cluster_link, 'btn-default')
end
end
end
@@ -372,14 +387,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
return unless can_view_pipeline_editor?(project)
if cicd_missing?
- AnchorData.new(false,
- statistic_icon + _('Set up CI/CD'),
- project_ci_pipeline_editor_path(project))
+ AnchorData.new(false, statistic_icon + _('Set up CI/CD'), project_ci_pipeline_editor_path(project))
elsif repository.gitlab_ci_yml.present?
- AnchorData.new(false,
- statistic_icon('doc-text') + _('CI/CD configuration'),
- project_ci_pipeline_editor_path(project),
- 'btn-default')
+ AnchorData.new(false, statistic_icon('doc-text') + _('CI/CD configuration'), project_ci_pipeline_editor_path(project), 'btn-default')
end
end
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/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index 2e5d3ae21d9..84e98e18e32 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -40,9 +40,11 @@ class SnippetBlobPresenter < BlobPresenter
end
def render_rich_partial
- renderer.render("projects/blob/viewers/_#{blob.rich_viewer.partial_name}",
- locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url, parent_dir_raw_path: raw_directory },
- layout: false)
+ renderer.render(
+ "projects/blob/viewers/_#{blob.rich_viewer.partial_name}",
+ locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url, parent_dir_raw_path: raw_directory },
+ layout: false
+ )
end
def renderer
diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb
new file mode 100644
index 00000000000..c0394eb38c5
--- /dev/null
+++ b/app/serializers/admin/abuse_report_details_entity.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportDetailsEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :user, if: ->(report) { report.user } do
+ expose :details, merge: true do |report|
+ UserEntity.represent(report.user, only: [:name, :username, :avatar_url, :email, :created_at, :last_activity_on])
+ end
+ expose :path do |report|
+ user_path(report.user)
+ end
+ expose :admin_path do |report|
+ admin_user_path(report.user)
+ end
+ expose :plan do |report|
+ if Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?)
+ report.user.namespace&.actual_plan&.title
+ end
+ end
+ expose :verification_state do
+ expose :email do |report|
+ report.user.confirmed?
+ end
+ expose :phone do |report|
+ report.user.phone_number_validation.present? && report.user.phone_number_validation.validated?
+ end
+ expose :credit_card do |report|
+ report.user.credit_card_validation.present?
+ end
+ end
+ expose :credit_card, if: ->(report) { report.user.credit_card_validation&.holder_name } do
+ expose :name do |report|
+ report.user.credit_card_validation.holder_name
+ end
+ expose :similar_records_count do |report|
+ report.user.credit_card_validation.similar_records.count
+ end
+ expose :card_matches_link do |report|
+ card_match_admin_user_path(report.user) if Gitlab.ee?
+ end
+ end
+ expose :other_reports do |report|
+ AbuseReportEntity.represent(report.other_reports_for_user, only: [:created_at, :category, :report_path])
+ end
+ expose :most_used_ip do |report|
+ AuthenticationEvent.most_used_ip_address_for_user(report.user)
+ end
+ expose :last_sign_in_ip do |report|
+ report.user.last_sign_in_ip
+ end
+ expose :snippets_count do |report|
+ report.user.snippets.count
+ end
+ expose :groups_count do |report|
+ report.user.groups.count
+ end
+ expose :notes_count do |report|
+ report.user.notes.count
+ end
+ end
+
+ expose :reporter, if: ->(report) { report.reporter } do
+ expose :details, merge: true do |report|
+ UserEntity.represent(report.reporter, only: [:name, :username, :avatar_url])
+ end
+ expose :path do |report|
+ user_path(report.reporter)
+ end
+ end
+
+ expose :report do
+ expose :message
+ expose :created_at, as: :reported_at
+ expose :category
+ expose :report_type, as: :type
+ expose :reported_content, as: :content
+ expose :reported_from_url, as: :url
+ expose :screenshot_path, as: :screenshot
+ end
+
+ expose :actions, if: ->(report) { report.user } do
+ 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 :reported_user do |report|
+ UserEntity.represent(report.user, only: [:name, :created_at])
+ end
+ expose :redirect_path do |_|
+ admin_abuse_reports_path
+ end
+ end
+ end
+end
diff --git a/app/serializers/admin/abuse_report_details_serializer.rb b/app/serializers/admin/abuse_report_details_serializer.rb
new file mode 100644
index 00000000000..ca90de1cf3c
--- /dev/null
+++ b/app/serializers/admin/abuse_report_details_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportDetailsSerializer < BaseSerializer
+ entity Admin::AbuseReportDetailsEntity
+ end
+end
diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb
new file mode 100644
index 00000000000..58637445e81
--- /dev/null
+++ b/app/serializers/admin/abuse_report_entity.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :category
+ expose :created_at
+ expose :updated_at
+
+ expose :reported_user do |report|
+ UserEntity.represent(report.user, only: [:name])
+ end
+
+ expose :reporter do |report|
+ UserEntity.represent(report.reporter, only: [:name])
+ end
+
+ expose :report_path do |report|
+ admin_abuse_report_path(report)
+ end
+ end
+end
diff --git a/app/serializers/admin/abuse_report_serializer.rb b/app/serializers/admin/abuse_report_serializer.rb
new file mode 100644
index 00000000000..af43e459482
--- /dev/null
+++ b/app/serializers/admin/abuse_report_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportSerializer < BaseSerializer
+ entity Admin::AbuseReportEntity
+ 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/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
deleted file mode 100644
index f57ac4af113..00000000000
--- a/app/serializers/cluster_application_entity.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterApplicationEntity < Grape::Entity
- expose :name
- expose :status_name, as: :status
- expose :status_reason
- expose :version, if: -> (e, _) { e.respond_to?(:version) }
- expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
- expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) }
- expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
- expose :email, if: -> (e, _) { e.respond_to?(:email) }
- expose :stack, if: -> (e, _) { e.respond_to?(:stack) }
- expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) }
- expose :can_uninstall?, as: :can_uninstall
- expose :available_domains, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:available_domains) }
- expose :pages_domain, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:pages_domain) }
- expose :host, if: -> (e, _) { e.respond_to?(:host) }
- expose :port, if: -> (e, _) { e.respond_to?(:port) }
- expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) }
-end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index 8e256863bcd..161758debca 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -13,7 +13,6 @@ class ClusterEntity < Grape::Entity
expose :provider_type
expose :status_name, as: :status
expose :status_reason
- expose :applications, using: ClusterApplicationEntity
expose :path do |cluster|
Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 30b8863efa2..a4e12e51f69 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -22,6 +22,6 @@ class ClusterSerializer < BaseSerializer
end
def represent_status(resource)
- represent(resource, { only: [:status, :status_reason, :applications] })
+ represent(resource, { only: [:status, :status_reason] })
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_entity.rb b/app/serializers/environment_entity.rb
index 3473b4aebc8..6457127d831 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -27,7 +27,7 @@ class EnvironmentEntity < Grape::Entity
ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT))
end
- expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment|
+ expose :metrics_path, if: -> (*) { expose_metrics_path? } do |environment|
metrics_project_environment_path(environment.project, environment)
end
@@ -101,6 +101,10 @@ class EnvironmentEntity < Grape::Entity
def cluster
deployment_platform.cluster
end
+
+ def expose_metrics_path?
+ !Feature.enabled?(:remove_monitor_metrics) && environment.has_metrics?
+ end
end
EnvironmentEntity.prepend_mod_with('EnvironmentEntity')
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/environment_status_entity.rb b/app/serializers/environment_status_entity.rb
index e8cf7980f5e..9318e0c1de8 100644
--- a/app/serializers/environment_status_entity.rb
+++ b/app/serializers/environment_status_entity.rb
@@ -23,6 +23,10 @@ class EnvironmentStatusEntity < Grape::Entity
stop_project_environment_path(es.project, es.environment)
end
+ expose :retry_url, if: ->(*) { can_rollback_environment? } do |es|
+ retry_project_job_path(es.project, es.deployment.deployable)
+ end
+
expose :external_url do |es|
es.environment.external_url
end
@@ -41,6 +45,10 @@ class EnvironmentStatusEntity < Grape::Entity
DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build]))
end
+ expose :environment_available do |es|
+ es.environment.available?
+ end
+
expose :changes
private
@@ -68,4 +76,8 @@ class EnvironmentStatusEntity < Grape::Entity
def can_stop_environment?
can?(current_user, :stop_environment, environment)
end
+
+ def can_rollback_environment?
+ object.deployable && can?(current_user, :play_job, object.deployable)
+ end
end
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/import/github_failure_entity.rb b/app/serializers/import/github_failure_entity.rb
new file mode 100644
index 00000000000..8d8b16c2a6d
--- /dev/null
+++ b/app/serializers/import/github_failure_entity.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+module Import
+ class GithubFailureEntity < Grape::Entity
+ expose :type do |failure|
+ failure.external_identifiers['object_type']
+ end
+
+ expose :title do |failure|
+ title(failure)
+ end
+
+ expose :provider_url do |failure|
+ build_url(failure)
+ end
+
+ expose :details do
+ expose :exception_class
+ expose :exception_message
+ expose :correlation_id_value
+ expose :source
+ expose :created_at
+ expose :github_identifiers do
+ with_options(expose_nil: false) do
+ expose(:object_type) { |failure| failure.external_identifiers['object_type'] }
+ expose(:id) { |failure| failure.external_identifiers['id'] }
+ expose(:db_id) { |failure| failure.external_identifiers['db_id'] }
+ expose(:iid) { |failure| failure.external_identifiers['iid'] }
+ expose(:title) { |failure| failure.external_identifiers['title'] }
+ expose(:login) { |failure| failure.external_identifiers['login'] }
+ expose(:event) { |failure| failure.external_identifiers['event'] }
+ expose(:merge_request_id) { |failure| failure.external_identifiers['merge_request_id'] }
+ expose(:merge_request_iid) { |failure| failure.external_identifiers['merge_request_iid'] }
+ expose(:requested_reviewers) { |failure| failure.external_identifiers['requested_reviewers'] }
+ expose(:note_id) { |failure| failure.external_identifiers['note_id'] }
+ expose(:noteable_type) { |failure| failure.external_identifiers['noteable_type'] }
+ expose(:noteable_iid) { |failure| failure.external_identifiers['noteable_iid'] }
+ expose(:issuable_type) { |failure| failure.external_identifiers['issuable_type'] }
+ expose(:issuable_iid) { |failure| failure.external_identifiers['issuable_iid'] }
+ expose(:review_id) { |failure| failure.external_identifiers['review_id'] }
+ expose(:tag) { |failure| failure.external_identifiers['tag'] }
+ expose(:oid) { |failure| failure.external_identifiers['oid'] }
+ expose(:size) { |failure| failure.external_identifiers['size'] }
+ end
+ end
+ end
+
+ private
+
+ # rubocop:disable Metrics/CyclomaticComplexity
+ def title(failure)
+ gh_identifiers = failure.external_identifiers
+
+ case gh_identifiers['object_type']
+ when 'pull_request', 'issue', 'label', 'milestone'
+ gh_identifiers['title']
+ when 'pull_request_merged_by'
+ format(s_("GithubImporter|Pull request %{pull_request_iid} merger"), pull_request_iid: gh_identifiers['iid'])
+ when 'pull_request_review_request'
+ format(
+ s_("GithubImporter|Pull request %{pull_request_iid} review request"),
+ pull_request_iid: gh_identifiers['merge_request_iid']
+ )
+ when 'pull_request_review'
+ format(s_("GithubImporter|Pull request review %{review_id}"), review_id: gh_identifiers['review_id'])
+ when 'collaborator'
+ gh_identifiers['login']
+ when 'protected_branch'
+ gh_identifiers['id']
+ when 'issue_event'
+ gh_identifiers['event']
+ when 'release'
+ gh_identifiers['tag']
+ when 'note'
+ format(
+ s_("GithubImporter|%{noteable_type} comment %{note_id}"),
+ noteable_type: gh_identifiers['noteable_type'],
+ note_id: gh_identifiers['note_id']
+ )
+ when 'diff_note'
+ format(s_("GithubImporter|Pull request review comment %{note_id}"), note_id: gh_identifiers['note_id'])
+ when 'issue_attachment'
+ format(s_("GithubImporter|Issue %{issue_iid} attachment"), issue_iid: gh_identifiers['noteable_iid'])
+ when 'merge_request_attachment'
+ format(
+ s_("GithubImporter|Merge request %{merge_request_iid} attachment"),
+ merge_request_iid: gh_identifiers['noteable_iid']
+ )
+ when 'release_attachment'
+ format(s_("GithubImporter|Release %{tag} attachment"), tag: gh_identifiers['tag'])
+ when 'note_attachment'
+ s_('GithubImporter|Note attachment')
+ when 'lfs_object'
+ gh_identifiers['oid'].to_s
+ else
+ ''
+ end
+ end
+
+ def build_url(failure)
+ project = failure.project
+ gh_identifiers = failure.external_identifiers
+ github_repo = project.import_source
+
+ host = host(project.import_url)
+ return '' unless host
+
+ case gh_identifiers['object_type']
+ when 'pull_request', 'pull_request_merged_by'
+ # https://github.com/OWNER/REPO/pull/1
+ "#{host}/#{github_repo}/pull/#{gh_identifiers['iid']}"
+ when 'pull_request_review_request'
+ # https://github.com/OWNER/REPO/pull/1
+ "#{host}/#{github_repo}/pull/#{gh_identifiers['merge_request_iid']}"
+ when 'pull_request_review'
+ # https://github.com/OWNER/REPO/pull/1#pullrequestreview-1219894643
+ "#{host}/#{github_repo}/pull/#{gh_identifiers['merge_request_iid']}" \
+ "#pullrequestreview-#{gh_identifiers['review_id']}"
+ when 'issue'
+ # https://github.com/OWNER/REPO/issues/1
+ "#{host}/#{github_repo}/issues/#{gh_identifiers['iid']}"
+ when 'collaborator'
+ # https://github.com/USER_NAME
+ "#{host}/#{gh_identifiers['login']}"
+ when 'protected_branch'
+ branch = escape(gh_identifiers['id'])
+
+ # https://github.com/OWNER/REPO/tree/BRANCH_NAME
+ "#{host}/#{github_repo}/tree/#{branch}"
+ when 'issue_event'
+ # https://github.com/OWNER/REPO/issues/1#event-8356623615
+ "#{host}/#{github_repo}/issues/#{gh_identifiers['issuable_iid']}#event-#{gh_identifiers['id']}"
+ when 'label'
+ label = escape(gh_identifiers['title'])
+
+ # https://github.com/OWNER/REPO/labels/bug
+ "#{host}/#{github_repo}/labels/#{label}"
+ when 'milestone'
+ # https://github.com/OWNER/REPO/milestone/1
+ "#{host}/#{github_repo}/milestone/#{gh_identifiers['iid']}"
+ when 'release', 'release_attachment'
+ tag = escape(gh_identifiers['tag'])
+
+ # https://github.com/OWNER/REPO/releases/tag/v1.0
+ "#{host}/#{github_repo}/releases/tag/#{tag}"
+ when 'note'
+ # https://github.com/OWNER/REPO/issues/2#issuecomment-1480755368
+ "#{host}/#{github_repo}/issues/#{gh_identifiers['noteable_iid']}#issuecomment-#{gh_identifiers['note_id']}"
+ when 'diff_note'
+ # https://github.com/OWNER/REPO/pull/1#discussion_r1050098241
+ "#{host}/#{github_repo}/pull/#{gh_identifiers['noteable_iid']}#discussion_r#{gh_identifiers['note_id']}"
+ when 'issue_attachment'
+ # https://github.com/OWNER/REPO/issues/1
+ "#{host}/#{github_repo}/issues/#{gh_identifiers['noteable_iid']}"
+ when 'merge_request_attachment'
+ # https://github.com/OWNER/REPO/pull/1
+ "#{host}/#{github_repo}/pull/#{gh_identifiers['noteable_iid']}"
+ when 'lfs_object', 'note_attachment'
+ # we can't build url to lfs objects and note attachments
+ ''
+ else
+ ''
+ end
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+
+ def host(uri)
+ parsed_uri = URI.parse(uri)
+ "#{parsed_uri.scheme}://#{parsed_uri.hostname}"
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ def escape(str)
+ CGI.escape(str)
+ end
+ end
+end
diff --git a/app/serializers/import/github_failure_serializer.rb b/app/serializers/import/github_failure_serializer.rb
new file mode 100644
index 00000000000..297b7a71fe2
--- /dev/null
+++ b/app/serializers/import/github_failure_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Import
+ class GithubFailureSerializer < BaseSerializer
+ include WithPagination
+
+ entity Import::GithubFailureEntity
+ end
+end
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/jira_connect/app_data_serializer.rb b/app/serializers/jira_connect/app_data_serializer.rb
index 994ff19f96e..5e275f35beb 100644
--- a/app/serializers/jira_connect/app_data_serializer.rb
+++ b/app/serializers/jira_connect/app_data_serializer.rb
@@ -4,9 +4,8 @@ class JiraConnect::AppDataSerializer
include Gitlab::Routing
include ::API::Helpers::RelatedResourcesHelpers
- def initialize(subscriptions, signed_in)
+ def initialize(subscriptions)
@subscriptions = subscriptions
- @signed_in = signed_in
end
def as_json
@@ -15,14 +14,7 @@ class JiraConnect::AppDataSerializer
{
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
subscriptions: JiraConnect::SubscriptionEntity.represent(@subscriptions).as_json,
- subscriptions_path: jira_connect_subscriptions_path,
- login_path: signed_in? ? nil : jira_connect_users_path
+ subscriptions_path: jira_connect_subscriptions_path
}
end
-
- private
-
- def signed_in?
- !!@signed_in
- end
end
diff --git a/app/serializers/linked_issue_entity.rb b/app/serializers/linked_issue_entity.rb
index 4a28213fbac..9f24b465248 100644
--- a/app/serializers/linked_issue_entity.rb
+++ b/app/serializers/linked_issue_entity.rb
@@ -7,7 +7,7 @@ class LinkedIssueEntity < Grape::Entity
item.try(:upcase)
end
- expose :id, :confidential, :title
+ expose :id, :iid, :confidential, :title
expose :assignees, using: UserEntity
@@ -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/note_entity.rb b/app/serializers/note_entity.rb
index 679f829e852..6058c89d347 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -12,6 +12,8 @@ class NoteEntity < API::Entities::Note
expose :type
+ expose :external_author
+
expose :author, using: NoteUserEntity
unexpose :note, as: :body
@@ -105,6 +107,18 @@ class NoteEntity < API::Entities::Note
def with_base_discussion?
options.fetch(:with_base_discussion, true)
end
+
+ def external_author
+ return unless Feature.enabled?(:external_note_author_service_desk)
+
+ return unless object.note_metadata&.external_author
+
+ if can?(current_user, :read_external_emails, object.project)
+ object.note_metadata.external_author
+ else
+ Gitlab::Utils::Email.obfuscated_email(object.note_metadata.external_author, deform: true)
+ end
+ end
end
NoteEntity.prepend_mod_with('NoteEntity')
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index 76797a773b5..6c20f665bfa 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -8,8 +8,14 @@ class PipelineDetailsEntity < Ci::PipelineEntity
end
expose :details do
- expose :manual_actions, using: BuildActionEntity
- expose :scheduled_actions, using: BuildActionEntity
+ expose :manual_actions, unless: proc { options[:disable_manual_and_scheduled_actions] }, using: BuildActionEntity
+ expose :scheduled_actions, unless: proc { options[:disable_manual_and_scheduled_actions] }, using: BuildActionEntity
+ expose :has_manual_actions do |pipeline|
+ pipeline.manual_actions.any?
+ end
+ expose :has_scheduled_actions do |pipeline|
+ pipeline.scheduled_actions.any?
+ end
end
expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity
diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb
new file mode 100644
index 00000000000..fe90265c888
--- /dev/null
+++ b/app/serializers/profile/event_entity.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Profile
+ class EventEntity < Grape::Entity
+ include ActionView::Helpers::SanitizeHelper
+ include RequestAwareEntity
+ include MarkupHelper
+ include MergeRequestsHelper
+ include EventsHelper
+
+ expose :created_at, if: ->(event) { include_private_event?(event) }
+ expose(:action, if: ->(event) { include_private_event?(event) }) { |event| event_action(event) }
+
+ expose :ref, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
+ expose(:type) { |event| event.ref_type } # rubocop:disable Style/SymbolProc
+ expose(:count) { |event| event.ref_count } # rubocop:disable Style/SymbolProc
+ expose(:name) { |event| event.ref_name } # rubocop:disable Style/SymbolProc
+ expose(:path) { |event| ref_path(event) }
+ end
+
+ expose :commit, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
+ expose(:truncated_sha) { |event| Commit.truncate_sha(event.commit_id) }
+ expose(:path) { |event| project_commit_path(event.project, event.commit_id) }
+ expose(:title) { |event| event_commit_title(event.commit_title) }
+ expose(:count) { |event| event.commits_count } # rubocop:disable Style/SymbolProc
+ expose(:create_mr_path) { |event| commit_create_mr_path(event) }
+ expose(:from_truncated_sha) { |event| commit_from(event) if event.commit_from }
+ expose(:to_truncated_sha) { |event| Commit.truncate_sha(event.commit_to) if event.commit_to }
+
+ expose :compare_path, if: ->(event) { event.push_with_commits? && event.commits_count > 1 } do |event|
+ project = event.project
+ from = event.md_ref? ? event.commit_from : project.default_branch
+ project_compare_path(project, from: from, to: event.commit_to)
+ end
+ end
+
+ expose :author, if: ->(event) { include_private_event?(event) } do
+ expose(:id) { |event| event.author.id }
+ expose(:name) { |event| event.author.name }
+ expose(:path) { |event| event.author.username }
+ end
+
+ expose :target, if: ->(event) { event.visible_to_user?(current_user) } do
+ expose :target_type
+
+ expose(:title) { |event| event.target_title } # rubocop:disable Style/SymbolProc
+ expose :target_url, if: ->(event) { event.target } do |event|
+ Gitlab::UrlBuilder.build(event.target, only_path: true)
+ end
+ expose :reference_link_text, if: ->(event) { event.target&.respond_to?(:reference_link_text) } do |event|
+ event.target.reference_link_text
+ end
+ expose :first_line_in_markdown, if: ->(event) { event.note? && event.target && event.project } do |event|
+ first_line_in_markdown(event.target, :note, 150, project: event.project)
+ end
+ expose :attachment, if: ->(event) { event.note? && event.target&.attachment } do
+ expose(:url) { |event| event.target.attachment.url }
+ end
+ end
+
+ expose :resource_parent, if: ->(event) { event.visible_to_user?(current_user) } do
+ expose(:type) { |event| resource_parent_type(event) }
+ expose(:full_name) { |event| event.resource_parent&.full_name }
+ expose(:full_path) { |event| event.resource_parent&.full_path }
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+
+ def target_user
+ request.target_user
+ end
+
+ def include_private_event?(event)
+ event.visible_to_user?(current_user) || target_user.include_private_contributions?
+ end
+
+ def commit_from(event)
+ if event.md_ref?
+ Commit.truncate_sha(event.commit_from)
+ else
+ event.project.default_branch
+ end
+ end
+
+ def event_action(event)
+ if event.visible_to_user?(current_user)
+ event.action
+ elsif target_user.include_private_contributions?
+ 'private'
+ end
+ end
+
+ def ref_path(event)
+ project = event.project
+ commits_link = project_commits_path(project, event.ref_name)
+ should_link = if event.tag?
+ project.repository.tag_exists?(event.ref_name)
+ else
+ project.repository.branch_exists?(event.ref_name)
+ end
+
+ should_link ? commits_link : nil
+ end
+
+ def commit_create_mr_path(event)
+ if event.new_ref? &&
+ create_mr_button_from_event?(event) &&
+ event.authored_by?(current_user)
+ create_mr_path_from_push_event(event)
+ end
+ end
+
+ def resource_parent_type(event)
+ if event.project
+ "project"
+ elsif event.group
+ "group"
+ end
+ end
+ end
+end
diff --git a/app/serializers/profile/event_serializer.rb b/app/serializers/profile/event_serializer.rb
new file mode 100644
index 00000000000..c7f23d61fe1
--- /dev/null
+++ b/app/serializers/profile/event_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Profile
+ class EventSerializer < BaseSerializer
+ entity Profile::EventEntity
+ end
+end
diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb
index 58360321f7c..302086143c1 100644
--- a/app/serializers/project_import_entity.rb
+++ b/app/serializers/project_import_entity.rb
@@ -16,4 +16,11 @@ class ProjectImportEntity < ProjectEntity
expose :import_error, if: ->(project) { project.import_state&.failed? } do |project|
project.import_failures.last&.exception_message
end
+
+ # Only for GitHub importer where we pass client through
+ expose :relation_type do |project, options|
+ next nil if options[:client].nil? || Feature.disabled?(:remove_legacy_github_client)
+
+ ::Gitlab::GithubImport::ProjectRelationType.new(options[:client]).for(project.import_source)
+ end
end
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/runner_entity.rb b/app/serializers/runner_entity.rb
index 6d6ba920a3b..cf0b2e2c3f6 100644
--- a/app/serializers/runner_entity.rb
+++ b/app/serializers/runner_entity.rb
@@ -9,6 +9,10 @@ class RunnerEntity < Grape::Entity
edit_project_runner_path(project, runner)
end
+ expose :admin_path, if: -> (*) { can_admin_runner? } do |runner|
+ Gitlab::Routing.url_helpers.admin_runner_url(runner)
+ end
+
private
alias_method :runner, :object
@@ -17,7 +21,17 @@ class RunnerEntity < Grape::Entity
request.project
end
+ def current_user
+ request.current_user
+ end
+
def can_edit_runner?
- can?(request.current_user, :update_runner, runner) && runner.project_type?
+ can?(current_user, :update_runner, runner) && runner.project_type?
+ end
+
+ # can_admin_all_resources? is used here because the
+ # path exposed is only available to admins
+ def can_admin_runner?
+ current_user&.can_admin_all_resources?
end
end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index f278ccfce73..aa570988010 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
@@ -71,7 +67,7 @@ class StageEntity < Grape::Entity
end
def preload_metadata(statuses)
- Preloaders::CommitStatusPreloader.new(statuses).execute([:metadata])
+ Preloaders::CommitStatusPreloader.new(statuses).execute([:metadata, :pipeline])
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
new file mode 100644
index 00000000000..3cefb0442d5
--- /dev/null
+++ b/app/services/achievements/award_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Achievements
+ class AwardService
+ attr_reader :current_user, :achievement_id, :recipient_id
+
+ def initialize(current_user, achievement_id, recipient_id)
+ @current_user = current_user
+ @achievement_id = achievement_id
+ @recipient_id = recipient_id
+ end
+
+ def execute
+ achievement = Achievements::Achievement.find(achievement_id)
+ return error_no_permissions unless allowed?(achievement)
+
+ recipient = User.find(recipient_id)
+
+ user_achievement = Achievements::UserAchievement.create(
+ achievement: achievement,
+ user: recipient,
+ 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)
+ end
+
+ private
+
+ def allowed?(achievement)
+ current_user&.can?(:award_achievement, achievement)
+ end
+
+ def error_no_permissions
+ error('You have insufficient permissions to award this achievement')
+ end
+
+ def error_awarding(user_achievement)
+ error(user_achievement&.errors&.full_messages || 'Failed to award achievement')
+ end
+
+ def error(message)
+ ServiceResponse.error(message: Array(message))
+ end
+ end
+end
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/revoke_service.rb b/app/services/achievements/revoke_service.rb
new file mode 100644
index 00000000000..4601622f517
--- /dev/null
+++ b/app/services/achievements/revoke_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Achievements
+ class RevokeService
+ attr_reader :current_user, :user_achievement
+
+ def initialize(current_user, user_achievement)
+ @current_user = current_user
+ @user_achievement = user_achievement
+ end
+
+ def execute
+ return error_no_permissions unless allowed?(user_achievement.achievement)
+ return error_already_revoked if user_achievement.revoked?
+
+ user_achievement.assign_attributes({
+ revoked_by_user_id: current_user.id,
+ revoked_at: Time.zone.now
+ })
+ return error_awarding unless user_achievement.save
+
+ ServiceResponse.success(payload: user_achievement)
+ end
+
+ private
+
+ def allowed?(achievement)
+ current_user&.can?(:award_achievement, achievement)
+ end
+
+ def error_no_permissions
+ error('You have insufficient permissions to revoke this achievement')
+ end
+
+ def error_already_revoked
+ error('This achievement has already been revoked')
+ end
+
+ def error_awarding
+ error(user_achievement&.errors&.full_messages || 'Failed to revoke 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/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb
new file mode 100644
index 00000000000..5b2ad27ede4
--- /dev/null
+++ b/app/services/admin/abuse_report_update_service.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module Admin
+ class AbuseReportUpdateService < BaseService
+ attr_reader :abuse_report, :params, :current_user, :action
+
+ def initialize(abuse_report, current_user, params)
+ @abuse_report = abuse_report
+ @current_user = current_user
+ @params = params
+ @action = determine_action
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'Admin is required') unless current_user&.can_admin_all_resources?
+ return ServiceResponse.error(message: 'Action is required') unless action.present?
+
+ result = perform_action
+ if result[:status] == :success
+ close_report_and_record_event
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: result[:message])
+ end
+ end
+
+ private
+
+ def determine_action
+ action = params[:user_action]
+ if action.in?(ResourceEvents::AbuseReportEvent.actions.keys)
+ action.to_sym
+ elsif close_report?
+ :close_report
+ end
+ end
+
+ def perform_action
+ case action
+ when :ban_user then ban_user
+ when :block_user then block_user
+ when :delete_user then delete_user
+ when :close_report then close_report
+ end
+ end
+
+ def ban_user
+ Users::BanService.new(current_user).execute(abuse_report.user)
+ end
+
+ def block_user
+ Users::BlockService.new(current_user).execute(abuse_report.user)
+ end
+
+ def delete_user
+ abuse_report.user.delete_async(deleted_by: current_user)
+ success
+ end
+
+ def close_report
+ abuse_report.closed!
+ success
+ end
+
+ def close_report_and_record_event
+ event = action
+
+ if close_report? && action != :close_report
+ close_report
+ event = "#{action}_and_close_report"
+ end
+
+ record_event(event)
+ end
+
+ def close_report?
+ params[:close].to_s == 'true'
+ end
+
+ def record_event(action)
+ reason = params[:reason]
+ unless reason.in?(ResourceEvents::AbuseReportEvent.reasons.keys)
+ reason = ResourceEvents::AbuseReportEvent.reasons[:other]
+ end
+
+ abuse_report.events.create(action: action, user: current_user, reason: reason, comment: params[:comment])
+ end
+ end
+end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 509c2d4d544..3827d199325 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -65,7 +65,12 @@ module Auth
token.expire_time = token_expire_at
token[:access] = names.map do |name|
- { type: type, name: name, actions: actions }
+ {
+ type: type,
+ name: name,
+ actions: actions,
+ meta: access_metadata(path: name)
+ }.compact
end
token.encoded
@@ -75,6 +80,28 @@ module Auth
Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
end
+ def self.access_metadata(project: nil, path: nil)
+ # If the project is not given, try to infer it from the provided path
+ if project.nil?
+ return if path.nil? # If no path is given, return early
+ return if path == 'import' # Ignore the special 'import' path
+
+ # If the path ends with '/*', remove it so we can parse the actual repository path
+ path = path.chomp('/*')
+
+ # Parse the repository project from the path
+ begin
+ project = ContainerRegistry::Path.new(path).repository_project
+ rescue ContainerRegistry::Path::InvalidRegistryPathError
+ # If the path is invalid, gracefully handle the error
+ return
+ end
+ end
+
+ # Return the project path (lowercase) as metadata
+ { project_path: project&.full_path&.downcase }
+ end
+
private
def authorized_token(*accesses)
@@ -138,7 +165,12 @@ module Auth
#
ensure_container_repository!(path, authorized_actions)
- { type: type, name: path.to_s, actions: authorized_actions }
+ {
+ type: type,
+ name: path.to_s,
+ actions: authorized_actions,
+ meta: self.class.access_metadata(project: requested_project)
+ }
end
def actively_importing?(actions, path)
diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb
index 8d60fffd959..cb83dc57478 100644
--- a/app/services/authorized_project_update/project_recalculate_service.rb
+++ b/app/services/authorized_project_update/project_recalculate_service.rb
@@ -82,3 +82,5 @@ module AuthorizedProjectUpdate
end
end
end
+
+AuthorizedProjectUpdate::ProjectRecalculateService.prepend_mod
diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb
index 86df0236a7f..f46e8d5ec42 100644
--- a/app/services/base_container_service.rb
+++ b/app/services/base_container_service.rb
@@ -10,13 +10,17 @@
# the top of the original BaseService.
class BaseContainerService
include BaseServiceUtility
+ include ::Gitlab::Utils::StrongMemoize
+ attr_accessor :project, :group
attr_reader :container, :current_user, :params
def initialize(container:, current_user: nil, params: {})
@container = container
@current_user = current_user
@params = params.dup
+
+ handle_container_type(container)
end
def project_container?
@@ -30,4 +34,22 @@ class BaseContainerService
def namespace_container?
container.is_a?(::Namespace)
end
+
+ def project_group
+ project&.group
+ end
+ strong_memoize_attr :project_group
+
+ private
+
+ def handle_container_type(container)
+ case container
+ when Project
+ @project = container
+ when Group
+ @group = container
+ when Namespaces::ProjectNamespace
+ @project = container.project
+ 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/archive_extraction_service.rb b/app/services/bulk_imports/archive_extraction_service.rb
index caa40d98a76..fec8fd0e1f5 100644
--- a/app/services/bulk_imports/archive_extraction_service.rb
+++ b/app/services/bulk_imports/archive_extraction_service.rb
@@ -33,7 +33,6 @@ module BulkImports
validate_symlink
extract_archive
- remove_symlinks
tmpdir
end
@@ -60,15 +59,5 @@ module BulkImports
def extract_archive
untar_xf(archive: filepath, dir: tmpdir)
end
-
- def extracted_files
- Dir.glob(File.join(tmpdir, '**', '*'))
- end
-
- def remove_symlinks
- extracted_files.each do |path|
- FileUtils.rm(path) if symlink?(path)
- end
- end
end
end
diff --git a/app/services/bulk_imports/batched_relation_export_service.rb b/app/services/bulk_imports/batched_relation_export_service.rb
new file mode 100644
index 00000000000..778510f2e35
--- /dev/null
+++ b/app/services/bulk_imports/batched_relation_export_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class BatchedRelationExportService
+ include Gitlab::Utils::StrongMemoize
+
+ BATCH_SIZE = 1000
+ BATCH_CACHE_KEY = 'bulk_imports/batched_relation_export/%{export_id}/%{batch_id}'
+ CACHE_DURATION = 4.hours
+
+ def self.cache_key(export_id, batch_id)
+ Kernel.format(BATCH_CACHE_KEY, export_id: export_id, batch_id: batch_id)
+ end
+
+ def initialize(user, portable, relation, jid)
+ @user = user
+ @portable = portable
+ @relation = relation
+ @resolved_relation = portable.public_send(relation) # rubocop:disable GitlabSecurity/PublicSend
+ @jid = jid
+ end
+
+ def execute
+ return finish_export! if batches_count == 0
+
+ start_export!
+ export.batches.destroy_all # rubocop: disable Cop/DestroyAll
+ enqueue_batch_exports
+ rescue StandardError => e
+ fail_export!(e)
+ ensure
+ FinishBatchedRelationExportWorker.perform_async(export.id)
+ end
+
+ private
+
+ attr_reader :user, :portable, :relation, :jid, :config, :resolved_relation
+
+ def export
+ @export ||= portable.bulk_import_exports.find_or_create_by!(relation: relation) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def objects_count
+ resolved_relation.count
+ end
+
+ def batches_count
+ objects_count.fdiv(BATCH_SIZE).ceil
+ end
+
+ def start_export!
+ update_export!('start')
+ end
+
+ def finish_export!
+ update_export!('finish')
+ end
+
+ def update_export!(event)
+ export.update!(
+ status_event: event,
+ total_objects_count: objects_count,
+ batched: true,
+ batches_count: batches_count,
+ jid: jid,
+ error: nil
+ )
+ end
+
+ def enqueue_batch_exports
+ resolved_relation.each_batch(of: BATCH_SIZE) do |batch, batch_number|
+ batch_id = find_or_create_batch(batch_number).id
+ ids = batch.pluck(batch.model.primary_key) # rubocop:disable CodeReuse/ActiveRecord
+
+ Gitlab::Cache::Import::Caching.set_add(self.class.cache_key(export.id, batch_id), ids, timeout: CACHE_DURATION)
+
+ RelationBatchExportWorker.perform_async(user.id, batch_id)
+ end
+ end
+
+ def find_or_create_batch(batch_number)
+ export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def fail_export!(exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ export.update!(status_event: 'fail_op', error: exception.message.truncate(255))
+ end
+ end
+end
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/bulk_imports/export_service.rb b/app/services/bulk_imports/export_service.rb
index 33b3a8e187f..1b60a8e0ff3 100644
--- a/app/services/bulk_imports/export_service.rb
+++ b/app/services/bulk_imports/export_service.rb
@@ -2,14 +2,20 @@
module BulkImports
class ExportService
- def initialize(portable:, user:)
+ # @param portable [Project|Group] A project or a group to export.
+ # @param user [User] A user performing the export.
+ # @param batched [Boolean] Whether to export the data in batches.
+ def initialize(portable:, user:, batched: false)
@portable = portable
@current_user = user
+ @batched = batched
end
def execute
+ validate_user_permissions!
+
FileTransfer.config_for(portable).portable_relations.each do |relation|
- RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation)
+ RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation, batched)
end
ServiceResponse.success
@@ -22,6 +28,13 @@ module BulkImports
private
- attr_reader :portable, :current_user
+ attr_reader :portable, :current_user, :batched
+
+ def validate_user_permissions!
+ ability = "admin_#{portable.to_ability_name}"
+
+ current_user.can?(ability, portable) ||
+ raise(::Gitlab::ImportExport::Error.permission_error(current_user, portable))
+ end
end
end
diff --git a/app/services/bulk_imports/file_export_service.rb b/app/services/bulk_imports/file_export_service.rb
index b2d114368a1..8b073f65769 100644
--- a/app/services/bulk_imports/file_export_service.rb
+++ b/app/services/bulk_imports/file_export_service.rb
@@ -4,39 +4,58 @@ module BulkImports
class FileExportService
include Gitlab::ImportExport::CommandLineUtil
- def initialize(portable, export_path, relation)
+ SINGLE_OBJECT_RELATIONS = [
+ FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION,
+ FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
+ ].freeze
+
+ def initialize(portable, export_path, relation, user)
@portable = portable
@export_path = export_path
@relation = relation
+ @user = user # not used anywhere in this class at the moment
end
- def execute
- export_service.execute
+ def execute(options = {})
+ export_service.execute(options)
archive_exported_data
end
+ def export_batch(ids)
+ execute(batch_ids: ids)
+ end
+
def exported_filename
"#{relation}.tar"
end
+ def exported_objects_count
+ case relation
+ when *SINGLE_OBJECT_RELATIONS
+ 1
+ else
+ export_service.exported_objects_count
+ end
+ end
+
private
attr_reader :export_path, :portable, :relation
def export_service
- case relation
- when FileTransfer::BaseConfig::UPLOADS_RELATION
- UploadsExportService.new(portable, export_path)
- when FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION
- LfsObjectsExportService.new(portable, export_path)
- when FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION
- RepositoryBundleExportService.new(portable.repository, export_path, relation)
- when FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
- RepositoryBundleExportService.new(portable.design_repository, export_path, relation)
- else
- raise BulkImports::Error, 'Unsupported relation export type'
- end
+ @export_service ||= case relation
+ when FileTransfer::BaseConfig::UPLOADS_RELATION
+ UploadsExportService.new(portable, export_path)
+ when FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION
+ LfsObjectsExportService.new(portable, export_path)
+ when FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION
+ RepositoryBundleExportService.new(portable.repository, export_path, relation)
+ when FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
+ RepositoryBundleExportService.new(portable.design_repository, export_path, relation)
+ else
+ raise BulkImports::Error, 'Unsupported relation export type'
+ end
end
def archive_exported_data
diff --git a/app/services/bulk_imports/lfs_objects_export_service.rb b/app/services/bulk_imports/lfs_objects_export_service.rb
index b3b7cddf2d9..3020e8ababb 100644
--- a/app/services/bulk_imports/lfs_objects_export_service.rb
+++ b/app/services/bulk_imports/lfs_objects_export_service.rb
@@ -6,16 +6,26 @@ module BulkImports
BATCH_SIZE = 100
+ attr_reader :exported_objects_count
+
def initialize(portable, export_path)
@portable = portable
@export_path = export_path
@lfs_json = {}
+ @exported_objects_count = 0
end
- def execute
- portable.lfs_objects.find_in_batches(batch_size: BATCH_SIZE) do |batch| # rubocop: disable CodeReuse/ActiveRecord
+ def execute(options = {})
+ relation = portable.lfs_objects
+
+ if options[:batch_ids]
+ relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ relation.find_in_batches(batch_size: BATCH_SIZE) do |batch| # rubocop: disable CodeReuse/ActiveRecord
batch.each do |lfs_object|
save_lfs_object(lfs_object)
+ @exported_objects_count += 1
end
append_lfs_json_for_batch(batch)
diff --git a/app/services/bulk_imports/relation_batch_export_service.rb b/app/services/bulk_imports/relation_batch_export_service.rb
new file mode 100644
index 00000000000..19eb550216d
--- /dev/null
+++ b/app/services/bulk_imports/relation_batch_export_service.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class RelationBatchExportService
+ include Gitlab::ImportExport::CommandLineUtil
+
+ def initialize(user_id, batch_id)
+ @user = User.find(user_id)
+ @batch = BulkImports::ExportBatch.find(batch_id)
+ @config = FileTransfer.config_for(portable)
+ end
+
+ def execute
+ start_batch!
+
+ export_service.export_batch(relation_batch_ids)
+ compress_exported_relation
+ upload_compressed_file
+
+ finish_batch!
+ rescue StandardError => e
+ fail_batch!(e)
+ ensure
+ FileUtils.remove_entry(export_path)
+ end
+
+ private
+
+ attr_reader :user, :batch, :config
+
+ delegate :export_path, to: :config
+ delegate :batch_number, :export, to: :batch
+ delegate :portable, :relation, to: :export
+ delegate :exported_filename, :exported_objects_count, to: :export_service
+
+ def export_service
+ @export_service ||= config.export_service_for(relation).new(portable, export_path, relation, user)
+ end
+
+ def compress_exported_relation
+ gzip(dir: export_path, filename: exported_filename)
+ end
+
+ def upload_compressed_file
+ File.open(compressed_filename) { |file| batch_upload.export_file = file }
+
+ batch_upload.save!
+ end
+
+ def batch_upload
+ @batch_upload ||= ::BulkImports::ExportUpload.find_or_initialize_by(export_id: export.id, batch_id: batch.id) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def compressed_filename
+ File.join(export_path, "#{exported_filename}.gz")
+ end
+
+ def relation_batch_ids
+ Gitlab::Cache::Import::Caching.values_from_set(cache_key).map(&:to_i)
+ end
+
+ def cache_key
+ BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+ end
+
+ def start_batch!
+ batch.update!(status_event: 'start', objects_count: 0, error: nil)
+ end
+
+ def finish_batch!
+ batch.update!(status_event: 'finish', objects_count: exported_objects_count, error: nil)
+ end
+
+ def fail_batch!(exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ batch.update!(status_event: 'fail_op', error: exception.message.truncate(255))
+ end
+ end
+end
diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb
index b1efa881180..142bc48efe3 100644
--- a/app/services/bulk_imports/relation_export_service.rb
+++ b/app/services/bulk_imports/relation_export_service.rb
@@ -22,36 +22,27 @@ module BulkImports
upload_compressed_file(export)
end
ensure
- FileUtils.remove_entry(config.export_path)
+ FileUtils.remove_entry(export_path)
end
private
attr_reader :user, :portable, :relation, :jid, :config
- def find_or_create_export!
- validate_user_permissions!
+ delegate :export_path, to: :config
+ def find_or_create_export!
export = portable.bulk_import_exports.safe_find_or_create_by!(relation: relation)
- return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago
+ return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago && !export.batched?
- export.update!(status_event: 'start', jid: jid)
+ start_export!(export)
yield export
- export.update!(status_event: 'finish', error: nil)
+ finish_export!(export)
rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, portable_id: portable.id, portable_type: portable.class.name)
-
- export&.update(status_event: 'fail_op', error: e.class)
- end
-
- def validate_user_permissions!
- ability = "admin_#{portable.to_ability_name}"
-
- user.can?(ability, portable) ||
- raise(::Gitlab::ImportExport::Error.permission_error(user, portable))
+ fail_export!(export, e)
end
def remove_existing_export_file!(export)
@@ -65,16 +56,16 @@ module BulkImports
def export_service
@export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
- TreeExportService.new(portable, config.export_path, relation, user)
+ TreeExportService.new(portable, export_path, relation, user)
elsif config.file_relation?(relation)
- FileExportService.new(portable, config.export_path, relation)
+ FileExportService.new(portable, export_path, relation, user)
else
raise BulkImports::Error, 'Unsupported export relation'
end
end
def upload_compressed_file(export)
- compressed_file = File.join(config.export_path, "#{export_service.exported_filename}.gz")
+ compressed_file = File.join(export_path, "#{export_service.exported_filename}.gz")
upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord
@@ -84,7 +75,30 @@ module BulkImports
end
def compress_exported_relation
- gzip(dir: config.export_path, filename: export_service.exported_filename)
+ gzip(dir: export_path, filename: export_service.exported_filename)
+ end
+
+ def start_export!(export)
+ export.update!(
+ status_event: 'start',
+ jid: jid,
+ batched: false,
+ batches_count: 0,
+ total_objects_count: 0,
+ error: nil
+ )
+
+ export.batches.destroy_all if export.batches.any? # rubocop:disable Cop/DestroyAll
+ end
+
+ def finish_export!(export)
+ export.update!(status_event: 'finish', batched: false, error: nil)
+ end
+
+ def fail_export!(export, exception)
+ Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name)
+
+ export&.update(status_event: 'fail_op', error: exception.class, batched: false)
end
end
end
diff --git a/app/services/bulk_imports/repository_bundle_export_service.rb b/app/services/bulk_imports/repository_bundle_export_service.rb
index 86159f5189d..441cced2f7f 100644
--- a/app/services/bulk_imports/repository_bundle_export_service.rb
+++ b/app/services/bulk_imports/repository_bundle_export_service.rb
@@ -8,7 +8,7 @@ module BulkImports
@export_filename = export_filename
end
- def execute
+ def execute(_options = {})
return unless repository_exists?
repository.bundle_to_disk(bundle_filepath)
diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb
index b6f094da558..0aad271f40f 100644
--- a/app/services/bulk_imports/tree_export_service.rb
+++ b/app/services/bulk_imports/tree_export_service.rb
@@ -2,6 +2,10 @@
module BulkImports
class TreeExportService
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :exported_objects_count, to: :serializer
+
def initialize(portable, export_path, relation, user)
@portable = portable
@export_path = export_path
@@ -11,43 +15,52 @@ module BulkImports
end
def execute
- return serializer.serialize_root(config.class::SELF_RELATION) if self_relation?
-
- relation_definition = config.tree_relation_definition_for(relation)
-
- raise BulkImports::Error, 'Unsupported relation export type' unless relation_definition
+ if self_relation?(relation)
+ serializer.serialize_root(config.class::SELF_RELATION)
+ else
+ serializer.serialize_relation(relation_definition)
+ end
+ end
- serializer.serialize_relation(relation_definition)
+ def export_batch(ids)
+ serializer.serialize_relation(relation_definition, batch_ids: Array.wrap(ids))
end
def exported_filename
- return "#{relation}.json" if self_relation?
-
- "#{relation}.ndjson"
+ "#{relation}.#{extension}"
end
private
+ delegate :self_relation?, to: :config
+
attr_reader :export_path, :portable, :relation, :config, :user
# rubocop: disable CodeReuse/Serializer
def serializer
- ::Gitlab::ImportExport::Json::StreamingSerializer.new(
+ @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new(
portable,
config.portable_tree,
- json_writer,
+ ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path),
exportable_path: '',
current_user: user
)
end
# rubocop: enable CodeReuse/Serializer
- def json_writer
- ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path)
+ def extension
+ return 'json' if self_relation?(relation)
+
+ 'ndjson'
end
- def self_relation?
- relation == config.class::SELF_RELATION
+ def relation_definition
+ definition = config.tree_relation_definition_for(relation)
+
+ raise BulkImports::Error, 'Unsupported relation export type' unless definition
+
+ definition
end
+ strong_memoize_attr :relation_definition
end
end
diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb
index 315590bea31..4d55f159af4 100644
--- a/app/services/bulk_imports/uploads_export_service.rb
+++ b/app/services/bulk_imports/uploads_export_service.rb
@@ -7,13 +7,22 @@ module BulkImports
BATCH_SIZE = 100
AVATAR_PATH = 'avatar'
+ attr_reader :exported_objects_count
+
def initialize(portable, export_path)
@portable = portable
@export_path = export_path
+ @exported_objects_count = 0
end
- def execute
- portable.uploads.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
+ def execute(options = {})
+ relation = portable.uploads
+
+ if options[:batch_ids]
+ relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ relation.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
uploader = upload.retrieve_uploader
next unless upload.exist?
@@ -22,6 +31,7 @@ module BulkImports
subdir_path = export_subdir_path(upload)
mkdir_p(subdir_path)
download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename))
+ @exported_objects_count += 1
rescue StandardError => e
# Do not fail entire project export if something goes wrong during file download
# (e.g. downloaded file has filename that exceeds 255 characters).
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 4b62580e670..e370f85fa96 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
@@ -84,21 +67,23 @@ module Ci
def failed_archive_counter
@failed_archive_counter ||=
- Gitlab::Metrics.counter(:job_trace_archive_failed_total,
- "Counter of failed attempts of trace archiving")
+ Gitlab::Metrics.counter(:job_trace_archive_failed_total, "Counter of failed attempts of trace archiving")
end
def archive_error(error, job, worker_name)
failed_archive_counter.increment
- Sidekiq.logger.warn(class: worker_name,
- message: "Failed to archive trace. message: #{error.message}.",
- job_id: job.id)
-
- Gitlab::ErrorTracking
- .track_and_raise_for_dev_exception(error,
- issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
- job_id: job.id)
+ Sidekiq.logger.warn(
+ class: worker_name,
+ message: "Failed to archive trace. message: #{error.message}.",
+ job_id: job.id
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
+ error,
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
+ job_id: job.id
+ )
end
end
end
diff --git a/app/services/ci/catalog/validate_resource_service.rb b/app/services/ci/catalog/validate_resource_service.rb
new file mode 100644
index 00000000000..f166c220869
--- /dev/null
+++ b/app/services/ci/catalog/validate_resource_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Ci
+ module Catalog
+ class ValidateResourceService
+ attr_reader :project
+
+ def initialize(project, ref)
+ @project = project
+ @ref = ref
+ @errors = []
+ end
+
+ def execute
+ check_project_readme
+ check_project_description
+
+ if errors.empty?
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: errors.join(' , '))
+ end
+ end
+
+ private
+
+ attr_reader :ref, :errors
+
+ def check_project_description
+ return if project.description.present?
+
+ errors << 'Project must have a description'
+ end
+
+ def check_project_readme
+ return if project_has_readme?
+
+ errors << 'Project must have a README'
+ end
+
+ def project_has_readme?
+ project.repository.blob_data_at(ref, 'README.md')
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 390675ab80b..a8da83e84a1 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -7,7 +7,6 @@ module Ci
LOG_MAX_DURATION_THRESHOLD = 3.seconds
LOG_MAX_PIPELINE_SIZE = 2_000
LOG_MAX_CREATION_THRESHOLD = 20.seconds
-
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
Gitlab::Ci::Pipeline::Chain::Build::Associations,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
@@ -34,7 +33,6 @@ module Ci
Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups,
Gitlab::Ci::Pipeline::Chain::Create,
Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations,
- Gitlab::Ci::Pipeline::Chain::Limit::Activity,
Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines,
Gitlab::Ci::Pipeline::Chain::Metrics,
Gitlab::Ci::Pipeline::Chain::TemplateUsage,
@@ -161,7 +159,7 @@ module Ci
pipeline_includes_count = observations['pipeline_includes_count']
next false unless pipeline_includes_count
- pipeline_includes_count.to_i > Gitlab::Ci::Config::External::Context::MAX_INCLUDES
+ pipeline_includes_count.to_i > Gitlab::Ci::Config::External::Context::TEMP_MAX_INCLUDES
end
end
end
diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb
index cbb3a2e4709..9d5ccecbe33 100644
--- a/app/services/ci/ensure_stage_service.rb
+++ b/app/services/ci/ensure_stage_service.rb
@@ -45,10 +45,12 @@ module Ci
# rubocop: enable CodeReuse/ActiveRecord
def create_stage
- Ci::Stage.create!(name: @build.stage,
- position: @build.stage_idx,
- pipeline: @build.pipeline,
- project: @build.project)
+ Ci::Stage.create!(
+ name: @build.stage,
+ position: @build.stage_idx,
+ pipeline: @build.pipeline,
+ project: @build.project
+ )
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/bulk_delete_by_project_service.rb b/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb
new file mode 100644
index 00000000000..738fa19e29b
--- /dev/null
+++ b/app/services/ci/job_artifacts/bulk_delete_by_project_service.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class BulkDeleteByProjectService
+ include BaseServiceUtility
+
+ JOB_ARTIFACTS_COUNT_LIMIT = 50
+
+ def initialize(job_artifact_ids:, project:, current_user:)
+ @job_artifact_ids = job_artifact_ids
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ if exceeds_limits?
+ return ServiceResponse.error(
+ message: "Can only delete up to #{JOB_ARTIFACTS_COUNT_LIMIT} job artifacts per call"
+ )
+ end
+
+ find_result = find_artifacts
+
+ return ServiceResponse.error(message: find_result[:error_message]) if find_result[:error_message]
+
+ @job_artifact_scope = find_result[:scope]
+
+ unless all_job_artifacts_belong_to_project?
+ return ServiceResponse.error(message: 'Not all artifacts belong to requested project')
+ end
+
+ result = Ci::JobArtifacts::DestroyBatchService.new(job_artifact_scope).execute
+
+ destroyed_artifacts_count = result.fetch(:destroyed_artifacts_count)
+ destroyed_ids = result.fetch(:destroyed_ids)
+
+ ServiceResponse.success(
+ payload: {
+ destroyed_count: destroyed_artifacts_count,
+ destroyed_ids: destroyed_ids,
+ errors: []
+ })
+ end
+
+ private
+
+ def find_artifacts
+ job_artifacts = ::Ci::JobArtifact.id_in(job_artifact_ids)
+
+ error_message = nil
+ if job_artifacts.count != job_artifact_ids.count
+ not_found_artifacts = job_artifact_ids - job_artifacts.map(&:id)
+ error_message = "Artifacts (#{not_found_artifacts.join(',')}) not found"
+ end
+
+ { scope: job_artifacts, error_message: error_message }
+ end
+
+ def exceeds_limits?
+ job_artifact_ids.count > JOB_ARTIFACTS_COUNT_LIMIT
+ end
+
+ def all_job_artifacts_belong_to_project?
+ # rubocop:disable CodeReuse/ActiveRecord
+ job_artifact_scope.pluck(:project_id).all?(project.id)
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+
+ attr_reader :job_artifact_ids, :job_artifact_scope, :current_user, :project
+ end
+ end
+end
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index 3d19fec6617..f7e04c59463 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -23,7 +23,11 @@ module Ci
result = validate_requirements(artifact_type: artifact_type, filesize: filesize)
return result unless result[:status] == :success
- headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type))
+ headers = JobArtifactUploader.workhorse_authorize(
+ has_length: false,
+ maximum_size: max_size(artifact_type),
+ use_final_store_path: Feature.enabled?(:ci_artifacts_upload_to_final_location, project)
+ )
if lsif?(artifact_type)
headers[:ProcessLsif] = true
@@ -39,14 +43,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,44 +84,54 @@ 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
+ }
+
+ file_attributes = {
+ file_type: params[:artifact_type],
+ file_format: params[:artifact_format],
+ file_sha256: artifacts_file.sha256,
+ file: artifacts_file
}
- artifact_attributes[:locked] = pipeline.locked
+ artifact = Ci::JobArtifact.new(artifact_attributes.merge(file_attributes))
- 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_metadata = build_metadata_artifact(artifact, metadata_file) if metadata_file
- 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
+ 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)
- params[:accessibility] || 'public'
+ accessibility = params[:accessibility]
+
+ return :public if Feature.disabled?(:non_public_artifacts, type: :development)
+
+ return accessibility if accessibility.present?
+
+ job.artifacts_public? ? :public : :private
end
def parse_artifact(artifact)
@@ -123,24 +141,26 @@ module Ci
end
end
- def persist_artifact(artifact, artifact_metadata, params)
- Ci::JobArtifact.transaction do
- artifact.save!
- artifact_metadata&.save!
-
+ 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.
job.update_column(:artifacts_expire_at, artifact.expire_at)
+
+ artifact.save!
+ artifact_metadata&.save!
end
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
@@ -151,11 +171,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/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
index b5dd5b843c6..57b95e59d7d 100644
--- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb
+++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
@@ -25,11 +25,7 @@ module Ci
# which is scheduled every 7 minutes.
def execute
in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
- if ::Feature.enabled?(:ci_destroy_unlocked_job_artifacts)
- destroy_unlocked_job_artifacts
- else
- destroy_job_artifacts_with_slow_iteration
- end
+ destroy_unlocked_job_artifacts
end
@removed_artifacts_count
@@ -39,26 +35,12 @@ module Ci
def destroy_unlocked_job_artifacts
loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
- artifacts = Ci::JobArtifact.expired_before(@start_at).artifact_unlocked.limit(BATCH_SIZE)
+ artifacts = Ci::JobArtifact.expired_before(@start_at).non_trace.artifact_unlocked.limit(BATCH_SIZE)
service_response = destroy_batch(artifacts)
@removed_artifacts_count += service_response[:destroyed_artifacts_count]
end
end
- def destroy_job_artifacts_with_slow_iteration
- Ci::JobArtifact.expired_before(@start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
- # For performance reasons, join with ci_pipelines after the batch is queried.
- # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496
- artifacts = relation.unlocked
-
- service_response = destroy_batch(artifacts)
- @removed_artifacts_count += service_response[:destroyed_artifacts_count]
-
- break if loop_timeout?
- break if index >= LOOP_LIMIT
- end
- end
-
def destroy_batch(artifacts)
Ci::JobArtifacts::DestroyBatchService.new(artifacts, skip_projects_on_refresh: true).execute
end
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
index 7cb1be95a3e..81cbeb31711 100644
--- a/app/services/ci/job_artifacts/destroy_batch_service.rb
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -21,6 +21,7 @@ module Ci
@job_artifacts = job_artifacts.with_destroy_preloads.to_a
@pick_up_at = pick_up_at
@skip_projects_on_refresh = skip_projects_on_refresh
+ @destroyed_ids = []
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -31,16 +32,17 @@ module Ci
track_artifacts_undergoing_stats_refresh
end
- exclude_trace_artifacts
-
- return success(destroyed_artifacts_count: 0, statistics_updates: {}) if @job_artifacts.empty?
+ if @job_artifacts.empty?
+ return success(destroyed_ids: @destroyed_ids, destroyed_artifacts_count: 0, statistics_updates: {})
+ end
destroy_related_records(@job_artifacts)
destroy_around_hook(@job_artifacts) do
+ @destroyed_ids = @job_artifacts.map(&:id)
Ci::DeletedObject.transaction do
Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at)
- Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all
+ Ci::JobArtifact.id_in(@destroyed_ids).delete_all
end
end
@@ -52,7 +54,11 @@ module Ci
Gitlab::Ci::Artifacts::Logger.log_deleted(@job_artifacts, 'Ci::JobArtifacts::DestroyBatchService#execute')
- success(destroyed_artifacts_count: artifacts_count, statistics_updates: statistics_updates_per_project)
+ success(
+ destroyed_ids: @destroyed_ids,
+ destroyed_artifacts_count: artifacts_count,
+ statistics_updates: statistics_updates_per_project
+ )
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -110,11 +116,6 @@ module Ci
end
end
- # Traces should never be destroyed.
- def exclude_trace_artifacts
- _trace_artifacts, @job_artifacts = @job_artifacts.partition(&:trace?)
- end
-
def track_artifacts_undergoing_stats_refresh
project_ids = @job_artifacts.find_all do |artifact|
artifact.project.refreshing_build_artifacts_size?
diff --git a/app/services/ci/job_token_scope/add_project_service.rb b/app/services/ci/job_token_scope/add_project_service.rb
index 15553ad6e92..8fb543a2796 100644
--- a/app/services/ci/job_token_scope/add_project_service.rb
+++ b/app/services/ci/job_token_scope/add_project_service.rb
@@ -5,9 +5,7 @@ module Ci
class AddProjectService < ::BaseService
include EditScopeValidations
- def execute(target_project, direction: :outbound)
- direction = :outbound if Feature.disabled?(:ci_inbound_job_token_scope)
-
+ def execute(target_project, direction: :inbound)
validate_edit!(project, target_project, current_user)
link = allowlist(direction)
@@ -31,3 +29,5 @@ module Ci
end
end
end
+
+Ci::JobTokenScope::AddProjectService.prepend_mod_with('Ci::JobTokenScope::AddProjectService')
diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb
index 864f9318c68..d6a2defd5b9 100644
--- a/app/services/ci/job_token_scope/remove_project_service.rb
+++ b/app/services/ci/job_token_scope/remove_project_service.rb
@@ -31,3 +31,5 @@ module Ci
end
end
end
+
+Ci::JobTokenScope::RemoveProjectService.prepend_mod_with('Ci::JobTokenScope::RemoveProjectService')
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
index dbea270b7c6..1020e98f463 100644
--- a/app/services/ci/list_config_variables_service.rb
+++ b/app/services/ci/list_config_variables_service.rb
@@ -28,9 +28,12 @@ module Ci
return {} unless config.exists?
- result = Gitlab::Ci::YamlProcessor.new(config.content, project: project,
- user: current_user,
- sha: sha).execute
+ result = Gitlab::Ci::YamlProcessor.new(
+ config.content,
+ project: project,
+ user: current_user,
+ sha: sha
+ ).execute
result.valid? ? result.root_variables_with_prefill_data : {}
end
diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb
index d4d5acef44e..89a3c7d9e03 100644
--- a/app/services/ci/parse_dotenv_artifact_service.rb
+++ b/app/services/ci/parse_dotenv_artifact_service.rb
@@ -44,8 +44,13 @@ module Ci
blob.each_line do |line|
key, value = scan_line!(line)
- variables[key] = Ci::JobVariable.new(job_id: artifact.job_id,
- source: :dotenv, key: key, value: value, raw: false)
+ variables[key] = Ci::JobVariable.new(
+ job_id: artifact.job_id,
+ source: :dotenv,
+ key: key,
+ value: value,
+ raw: false
+ )
end
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 2b8eb104be5..1094a131e68 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -19,9 +19,10 @@ module Ci
def execute
return unless pipeline.needs_processing?
+ # Run the process only if we can obtain an exclusive lease; returns nil if lease is unavailable
success = try_obtain_lease { process! }
- # re-schedule if we need further processing
+ # Re-schedule if we need further processing
if success && pipeline.needs_processing?
PipelineProcessWorker.perform_async(pipeline.id)
end
@@ -34,7 +35,7 @@ module Ci
def process!
update_stages!
update_pipeline!
- update_statuses_processed!
+ update_jobs_processed!
Ci::ExpirePipelineCacheService.new.execute(pipeline)
@@ -46,62 +47,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_for_stage_position(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_for_stage_position(stage.position)
+ 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)
- status = processable_status(processable)
- return unless Ci::HasStatus::COMPLETED_STATUSES.include?(status)
+ 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)
- # transition status if possible
- 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, status)
+ .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 processable_status(processable)
- if processable.scheduling_type_dag?
- # Processable uses DAG, get status of all dependent needs
- @collection.status_for_names(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_for_prior_stage_position(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 676c2ecb257..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 with given names
- def status_for_names(names, dag:)
- name_statuses = all_statuses_by_name.slice(*names)
+ # 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_jobs = all_jobs_grouped_by_stage_position[stage_position].to_a
- status_for_array(name_statuses.values, dag: dag)
- end
-
- # This methods gets composite status for processables before given stage
- def status_for_prior_stage_position(position)
- strong_memoize("status_for_prior_stage_position_#{position}") do
- stage_statuses = all_statuses_grouped_by_stage_position
- .select { |stage_position, _| stage_position < position }
-
- status_for_array(stage_statuses.values.flatten, dag: false)
+ status_for_array(stage_jobs.flatten)
end
end
- # This methods gets a list of processables for a given stage
- def created_processable_ids_for_stage_position(current_position)
- all_statuses_grouped_by_stage_position[current_position]
- .to_a
- .select { |processable| processable[:status] == 'created' }
- .map { |processable| processable[:id] }
+ # 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(jobs.values, dag: true)
end
- # This methods gets composite status for processables at a given stage
- def status_for_stage_position(current_position)
- strong_memoize("status_for_stage_position_#{current_position}") do
- stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a
+ # 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.flatten, dag: false)
+ status_for_array(stage_jobs.values.flatten)
end
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 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 { |job| job[:status] == 'created' }
+ .map { |job| job[:id] }
+ end
+
+ # 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/pipeline_schedules/take_ownership_service.rb b/app/services/ci/pipeline_schedules/take_ownership_service.rb
index 9b4001c74bd..b4d193cb875 100644
--- a/app/services/ci/pipeline_schedules/take_ownership_service.rb
+++ b/app/services/ci/pipeline_schedules/take_ownership_service.rb
@@ -23,7 +23,7 @@ module Ci
attr_reader :schedule, :user
def allowed?
- user.can?(:take_ownership_pipeline_schedule, schedule)
+ user.can?(:admin_pipeline_schedule, schedule)
end
def forbidden
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
index dfbb37cf0dc..1a5c8d0dccf 100644
--- a/app/services/ci/pipelines/add_job_service.rb
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -18,6 +18,12 @@ module Ci
in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do
Ci::Pipeline.transaction do
+ # This is used to reduce the deadlocks when partitioning `ci_builds`
+ # since inserting into this table requires locks on all foreign keys
+ # and we need to lock all the tables in a specific order for the
+ # migration to succeed.
+ Ci::Pipeline.connection.execute('LOCK "ci_pipelines", "ci_stages" IN ROW SHARE MODE;')
+
yield(job)
job.update_older_statuses_retried!
diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb
index a5300cfd29f..afaf18a4de2 100644
--- a/app/services/ci/process_build_service.rb
+++ b/app/services/ci/process_build_service.rb
@@ -2,40 +2,40 @@
module Ci
class ProcessBuildService < BaseService
- def execute(build, current_status)
- if valid_statuses_for_build(build).include?(current_status)
- process(build)
+ def execute(processable, current_status)
+ if valid_statuses_for_processable(processable).include?(current_status)
+ process(processable)
true
else
- build.skip
+ processable.skip
false
end
end
private
- def process(build)
- return enqueue(build) if build.enqueue_immediately?
+ def process(processable)
+ return enqueue(processable) if processable.enqueue_immediately?
- if build.schedulable?
- build.schedule
- elsif build.action?
- build.actionize
+ if processable.schedulable?
+ processable.schedule
+ elsif processable.action?
+ processable.actionize
else
- enqueue(build)
+ enqueue(processable)
end
end
- def enqueue(build)
- return build.drop!(:failed_outdated_deployment_job) if build.outdated_deployment?
+ def enqueue(processable)
+ return processable.drop!(:failed_outdated_deployment_job) if processable.outdated_deployment?
- build.enqueue
+ processable.enqueue
end
- def valid_statuses_for_build(build)
- case build.when
+ def valid_statuses_for_processable(processable)
+ case processable.when
when 'on_success', 'manual', 'delayed'
- build.scheduling_type_dag? ? %w[success] : %w[success skipped]
+ processable.scheduling_type_dag? ? %w[success] : %w[success skipped]
when 'on_failure'
%w[failed]
when 'always'
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
index 2deebc1d725..d6a252df82f 100644
--- a/app/services/ci/queue/build_queue_service.rb
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -34,10 +34,6 @@ module Ci
order(relation)
end
- def builds_queued_before(relation, time)
- relation.queued_before(time)
- end
-
def builds_for_protected_runner(relation)
relation.ref_protected
end
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
index cfafe66d10b..b2929390e58 100644
--- a/app/services/ci/queue/pending_builds_strategy.rb
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -57,9 +57,10 @@ module Ci
# if disaster recovery is enabled, we fallback to FIFO scheduling
relation.order('ci_pending_builds.build_id ASC')
else
- # Implement fair scheduling
- # this returns builds that are ordered by number of running builds
- # we prefer projects that don't use shared runners at all
+ # Implements Fair Scheduling
+ # Builds are ordered by projects that have the fewest running builds.
+ # This keeps projects that create many builds at once from hogging capacity but
+ # has the downside of penalizing projects with lots of builds created in a short period of time
relation
.with(running_builds_for_shared_runners_cte.to_arel)
.joins("LEFT JOIN project_builds ON ci_pending_builds.project_id = project_builds.project_id")
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 205da2632c2..68ebb376ccd 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
@@ -129,11 +129,6 @@ module Ci
builds = queue.builds_with_any_tags(builds)
end
- # pick builds that older than specified age
- if params.key?(:job_age)
- builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
- end
-
build_ids = retrieve_queue(-> { queue.execute(builds) })
@metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
@@ -244,7 +239,6 @@ module Ci
def assign_runner!(build, params)
build.runner_id = runner.id
build.runner_session_attributes = params[:session] if params[:session].present?
- build.ensure_metadata.runner_machine = runner_machine if runner_machine
failure_reason, _ = pre_assign_runner_checks.find { |_, check| check.call(build, params) }
@@ -256,6 +250,7 @@ module Ci
@metrics.increment_queue_operation(:runner_pre_assign_checks_success)
build.run!
+ build.runner_manager = runner_manager if runner_manager
end
!failure_reason
diff --git a/app/services/ci/reset_skipped_jobs_service.rb b/app/services/ci/reset_skipped_jobs_service.rb
index eb809b0162c..cb793eb3e06 100644
--- a/app/services/ci/reset_skipped_jobs_service.rb
+++ b/app/services/ci/reset_skipped_jobs_service.rb
@@ -4,8 +4,10 @@ module Ci
# This service resets skipped jobs so they can be processed again.
# It affects the jobs that depend on the passed in job parameter.
class ResetSkippedJobsService < ::BaseService
- def execute(processable)
- @processable = processable
+ def execute(processables)
+ @processables = Array.wrap(processables)
+ @pipeline = @processables.first.pipeline
+ @processable = @processables.first # Remove with FF `ci_support_reset_skipped_jobs_for_multiple_jobs`
process_subsequent_jobs
reset_source_bridge
@@ -20,13 +22,13 @@ module Ci
end
def reset_source_bridge
- @processable.pipeline.reset_source_bridge!(current_user)
+ @pipeline.reset_source_bridge!(current_user)
end
# rubocop: disable CodeReuse/ActiveRecord
def dependent_jobs
ordered_by_dag(
- @processable.pipeline.processables
+ @pipeline.processables
.from_union(needs_dependent_jobs, stage_dependent_jobs)
.skipped
.ordered_by_stage
@@ -41,13 +43,27 @@ module Ci
end
def stage_dependent_jobs
- @processable.pipeline.processables.after_stage(@processable.stage_idx)
+ if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project)
+ # Get all jobs after the earliest stage of the inputted jobs
+ min_stage_idx = @processables.map(&:stage_idx).min
+ @pipeline.processables.after_stage(min_stage_idx)
+ else
+ @pipeline.processables.after_stage(@processable.stage_idx)
+ end
end
def needs_dependent_jobs
- ::Gitlab::Ci::ProcessableObjectHierarchy.new(
- ::Ci::Processable.where(id: @processable.id)
- ).descendants
+ if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project)
+ # We must include the hierarchy base here because @processables may include both a parent job
+ # and its dependents, and we do not want to exclude those dependents from being processed.
+ ::Gitlab::Ci::ProcessableObjectHierarchy.new(
+ ::Ci::Processable.where(id: @processables.map(&:id))
+ ).base_and_descendants
+ else
+ ::Gitlab::Ci::ProcessableObjectHierarchy.new(
+ ::Ci::Processable.where(id: @processable.id)
+ ).descendants
+ end
end
def ordered_by_dag(jobs)
diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb
index 2de9ee4d38e..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[:paused].present?
+ 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/process_runner_version_update_service.rb b/app/services/ci/runners/process_runner_version_update_service.rb
index c8a5e42ccab..5c42a2ab018 100644
--- a/app/services/ci/runners/process_runner_version_update_service.rb
+++ b/app/services/ci/runners/process_runner_version_update_service.rb
@@ -8,6 +8,7 @@ module Ci
end
def execute
+ return ServiceResponse.error(message: 'version update disabled') unless enabled?
return ServiceResponse.error(message: 'version not present') unless @version
_, status = upgrade_check_service.check_runner_upgrade_suggestion(@version)
@@ -22,6 +23,10 @@ module Ci
def upgrade_check_service
@runner_upgrade_check ||= Gitlab::Ci::RunnerUpgradeCheck.new(::Gitlab::VERSION)
end
+
+ def enabled?
+ Gitlab::Ci::RunnerReleases.instance.enabled?
+ end
end
end
end
diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb
index db16b86d5e6..0c13c32e236 100644
--- a/app/services/ci/runners/register_runner_service.rb
+++ b/app/services/ci/runners/register_runner_service.rb
@@ -3,12 +3,23 @@
module Ci
module Runners
class RegisterRunnerService
- def execute(registration_token, attributes)
- runner_type_attrs = extract_runner_type_attrs(registration_token)
+ include Gitlab::Utils::StrongMemoize
- return ServiceResponse.error(message: 'invalid token supplied', http_status: :forbidden) unless runner_type_attrs
+ def initialize(registration_token, attributes)
+ @registration_token = registration_token
+ @attributes = attributes
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'invalid token supplied', http_status: :forbidden) unless attrs_from_token
+
+ unless registration_token_allowed?(attrs_from_token)
+ return ServiceResponse.error(
+ message: 'runner registration disallowed',
+ reason: :runner_registration_disallowed)
+ end
- runner = ::Ci::Runner.new(attributes.merge(runner_type_attrs))
+ runner = ::Ci::Runner.new(attributes.merge(attrs_from_token))
Ci::BulkInsertableTags.with_bulk_insert_tags do
Ci::Runner.transaction do
@@ -25,32 +36,30 @@ module Ci
private
- def extract_runner_type_attrs(registration_token)
- @attrs_from_token ||= check_token(registration_token)
-
- return unless @attrs_from_token
+ attr_reader :registration_token, :attributes
- attrs = @attrs_from_token.clone
- case attrs[:runner_type]
- when :project_type
- attrs[:projects] = [attrs.delete(:scope)]
- when :group_type
- attrs[:groups] = [attrs.delete(:scope)]
- end
-
- attrs
- end
-
- def check_token(registration_token)
+ def attrs_from_token
if runner_registration_token_valid?(registration_token)
# Create shared runner. Requires admin access
{ runner_type: :instance_type }
elsif runner_registrar_valid?('project') && project = ::Project.find_by_runners_token(registration_token)
# Create a project runner
- { runner_type: :project_type, scope: project }
+ { runner_type: :project_type, projects: [project] }
elsif runner_registrar_valid?('group') && group = ::Group.find_by_runners_token(registration_token)
# Create a group runner
- { runner_type: :group_type, scope: group }
+ { runner_type: :group_type, groups: [group] }
+ end
+ end
+ strong_memoize_attr :attrs_from_token
+
+ def registration_token_allowed?(attrs)
+ case attrs[:runner_type]
+ when :group_type
+ token_scope.allow_runner_registration_token?
+ when :project_type
+ token_scope.namespace.allow_runner_registration_token?
+ else
+ Gitlab::CurrentSettings.allow_runner_registration_token
end
end
@@ -63,7 +72,13 @@ module Ci
end
def token_scope
- @attrs_from_token[:scope]
+ case attrs_from_token[:runner_type]
+ when :project_type
+ attrs_from_token[:projects]&.first
+ when :group_type
+ attrs_from_token[:groups]&.first
+ # No scope for instance type
+ end
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/runners/unregister_runner_manager_service.rb b/app/services/ci/runners/unregister_runner_manager_service.rb
new file mode 100644
index 00000000000..ecf6aba09c7
--- /dev/null
+++ b/app/services/ci/runners/unregister_runner_manager_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class UnregisterRunnerManagerService
+ attr_reader :runner, :author, :system_id
+
+ # @param [Ci::Runner] runner the runner to unregister/destroy
+ # @param [User, authentication token String] author the user or the authentication token authorizing the removal
+ # @param [String] system_id ID of the system being unregistered
+ def initialize(runner, author, system_id:)
+ @runner = runner
+ @author = author
+ @system_id = system_id
+ end
+
+ def execute
+ return system_id_missing_error if system_id.blank?
+
+ runner_manager = runner.runner_managers.find_by_system_xid!(system_id)
+ runner_manager.destroy!
+
+ ServiceResponse.success
+ end
+
+ private
+
+ def system_id_missing_error
+ ServiceResponse.error(message: '`system_id` needs to be specified for runners created in the UI.')
+ end
+ end
+ end
+end
diff --git a/app/services/ci/runners/unregister_runner_service.rb b/app/services/ci/runners/unregister_runner_service.rb
index 742b21f77df..d186bd421d5 100644
--- a/app/services/ci/runners/unregister_runner_service.rb
+++ b/app/services/ci/runners/unregister_runner_service.rb
@@ -13,7 +13,8 @@ module Ci
end
def execute
- @runner&.destroy
+ runner.destroy!
+
ServiceResponse.success
end
end
diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb
index f56c9aaeb55..4ce30a6068c 100644
--- a/app/services/ci/stuck_builds/drop_helpers.rb
+++ b/app/services/ci/stuck_builds/drop_helpers.rb
@@ -45,23 +45,26 @@ module Ci
end
def track_exception_for_build(ex, build)
- Gitlab::ErrorTracking.track_exception(ex,
- build_id: build.id,
- build_name: build.name,
- build_stage: build.stage_name,
- pipeline_id: build.pipeline_id,
- project_id: build.project_id
+ Gitlab::ErrorTracking.track_exception(
+ ex,
+ build_id: build.id,
+ build_name: build.name,
+ build_stage: build.stage_name,
+ pipeline_id: build.pipeline_id,
+ project_id: build.project_id
)
end
def log_dropping_message(type, build, reason)
- Gitlab::AppLogger.info(class: self.class.name,
- message: "Dropping #{type} build",
- build_stuck_type: type,
- build_id: build.id,
- runner_id: build.runner_id,
- build_status: build.status,
- build_failure_reason: reason)
+ Gitlab::AppLogger.info(
+ class: self.class.name,
+ message: "Dropping #{type} build",
+ build_stuck_type: type,
+ build_id: build.id,
+ runner_id: build.runner_id,
+ build_status: build.status,
+ build_failure_reason: reason
+ )
end
end
end
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/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 58927a90b6e..40941dd4cd0 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -37,7 +37,7 @@ module Ci
end
##
- # Force recemove build from the queue, without checking a transition state
+ # Force remove build from the queue, without checking a transition state
#
def remove!(build)
removed = build.all_queuing_entries.delete_all
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
index 2539ffdc5ba..66a3cb04d98 100644
--- a/app/services/clusters/agent_tokens/create_service.rb
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -2,16 +2,24 @@
module Clusters
module AgentTokens
- class CreateService < ::BaseContainerService
+ class CreateService
ALLOWED_PARAMS = %i[agent_id description name].freeze
+ attr_reader :agent, :current_user, :params
+
+ def initialize(agent:, current_user:, params:)
+ @agent = agent
+ @current_user = current_user
+ @params = params
+ end
+
def execute
- return error_no_permissions unless current_user.can?(:create_cluster, container)
+ return error_no_permissions unless current_user.can?(:create_cluster, agent.project)
- token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
+ token = ::Clusters::AgentToken.new(filtered_params.merge(agent_id: agent.id, created_by_user: current_user))
if token.save
- log_activity_event!(token)
+ log_activity_event(token)
ServiceResponse.success(payload: { secret: token.token, token: token })
else
@@ -29,7 +37,7 @@ module Clusters
params.slice(*ALLOWED_PARAMS)
end
- def log_activity_event!(token)
+ def log_activity_event(token)
Clusters::Agents::CreateActivityEventService.new(
token.agent,
kind: :token_created,
@@ -42,3 +50,5 @@ module Clusters
end
end
end
+
+Clusters::AgentTokens::CreateService.prepend_mod
diff --git a/app/services/clusters/agent_tokens/revoke_service.rb b/app/services/clusters/agent_tokens/revoke_service.rb
new file mode 100644
index 00000000000..5d89b405969
--- /dev/null
+++ b/app/services/clusters/agent_tokens/revoke_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Clusters
+ module AgentTokens
+ class RevokeService
+ attr_reader :current_project, :current_user, :token
+
+ def initialize(token:, current_user:)
+ @token = token
+ @current_user = current_user
+ end
+
+ def execute
+ return error_no_permissions unless current_user.can?(:create_cluster, token.agent.project)
+
+ if token.update(status: token.class.statuses[:revoked])
+ log_activity_event(token)
+
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: token.errors.full_messages)
+ end
+ end
+
+ private
+
+ def error_no_permissions
+ ServiceResponse.error(
+ message: s_('ClusterAgent|User has insufficient permissions to revoke the token for this project'))
+ end
+
+ def log_activity_event(token)
+ Clusters::Agents::CreateActivityEventService.new(
+ token.agent,
+ kind: :token_revoked,
+ level: :info,
+ recorded_at: token.updated_at,
+ user: current_user,
+ agent_token: token
+ ).execute
+ end
+ end
+ end
+end
+
+Clusters::AgentTokens::RevokeService.prepend_mod
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
new file mode 100644
index 00000000000..fbcf25153c1
--- /dev/null
+++ b/app/services/clusters/agents/authorize_proxy_user_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class AuthorizeProxyUserService < ::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ def initialize(current_user, agent)
+ @current_user = current_user
+ @agent = agent
+ end
+
+ def execute
+ return forbidden unless user_access_config.present?
+
+ access_as = user_access_config['access_as']
+ return forbidden unless access_as.present?
+ return forbidden if access_as.size != 1
+
+ if payload = handle_access(access_as)
+ return success(payload: payload)
+ end
+
+ forbidden
+ end
+
+ private
+
+ attr_reader :current_user, :agent
+
+ # Override in EE
+ def handle_access(access_as)
+ access_as_agent if access_as.key?('agent')
+ end
+
+ def authorizations
+ @authorizations ||= ::Clusters::Agents::Authorizations::UserAccess::Finder
+ .new(current_user, agent: agent).execute
+ end
+
+ def response_base
+ {
+ agent: {
+ id: agent.id,
+ config_project: { id: agent.project_id }
+ },
+ user: {
+ id: current_user.id,
+ username: current_user.username
+ }
+ }
+ end
+
+ def access_as_agent
+ return if authorizations.empty?
+
+ response_base.merge(access_as: { agent: {} })
+ end
+
+ def user_access_config
+ agent.user_access_config
+ end
+ strong_memoize_attr :user_access_config
+
+ delegate :success, to: ServiceResponse, private: true
+
+ def forbidden
+ ServiceResponse.error(reason: :forbidden, message: '403 Forbidden')
+ end
+ end
+ end
+end
+
+Clusters::Agents::AuthorizeProxyUserService.prepend_mod
diff --git a/app/services/clusters/agents/create_activity_event_service.rb b/app/services/clusters/agents/create_activity_event_service.rb
index 886dddf1a52..87554f0e495 100644
--- a/app/services/clusters/agents/create_activity_event_service.rb
+++ b/app/services/clusters/agents/create_activity_event_service.rb
@@ -14,6 +14,10 @@ module Clusters
DeleteExpiredEventsWorker.perform_at(schedule_cleanup_at, agent.id)
ServiceResponse.success
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, agent_id: agent.id)
+
+ ServiceResponse.error(message: e.message)
end
private
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/commits/change_service.rb b/app/services/commits/change_service.rb
index dc7f84ab807..0b97aae9972 100644
--- a/app/services/commits/change_service.rb
+++ b/app/services/commits/change_service.rb
@@ -29,12 +29,24 @@ module Commits
dry_run: @dry_run
)
rescue Gitlab::Git::Repository::CreateTreeError => ex
- act = action.to_s.dasherize
type = @commit.change_type_title(current_user)
- error_msg = "Sorry, we cannot #{act} this #{type} automatically. " \
- "This #{type} may already have been #{act}ed, or a more recent " \
- "commit may have updated some of its content."
+ status = case [type, action]
+ when ['commit', :cherry_pick]
+ s_("MergeRequests|Commit cherry-pick failed")
+ when ['commit', :revert]
+ s_("MergeRequests|Commit revert failed")
+ when ['merge request', :cherry_pick]
+ s_("MergeRequests|Merge request cherry-pick failed")
+ when ['merge request', :revert]
+ s_("MergeRequests|Merge request revert failed")
+ end
+
+ detail = s_("MergeRequests|Can't perform this action automatically. " \
+ "It may have already been done, or a more recent commit may have updated some of this content. " \
+ "Please perform this action locally.")
+
+ error_msg = "#{status}: #{detail}"
raise ChangeError.new(error_msg, ex.error_code)
end
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
index 76d59cf2159..74acaa0522a 100644
--- a/app/services/concerns/exclusive_lease_guard.rb
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -21,7 +21,7 @@ module ExclusiveLeaseGuard
lease = exclusive_lease.try_obtain
unless lease
- log_error("Cannot obtain an exclusive lease for #{lease_key}. There must be another instance already in execution.")
+ log_lease_taken
return
end
@@ -57,7 +57,23 @@ module ExclusiveLeaseGuard
exclusive_lease.renew
end
- def log_error(message, extra_args = {})
- Gitlab::AppLogger.error(message)
+ def log_lease_taken
+ logger = Gitlab::AppJsonLogger
+ args = { message: lease_taken_message, lease_key: lease_key, class_name: self.class.name, lease_timeout: lease_timeout }
+
+ case lease_taken_log_level
+ when :debug then logger.debug(args)
+ when :info then logger.info(args)
+ when :warn then logger.warn(args)
+ else logger.error(args)
+ end
+ end
+
+ def lease_taken_message
+ "Cannot obtain an exclusive lease. There must be another instance already in execution."
+ end
+
+ def lease_taken_log_level
+ :error
end
end
diff --git a/app/services/concerns/incident_management/usage_data.rb b/app/services/concerns/incident_management/usage_data.rb
index 40183085344..f7edbb80d09 100644
--- a/app/services/concerns/incident_management/usage_data.rb
+++ b/app/services/concerns/incident_management/usage_data.rb
@@ -5,7 +5,7 @@ module IncidentManagement
include Gitlab::Utils::UsageData
def track_incident_action(current_user, target, action)
- return unless target.incident?
+ return unless target.incident_type_issue?
event = "incident_management_#{action}"
track_usage_event(event, current_user.id)
@@ -13,8 +13,6 @@ module IncidentManagement
namespace = target.try(:namespace)
project = target.try(:project)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2, target.try(:namespace))
-
Gitlab::Tracking.event(
self.class.to_s,
event,
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/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb
index b21d05f4178..a0b4040cff7 100644
--- a/app/services/concerns/update_repository_storage_methods.rb
+++ b/app/services/concerns/update_repository_storage_methods.rb
@@ -28,10 +28,7 @@ module UpdateRepositoryStorageMethods
track_repository(destination_storage_name)
end
- unless same_filesystem?
- remove_old_paths
- enqueue_housekeeping
- end
+ remove_old_paths unless same_filesystem?
repository_storage_move.finish_cleanup!
@@ -95,10 +92,6 @@ module UpdateRepositoryStorageMethods
end
end
- def enqueue_housekeeping
- # no-op
- end
-
def wait_for_pushes(type)
reference_counter = container.reference_counter(type: type)
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/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb
index 1123b29f217..6c2b41a4daf 100644
--- a/app/services/container_expiration_policies/cleanup_service.rb
+++ b/app/services/container_expiration_policies/cleanup_service.rb
@@ -19,7 +19,6 @@ module ContainerExpirationPolicies
return ServiceResponse.error(message: 'invalid policy')
end
- repository.start_expiration_policy!
schedule_next_run_if_needed
begin
diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb
index cd575b83a98..5bc5cb45a12 100644
--- a/app/services/dependency_proxy/head_manifest_service.rb
+++ b/app/services/dependency_proxy/head_manifest_service.rb
@@ -2,7 +2,7 @@
module DependencyProxy
class HeadManifestService < DependencyProxy::BaseService
- ACCEPT_HEADERS = ::ContainerRegistry::Client::ACCEPTED_TYPES.join(',')
+ ACCEPT_HEADERS = DependencyProxy::Manifest::ACCEPTED_TYPES.join(',')
def initialize(image, tag, token)
@image = image
diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb
index 7bdf7711155..31816b46c52 100644
--- a/app/services/discussions/update_diff_position_service.rb
+++ b/app/services/discussions/update_diff_position_service.rb
@@ -25,7 +25,7 @@ module Discussions
Note.transaction do
discussion.notes.each do |note|
- Gitlab::Timeless.timeless(note, &:save)
+ note.save(touch: false)
end
if outdated && current_user
diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb
index fab7a227e7d..9e1e381c568 100644
--- a/app/services/draft_notes/publish_service.rb
+++ b/app/services/draft_notes/publish_service.rb
@@ -59,7 +59,8 @@ module DraftNotes
note_params = draft.publish_params.merge(skip_keep_around_commits: skip_keep_around_commits)
note = Notes::CreateService.new(draft.project, draft.author, note_params).execute(
skip_capture_diff_note_position: skip_capture_diff_note_position,
- skip_merge_status_trigger: skip_merge_status_trigger
+ skip_merge_status_trigger: skip_merge_status_trigger,
+ skip_set_reviewed: true
)
set_discussion_resolve_status(note, draft)
diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb
index fb14ee40c05..1b2e7ef3cf9 100644
--- a/app/services/environments/stop_service.rb
+++ b/app/services/environments/stop_service.rb
@@ -5,13 +5,27 @@ module Environments
attr_reader :ref
def execute(environment)
- return unless can?(current_user, :stop_environment, environment)
+ unless can?(current_user, :stop_environment, environment)
+ return ServiceResponse.error(
+ message: 'Unauthorized to stop the environment',
+ payload: { environment: environment }
+ )
+ end
if params[:force]
environment.stop_complete!
else
environment.stop_with_actions!(current_user)
end
+
+ unless environment.saved_change_to_attribute?(:state)
+ return ServiceResponse.error(
+ message: 'Attemped to stop the environment but failed to change the status',
+ payload: { environment: environment }
+ )
+ end
+
+ ServiceResponse.success(payload: { environment: environment })
end
def execute_for_branch(branch_name)
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
index d52306ef805..35a8179d54d 100644
--- a/app/services/error_tracking/list_projects_service.rb
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -2,8 +2,6 @@
module ErrorTracking
class ListProjectsService < ErrorTracking::BaseService
- MASKED_TOKEN_REGEX = /\A\*+\z/.freeze
-
private
def perform
@@ -46,7 +44,7 @@ module ErrorTracking
end
def masked_token?
- MASKED_TOKEN_REGEX.match?(params[:token])
+ ErrorTracking::SentryClient::Token.masked_token?(params[:token])
end
end
end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index d848f694598..1893cfcfcff 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -12,7 +12,7 @@ class EventCreateService
DEGIGN_EVENT_LABEL = 'usage_activity_by_stage_monthly.create.action_monthly_active_users_design_management'
MR_EVENT_LABEL = 'usage_activity_by_stage_monthly.create.merge_requests_users'
- MR_EVENT_PROPERTY = 'merge_requests_users'
+ MR_EVENT_PROPERTY = 'merge_request_action'
def open_issue(issue, current_user)
create_record_event(issue, current_user, :created)
@@ -28,7 +28,7 @@ class EventCreateService
def open_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :created).tap do
- track_event(event_action: :created, event_target: MergeRequest, author_id: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:merge_request_action, values: current_user.id)
track_snowplow_event(
action: :created,
project: merge_request.project,
@@ -41,7 +41,7 @@ class EventCreateService
def close_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :closed).tap do
- track_event(event_action: :closed, event_target: MergeRequest, author_id: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:merge_request_action, values: current_user.id)
track_snowplow_event(
action: :closed,
project: merge_request.project,
@@ -58,7 +58,7 @@ class EventCreateService
def merge_mr(merge_request, current_user)
create_record_event(merge_request, current_user, :merged).tap do
- track_event(event_action: :merged, event_target: MergeRequest, author_id: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:merge_request_action, values: current_user.id)
track_snowplow_event(
action: :merged,
project: merge_request.project,
@@ -88,7 +88,7 @@ class EventCreateService
def leave_note(note, current_user)
create_record_event(note, current_user, :commented).tap do
if note.is_a?(DiffNote) && note.for_merge_request?
- track_event(event_action: :commented, event_target: MergeRequest, author_id: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:merge_request_action, values: current_user.id)
track_snowplow_event(
action: :commented,
project: note.project,
@@ -128,12 +128,17 @@ class EventCreateService
records = create.zip([:created].cycle) + update.zip([:updated].cycle)
return [] if records.empty?
- event_meta = { user: current_user, label: DEGIGN_EVENT_LABEL, property: Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION }
+ event_meta = { user: current_user, label: DEGIGN_EVENT_LABEL, property: :design_action }
track_snowplow_event(action: :create, project: create.first.project, **event_meta) if create.any?
track_snowplow_event(action: :update, project: update.first.project, **event_meta) if update.any?
- create_record_events(records, current_user)
+ inserted_events = create_record_events(records, current_user)
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:design_action, values: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:git_write_action, values: current_user.id)
+
+ inserted_events
end
def destroy_designs(designs, current_user)
@@ -144,9 +149,15 @@ class EventCreateService
project: designs.first.project,
user: current_user,
label: DEGIGN_EVENT_LABEL,
- property: Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION
+ property: :design_action
)
- create_record_events(designs.zip([:destroyed].cycle), current_user)
+
+ inserted_events = create_record_events(designs.zip([:destroyed].cycle), current_user)
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:design_action, values: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:git_write_action, values: current_user.id)
+
+ inserted_events
end
# Create a new wiki page event
@@ -163,7 +174,8 @@ class EventCreateService
def wiki_event(wiki_page_meta, author, action, fingerprint)
raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action)
- track_event(event_action: action, event_target: wiki_page_meta.class, author_id: author.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:wiki_action, values: author.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:git_write_action, values: author.id)
duplicate = Event.for_wiki_meta(wiki_page_meta).for_fingerprint(fingerprint).first
return duplicate if duplicate.present?
@@ -205,13 +217,7 @@ class EventCreateService
.merge(action: action, fingerprint: fingerprint, target_id: record.id, target_type: record.class.name)
end
- result = Event.insert_all(attribute_sets, returning: %w[id])
-
- tuples.each do |record, status, _|
- track_event(event_action: status, event_target: record.class, author_id: current_user.id)
- end
-
- result
+ Event.insert_all(attribute_sets, returning: %w[id])
end
def create_push_event(service_class, project, current_user, push_data)
@@ -226,7 +232,8 @@ class EventCreateService
new_event
end
- track_event(event_action: :pushed, event_target: Project, author_id: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:project_action, values: current_user.id)
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:git_write_action, values: current_user.id)
namespace = project.namespace
Gitlab::Tracking.event(
@@ -273,13 +280,7 @@ class EventCreateService
{ resource_parent_attr => resource_parent.id }
end
- def track_event(...)
- Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(...)
- end
-
def track_snowplow_event(action:, project:, user:, label:, property:)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2)
-
Gitlab::Tracking.event(
self.class.to_s,
action.to_s,
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index 59db1a5f12f..028906a0b43 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -7,42 +7,24 @@ module FeatureFlags
AUDITABLE_ATTRIBUTES = %w(name description active).freeze
def success(**args)
- audit_event = args.fetch(:audit_event) { audit_event(args[:feature_flag]) }
- save_audit_event(audit_event)
sync_to_jira(args[:feature_flag])
+
+ audit_event(args[:feature_flag], args[:audit_context])
super
end
protected
- def update_last_feature_flag_updated_at!
- Operations::FeatureFlagsClient.update_last_feature_flag_updated_at!(project)
- end
-
- def audit_event(feature_flag)
- message = audit_message(feature_flag)
+ def audit_event(feature_flag, context = nil)
+ context ||= audit_context(feature_flag)
- return if message.blank?
+ return if context[:message].blank?
- details =
- {
- custom_message: message,
- target_id: feature_flag.id,
- target_type: feature_flag.class.name,
- target_details: feature_flag.name
- }
-
- ::AuditEventService.new(
- current_user,
- feature_flag.project,
- details
- )
+ ::Gitlab::Audit::Auditor.audit(context)
end
- def save_audit_event(audit_event)
- return unless audit_event
-
- audit_event.security_event
+ def update_last_feature_flag_updated_at!
+ Operations::FeatureFlagsClient.update_last_feature_flag_updated_at!(project)
end
def sync_to_jira(feature_flag)
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index 6ea40345191..2a3153e6a54 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -21,6 +21,16 @@ module FeatureFlags
private
+ def audit_context(feature_flag)
+ {
+ name: 'feature_flag_created',
+ message: audit_message(feature_flag),
+ author: current_user,
+ scope: feature_flag.project,
+ target: feature_flag
+ }
+ end
+
def audit_message(feature_flag)
message_parts = ["Created feature flag #{feature_flag.name} with description \"#{feature_flag.description}\"."]
diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb
index 0fdc890b8a3..fdcbb802b16 100644
--- a/app/services/feature_flags/destroy_service.rb
+++ b/app/services/feature_flags/destroy_service.rb
@@ -22,6 +22,16 @@ module FeatureFlags
end
end
+ def audit_context(feature_flag)
+ {
+ name: 'feature_flag_deleted',
+ message: audit_message(feature_flag),
+ author: current_user,
+ scope: feature_flag.project,
+ target: feature_flag
+ }
+ end
+
def audit_message(feature_flag)
"Deleted feature flag #{feature_flag.name}."
end
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
index a465ca1dd5f..555b5a93d23 100644
--- a/app/services/feature_flags/update_service.rb
+++ b/app/services/feature_flags/update_service.rb
@@ -25,13 +25,13 @@ module FeatureFlags
end
end
- # We generate the audit event before the feature flag is saved as #changed_strategies_messages depends on the strategies' states before save
- audit_event = audit_event(feature_flag)
+ # We generate the audit context before the feature flag is saved as #changed_strategies_messages depends on the strategies' states before save
+ saved_audit_context = audit_context feature_flag
if feature_flag.save
update_last_feature_flag_updated_at!
- success(feature_flag: feature_flag, audit_event: audit_event)
+ success(feature_flag: feature_flag, audit_context: saved_audit_context)
else
error(feature_flag.errors.full_messages, :bad_request)
end
@@ -50,6 +50,16 @@ module FeatureFlags
end
end
+ def audit_context(feature_flag)
+ {
+ name: 'feature_flag_updated',
+ message: audit_message(feature_flag),
+ author: current_user,
+ scope: feature_flag.project,
+ target: feature_flag
+ }
+ end
+
def audit_message(feature_flag)
changes = changed_attributes_messages(feature_flag)
changes += changed_strategies_messages(feature_flag)
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index 8f722de2019..613785d01cc 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -26,16 +26,23 @@ module Files
def file_has_changed?(path, commit_id)
return false unless commit_id
- last_commit = Gitlab::Git::Commit
- .last_for_path(@start_project.repository, @start_branch, path, literal_pathspec: true)
+ last_commit_from_branch = get_last_commit_for_path(ref: @start_branch, path: path)
- return false unless last_commit
+ return false unless last_commit_from_branch
- last_commit.sha != commit_id
+ last_commit_from_commit_id = get_last_commit_for_path(ref: commit_id, path: path)
+
+ return false unless last_commit_from_commit_id
+
+ last_commit_from_branch.sha != last_commit_from_commit_id.sha
end
private
+ def get_last_commit_for_path(ref:, path:)
+ Gitlab::Git::Commit.last_for_path(@start_project.repository, ref, path, literal_pathspec: true)
+ end
+
def commit_email(git_user)
return params[:author_email] if params[:author_email].present?
return unless current_user
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 7158116fde1..acf54dec51b 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -15,6 +15,7 @@ module Git
# Not a hook, but it needs access to the list of changed commits
enqueue_invalidate_cache
+ enqueue_notify_kas
success
end
@@ -77,6 +78,13 @@ module Git
ProjectCacheWorker.perform_async(project.id, file_types, [], false)
end
+ def enqueue_notify_kas
+ return unless Gitlab::Kas.enabled?
+ return unless Feature.enabled?(:notify_kas_on_git_push, project)
+
+ Clusters::Agents::NotifyGitPushWorker.perform_async(project.id)
+ end
+
def pipeline_params
strong_memoize(:pipeline_params) do
{
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/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
index b6438d6f501..791be69f4d4 100644
--- a/app/services/google_cloud/generate_pipeline_service.rb
+++ b/app/services/google_cloud/generate_pipeline_service.rb
@@ -61,7 +61,7 @@ module GoogleCloud
end
def pipeline_content(include_path)
- gitlab_ci_yml = Gitlab::Config::Loader::Yaml.new(default_branch_gitlab_ci_yml || '{}').load!
+ gitlab_ci_yml = ::Gitlab::Ci::Config::Yaml.load!(default_branch_gitlab_ci_yml || '{}')
append_remote_include(gitlab_ci_yml, "https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library/-/raw/main/#{include_path}")
end
diff --git a/app/services/groups/autocomplete_service.rb b/app/services/groups/autocomplete_service.rb
index 92b05d9ac08..5b9d60495e9 100644
--- a/app/services/groups/autocomplete_service.rb
+++ b/app/services/groups/autocomplete_service.rb
@@ -13,7 +13,7 @@ module Groups
IssuesFinder.new(current_user, finder_params)
.execute
.preload(project: :namespace)
- .select(:iid, :title, :project_id)
+ .select(:iid, :title, :project_id, :namespace_id)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb
index 9c1a003ff36..a6e2c0b952e 100644
--- a/app/services/groups/group_links/create_service.rb
+++ b/app/services/groups/group_links/create_service.rb
@@ -36,3 +36,5 @@ module Groups
end
end
end
+
+Groups::GroupLinks::CreateService.prepend_mod
diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb
index dc3cab927be..8eed46b28ca 100644
--- a/app/services/groups/group_links/destroy_service.rb
+++ b/app/services/groups/group_links/destroy_service.rb
@@ -24,7 +24,11 @@ module Groups
Gitlab::AppLogger.info(
"Failed to delete GroupGroupLinks with ids: #{links.map(&:id)}.")
end
+
+ links
end
end
end
end
+
+Groups::GroupLinks::DestroyService.prepend_mod
diff --git a/app/services/groups/group_links/update_service.rb b/app/services/groups/group_links/update_service.rb
index 66d0d63cb9b..913bf2bfce7 100644
--- a/app/services/groups/group_links/update_service.rb
+++ b/app/services/groups/group_links/update_service.rb
@@ -15,6 +15,8 @@ module Groups
if requires_authorization_refresh?(group_link_params)
group_link.shared_with_group.refresh_members_authorized_projects(direct_members_only: true)
end
+
+ group_link
end
private
@@ -27,3 +29,5 @@ module Groups
end
end
end
+
+Groups::GroupLinks::UpdateService.prepend_mod
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 7e9fd9dad54..1c8df157716 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -60,7 +60,7 @@ module Groups
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images?
raise_transfer_error(:cannot_transfer_to_subgroup) if transfer_to_subgroup?
- raise_transfer_error(:group_contains_npm_packages) if group_with_npm_packages?
+ raise_transfer_error(:group_contains_namespaced_npm_packages) if group_with_namespaced_npm_packages?
raise_transfer_error(:no_permissions_to_migrate_crm) if no_permissions_to_migrate_crm?
end
@@ -74,10 +74,11 @@ module Groups
false
end
- def group_with_npm_packages?
+ def group_with_namespaced_npm_packages?
return false unless group.packages_feature_enabled?
- npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm).execute
+ npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm, preload_pipelines: false).execute
+ npm_packages = npm_packages.with_npm_scope(group.root_ancestor.path)
different_root_ancestor? && npm_packages.exists?
end
@@ -219,7 +220,7 @@ module Groups
invalid_policies: s_("TransferGroup|You don't have enough permissions."),
group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.'),
cannot_transfer_to_subgroup: s_('TransferGroup|Cannot transfer group to one of its subgroup.'),
- group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.'),
+ group_contains_namespaced_npm_packages: s_('TransferGroup|Group contains projects with NPM packages scoped to the current root level group.'),
no_permissions_to_migrate_crm: s_("TransferGroup|Group contains contacts/organizations and you don't have enough permissions to move them to the new root group.")
}.freeze
end
diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb
index 6b5adcbc39e..64cf3cfa04a 100644
--- a/app/services/import/base_service.rb
+++ b/app/services/import/base_service.rb
@@ -9,7 +9,7 @@ module Import
end
def authorized?
- can?(current_user, :create_projects, target_namespace)
+ can?(current_user, :import_projects, target_namespace)
end
private
diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb
index f7f17f1e53e..5d496dc7cc3 100644
--- a/app/services/import/bitbucket_server_service.rb
+++ b/app/services/import/bitbucket_server_service.rb
@@ -10,7 +10,7 @@ module Import
end
unless authorized?
- return log_and_return_error("You don't have permissions to create this project", :unauthorized)
+ return log_and_return_error("You don't have permissions to import this project", :unauthorized)
end
unless repo
diff --git a/app/services/import/fogbugz_service.rb b/app/services/import/fogbugz_service.rb
index d1003823456..9a8def43312 100644
--- a/app/services/import/fogbugz_service.rb
+++ b/app/services/import/fogbugz_service.rb
@@ -13,8 +13,8 @@ module Import
unless authorized?
return log_and_return_error(
- "You don't have permissions to create this project",
- _("You don't have permissions to create this project"),
+ "You don't have permissions to import this project",
+ _("You don't have permissions to import this project"),
:unauthorized
)
end
diff --git a/app/services/import/github/cancel_project_import_service.rb b/app/services/import/github/cancel_project_import_service.rb
index 5dce5e73662..62cd0c95eaf 100644
--- a/app/services/import/github/cancel_project_import_service.rb
+++ b/app/services/import/github/cancel_project_import_service.rb
@@ -9,6 +9,8 @@ module Import
if project.import_in_progress?
project.import_state.cancel
+ metrics.track_canceled_import
+
success(project: project)
else
error(cannot_cancel_error_message, :bad_request)
@@ -31,6 +33,10 @@ module Import
project_status: project.import_state.status
)
end
+
+ def metrics
+ @metrics ||= Gitlab::Import::Metrics.new(:github_importer, project)
+ end
end
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index b30c344723d..7e7f7ea9810 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -103,7 +103,7 @@ module Import
elsif target_namespace.nil?
error(_('Namespace or group to import repository into does not exist.'), :unprocessable_entity)
elsif !authorized?
- error(_('This namespace has already been taken. Choose a different one.'), :unprocessable_entity)
+ error(_('You are not allowed to import projects in this namespace.'), :unprocessable_entity)
elsif oversized?
error(oversize_error_message, :unprocessable_entity)
end
diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb
index 1b8fa45e979..2886bd5c9b7 100644
--- a/app/services/import/validate_remote_git_endpoint_service.rb
+++ b/app/services/import/validate_remote_git_endpoint_service.rb
@@ -21,7 +21,9 @@ module Import
def execute
uri = Gitlab::Utils.parse_url(@params[:url])
- return ServiceResponse.error(message: "#{@params[:url]} is not a valid URL") unless uri
+ if !uri || !uri.hostname || Project::VALID_IMPORT_PROTOCOLS.exclude?(uri.scheme)
+ return ServiceResponse.error(message: "#{@params[:url]} is not a valid URL")
+ end
return ServiceResponse.success if uri.scheme == 'git'
diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb
index feb76425fb4..70834b8a85a 100644
--- a/app/services/import_csv/base_service.rb
+++ b/app/services/import_csv/base_service.rb
@@ -2,6 +2,8 @@
module ImportCsv
class BaseService
+ include Gitlab::Utils::StrongMemoize
+
def initialize(user, project, csv_io)
@user = user
@project = project
@@ -9,6 +11,8 @@ module ImportCsv
@results = { success: 0, error_lines: [], parse_error: false }
end
+ PreprocessError = Class.new(StandardError)
+
def execute
process_csv
email_results_to_user
@@ -36,7 +40,23 @@ module ImportCsv
raise NotImplementedError
end
+ 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
+ end
+
+ def preprocess!
+ # any logic can be added in subclasses if needed
+ # hence just a no-op rather than NotImplementedError
+ end
+
def process_csv
+ validate_structure!
+ preprocess!
+
with_csv_lines.each do |row, line_no|
attributes = attributes_for(row)
@@ -46,23 +66,30 @@ module ImportCsv
results[:error_lines].push(line_no)
end
end
- rescue ArgumentError, CSV::MalformedCSVError
+ rescue ArgumentError, CSV::MalformedCSVError => e
results[:parse_error] = true
+ results[:error_lines].push(e.line_number) if e.respond_to?(:line_number)
+ rescue PreprocessError
+ results[:parse_error] = false
end
def with_csv_lines
- csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
- validate_headers_presence!(csv_data.lines.first)
-
CSV.new(
csv_data,
- col_sep: detect_col_sep(csv_data.lines.first),
+ col_sep: detect_col_sep,
headers: true,
header_converters: :symbol
).each.with_index(2)
end
- def detect_col_sep(header)
+ def csv_data
+ @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
+ end
+ strong_memoize_attr :csv_data
+
+ def detect_col_sep
+ header = csv_data.lines.first
+
if header.include?(",")
","
elsif header.include?(";")
@@ -73,6 +100,7 @@ module ImportCsv
raise CSV::MalformedCSVError.new('Invalid CSV format', 1)
end
end
+ strong_memoize_attr :detect_col_sep
def create_object(attributes)
# NOTE: CSV imports are performed by workers, so we do not have a request context in order
diff --git a/app/services/incident_management/timeline_events/base_service.rb b/app/services/incident_management/timeline_events/base_service.rb
index e997d940ed4..75a3811af2d 100644
--- a/app/services/incident_management/timeline_events/base_service.rb
+++ b/app/services/incident_management/timeline_events/base_service.rb
@@ -29,8 +29,6 @@ module IncidentManagement
namespace = project.namespace
track_usage_event(event, user.id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace)
-
Gitlab::Tracking.event(
self.class.to_s,
event,
diff --git a/app/services/integrations/slack_event_service.rb b/app/services/integrations/slack_event_service.rb
new file mode 100644
index 00000000000..65f3c226e34
--- /dev/null
+++ b/app/services/integrations/slack_event_service.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Performs the initial handling of event payloads sent from Slack to GitLab.
+# See `API::Integrations::Slack::Events` which calls this service.
+module Integrations
+ class SlackEventService
+ URL_VERIFICATION_EVENT = 'url_verification'
+
+ UnknownEventError = Class.new(StandardError)
+
+ def initialize(params)
+ # When receiving URL verification events, params[:type] is 'url_verification'.
+ # For all other events we subscribe to, params[:type] is 'event_callback' and
+ # the specific type of the event will be in params[:event][:type].
+ # Remove both of these from the params before they are passed to the services.
+ type = params.delete(:type)
+ type = params[:event].delete(:type) if type == 'event_callback'
+
+ @slack_event = type
+ @params = params
+ end
+
+ def execute
+ raise UnknownEventError, "Unable to handle event type: '#{slack_event}'" unless routable_event?
+
+ payload = route_event
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ private
+
+ # The `url_verification` slack_event response must be returned to Slack in-request,
+ # so for this event we call the service directly instead of through a worker.
+ #
+ # All other events must be handled asynchronously in order to return a 2xx response
+ # immediately to Slack in the request. See https://api.slack.com/apis/connections/events-api.
+ def route_in_request?
+ slack_event == URL_VERIFICATION_EVENT
+ end
+
+ def routable_event?
+ route_in_request? || route_to_event_worker?
+ end
+
+ def route_to_event_worker?
+ SlackEventWorker.event?(slack_event)
+ end
+
+ # Returns a payload for the service response.
+ def route_event
+ return SlackEvents::UrlVerificationService.new(params).execute if route_in_request?
+
+ SlackEventWorker.perform_async(slack_event: slack_event, params: params)
+
+ {}
+ end
+
+ attr_reader :slack_event, :params
+ end
+end
diff --git a/app/services/integrations/slack_events/app_home_opened_service.rb b/app/services/integrations/slack_events/app_home_opened_service.rb
new file mode 100644
index 00000000000..48dda324270
--- /dev/null
+++ b/app/services/integrations/slack_events/app_home_opened_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+# Handles the Slack `app_home_opened` event sent from Slack to GitLab.
+# Responds with a POST to the Slack API 'views.publish' method.
+#
+# See:
+# - https://api.slack.com/methods/views.publish
+# - https://api.slack.com/events/app_home_opened
+module Integrations
+ module SlackEvents
+ class AppHomeOpenedService
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(params)
+ @slack_user_id = params.dig(:event, :user)
+ @slack_workspace_id = params[:team_id]
+ end
+
+ def execute
+ # Legacy Slack App integrations will not yet have a token we can use
+ # to call the Slack API. Do nothing, and consider the service successful.
+ unless slack_installation
+ logger.info(
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id,
+ message: 'SlackInstallation record has no bot token'
+ )
+
+ return ServiceResponse.success
+ end
+
+ begin
+ response = ::Slack::API.new(slack_installation).post(
+ 'views.publish',
+ payload
+ )
+ rescue *Gitlab::HTTP::HTTP_ERRORS => e
+ return ServiceResponse
+ .error(message: 'HTTP exception when calling Slack API')
+ .track_exception(
+ as: e.class,
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id
+ )
+ end
+
+ return ServiceResponse.success if response['ok']
+
+ # For a list of errors, see:
+ # https://api.slack.com/methods/views.publish#errors
+ ServiceResponse.error(
+ message: 'Slack API returned an error',
+ payload: response
+ ).track_exception(
+ slack_user_id: slack_user_id,
+ slack_workspace_id: slack_workspace_id,
+ response: response.to_h
+ )
+ end
+
+ private
+
+ def slack_installation
+ SlackIntegration.with_bot.find_by_team_id(slack_workspace_id)
+ end
+ strong_memoize_attr :slack_installation
+
+ def slack_gitlab_user_connection
+ ChatNames::FindUserService.new(slack_workspace_id, slack_user_id).execute
+ end
+ strong_memoize_attr :slack_gitlab_user_connection
+
+ def payload
+ {
+ user_id: slack_user_id,
+ view: ::Slack::BlockKit::AppHomeOpened.new(
+ slack_user_id,
+ slack_workspace_id,
+ slack_gitlab_user_connection,
+ slack_installation
+ ).build
+ }
+ end
+
+ def logger
+ Gitlab::IntegrationsLogger
+ end
+
+ attr_reader :slack_user_id, :slack_workspace_id
+ end
+ end
+end
diff --git a/app/services/integrations/slack_events/url_verification_service.rb b/app/services/integrations/slack_events/url_verification_service.rb
new file mode 100644
index 00000000000..dbe2ffc77f8
--- /dev/null
+++ b/app/services/integrations/slack_events/url_verification_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# Returns the special URL verification response expected by Slack when the
+# GitLab Slack app is first configured to receive Slack events.
+#
+# Slack will issue the challenge request to the endpoint that receives events
+# and expect it to respond with same the `challenge` param back.
+#
+# See https://api.slack.com/apis/connections/events-api.
+module Integrations
+ module SlackEvents
+ class UrlVerificationService
+ def initialize(params)
+ @challenge = params[:challenge]
+ end
+
+ def execute
+ { challenge: challenge }
+ end
+
+ private
+
+ attr_reader :challenge
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interaction_service.rb b/app/services/integrations/slack_interaction_service.rb
new file mode 100644
index 00000000000..30e1a396f0d
--- /dev/null
+++ b/app/services/integrations/slack_interaction_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Integrations
+ class SlackInteractionService
+ UnknownInteractionError = Class.new(StandardError)
+
+ INTERACTIONS = {
+ 'view_closed' => SlackInteractions::IncidentManagement::IncidentModalClosedService,
+ 'view_submission' => SlackInteractions::IncidentManagement::IncidentModalSubmitService,
+ 'block_actions' => SlackInteractions::BlockActionService
+ }.freeze
+
+ def initialize(params)
+ @interaction_type = params.delete(:type)
+ @params = params
+ end
+
+ def execute
+ raise UnknownInteractionError, "Unable to handle interaction type: '#{interaction_type}'" \
+ unless interaction?(interaction_type)
+
+ service_class = INTERACTIONS[interaction_type]
+ service_class.new(params).execute
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :interaction_type, :params
+
+ def interaction?(type)
+ INTERACTIONS.key?(type)
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interactions/block_action_service.rb b/app/services/integrations/slack_interactions/block_action_service.rb
new file mode 100644
index 00000000000..d135635fda4
--- /dev/null
+++ b/app/services/integrations/slack_interactions/block_action_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackInteractions
+ class BlockActionService
+ ALLOWED_UPDATES_HANDLERS = {
+ 'incident_management_project' => SlackInteractions::SlackBlockActions::IncidentManagement::ProjectUpdateHandler
+ }.freeze
+
+ def initialize(params)
+ @params = params
+ end
+
+ def execute
+ actions.each do |action|
+ action_id = action[:action_id]
+
+ action_handler_class = ALLOWED_UPDATES_HANDLERS[action_id]
+ action_handler_class.new(params, action).execute
+ end
+ end
+
+ private
+
+ def actions
+ params[:actions].select { |action| ALLOWED_UPDATES_HANDLERS[action[:action_id]] }
+ end
+
+ attr_accessor :params
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb
new file mode 100644
index 00000000000..9daa5d76df7
--- /dev/null
+++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackInteractions
+ module IncidentManagement
+ class IncidentModalClosedService
+ def initialize(params)
+ @params = params
+ end
+
+ def execute
+ begin
+ response = close_modal
+ rescue *Gitlab::HTTP::HTTP_ERRORS => e
+ return ServiceResponse
+ .error(message: 'HTTP exception when calling Slack API')
+ .track_exception(
+ params: params,
+ as: e.class
+ )
+ end
+
+ return ServiceResponse.success if response['ok']
+
+ ServiceResponse.error(
+ message: _('Something went wrong while closing the incident form.'),
+ payload: response
+ ).track_exception(
+ response: response.to_h,
+ params: params
+ )
+ end
+
+ private
+
+ attr_accessor :params
+
+ def close_modal
+ request_body = Gitlab::Json.dump(close_request_body)
+ response_url = params.dig(:view, :private_metadata)
+
+ Gitlab::HTTP.post(response_url, body: request_body, headers: headers)
+ end
+
+ def close_request_body
+ {
+ replace_original: 'true',
+ text: _('Incident creation cancelled.')
+ }
+ end
+
+ def headers
+ { 'Content-Type' => 'application/json' }
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_opened_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_opened_service.rb
new file mode 100644
index 00000000000..b7940a5126e
--- /dev/null
+++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_opened_service.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackInteractions
+ module IncidentManagement
+ class IncidentModalOpenedService
+ MAX_PROJECTS = 100
+ CACHE_EXPIRES_IN = 5.minutes
+
+ def initialize(slack_installation, current_user, params)
+ @slack_installation = slack_installation
+ @current_user = current_user
+ @team_id = params[:team_id]
+ @response_url = params[:response_url]
+ @trigger_id = params[:trigger_id]
+ end
+
+ def execute
+ if user_projects.empty?
+ return ServiceResponse.error(message: _('You do not have access to any projects for creating incidents.'))
+ end
+
+ post_modal
+ end
+
+ def self.cache_write(view_id, project_id)
+ Rails.cache.write(cache_build_key(view_id), project_id, expires_in: CACHE_EXPIRES_IN)
+ end
+
+ def self.cache_read(view_id)
+ Rails.cache.read(cache_build_key(view_id))
+ end
+
+ private
+
+ attr_reader :slack_installation, :current_user, :team_id, :response_url, :trigger_id
+
+ def self.cache_build_key(view_id)
+ "slack:incident_modal_opened:#{view_id}"
+ end
+
+ def user_projects
+ current_user.projects_where_can_admin_issues.limit(MAX_PROJECTS)
+ end
+
+ def post_modal
+ begin
+ response = ::Slack::API.new(slack_installation).post(
+ 'views.open',
+ modal_view
+ )
+ rescue *Gitlab::HTTP::HTTP_ERRORS => e
+ return ServiceResponse
+ .error(message: 'HTTP exception when calling Slack API')
+ .track_exception(
+ as: e.class,
+ slack_workspace_id: team_id
+ )
+ end
+
+ if response['ok']
+ self.class.cache_write(view_id(response), project_id(response))
+
+ return ServiceResponse.success(message: _('Please complete the incident creation form.'))
+ end
+
+ ServiceResponse.error(
+ message: _('Something went wrong while opening the incident form.'),
+ payload: response
+ ).track_exception(
+ response: response.to_h,
+ slack_workspace_id: team_id,
+ slack_user_id: slack_installation.user_id
+ )
+ end
+
+ def modal_view
+ {
+ trigger_id: trigger_id,
+ view: modal_payload
+ }
+ end
+
+ def modal_payload
+ ::Slack::BlockKit::IncidentManagement::IncidentModalOpened.new(
+ user_projects,
+ response_url
+ ).build
+ end
+
+ def project_id(response)
+ response.dig(
+ 'view', 'state', 'values',
+ 'project_and_severity_selector',
+ 'incident_management_project', 'selected_option',
+ 'value')
+ end
+
+ def view_id(response)
+ response.dig('view', 'id')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb
new file mode 100644
index 00000000000..34af03640d3
--- /dev/null
+++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackInteractions
+ module IncidentManagement
+ class IncidentModalSubmitService
+ include GitlabRoutingHelper
+ include Gitlab::Routing
+
+ IssueCreateError = Class.new(StandardError)
+
+ def initialize(params)
+ @params = params
+ @values = params.dig(:view, :state, :values)
+ @team_id = params.dig(:team, :id)
+ @user_id = params.dig(:user, :id)
+ @additional_message = ''
+ end
+
+ def execute
+ create_response = Issues::CreateService.new(
+ container: project,
+ current_user: find_user.user,
+ params: incident_params,
+ spam_params: nil
+ ).execute
+
+ raise IssueCreateError, create_response.errors.to_sentence if create_response.error?
+
+ incident = create_response.payload[:issue]
+ incident_link = incident_link_text(incident)
+ response = send_to_slack(incident_link)
+
+ return ServiceResponse.success(payload: { incident: incident }) if response['ok']
+
+ ServiceResponse.error(
+ message: _('Something went wrong when sending the incident link to Slack.'),
+ payload: response
+ ).track_exception(
+ response: response.to_h,
+ slack_workspace_id: team_id,
+ slack_user_id: user_id
+ )
+ rescue StandardError => e
+ send_to_slack(_('There was a problem creating the incident. Please try again.'))
+
+ ServiceResponse
+ .error(
+ message: e.message
+ ).track_exception(
+ slack_workspace_id: team_id,
+ slack_user_id: user_id,
+ as: e.class
+ )
+ end
+
+ private
+
+ attr_accessor :params, :values, :team_id, :user_id, :additional_message
+
+ def incident_params
+ {
+ title: values.dig(:title_input, :title, :value),
+ severity: severity,
+ confidential: confidential?,
+ description: description,
+ escalation_status: { status: status },
+ issue_type: "incident",
+ assignee_ids: [assignee],
+ label_ids: labels
+ }
+ end
+
+ def strip_markup(string)
+ SlackMarkdownSanitizer.sanitize(string)
+ end
+
+ def send_to_slack(text)
+ response_url = params.dig(:view, :private_metadata)
+
+ body = {
+ replace_original: 'true',
+ text: text
+ }
+
+ Gitlab::HTTP.post(
+ response_url,
+ body: Gitlab::Json.dump(body),
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ def incident_link_text(incident)
+ "#{_('New incident has been created')}: " \
+ "<#{issue_url(incident)}|#{incident.to_reference} " \
+ "- #{strip_markup(incident.title)}>. #{@additional_message}"
+ end
+
+ def project
+ project_id = values.dig(
+ :project_and_severity_selector,
+ :incident_management_project,
+ :selected_option,
+ :value)
+
+ Project.find(project_id)
+ end
+
+ def find_user
+ ChatNames::FindUserService.new(team_id, user_id).execute
+ end
+
+ def description
+ description =
+ values.dig(:incident_description, :description, :value) ||
+ values.dig(project.id.to_s.to_sym, :description, :value)
+
+ zoom_link = values.dig(:zoom, :link, :value)
+
+ return description if zoom_link.blank?
+
+ "#{description} \n/zoom #{zoom_link}"
+ end
+
+ def confidential?
+ values.dig(:confidentiality, :confidential, :selected_options).present?
+ end
+
+ def severity
+ values.dig(:project_and_severity_selector, :severity, :selected_option, :value) || 'unknown'
+ end
+
+ def status
+ values.dig(:status_and_assignee_selector, :status, :selected_option, :value)
+ end
+
+ def assignee
+ assignee_id = values.dig(:status_and_assignee_selector, :assignee, :selected_option, :value)
+
+ return unless assignee_id
+
+ user = User.find_by_id(assignee_id)
+ member = project.member(user)
+
+ unless member
+ @additional_message =
+ "However, " \
+ "#{user.name} was not assigned to the incident as they are not a member in #{project.name}."
+
+ return
+ end
+
+ member.user_id
+ end
+
+ def labels
+ values.dig(:label_selector, :labels, :selected_options)&.pluck(:value)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb b/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb
new file mode 100644
index 00000000000..5f24c8ec4f5
--- /dev/null
+++ b/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackInteractions
+ module SlackBlockActions
+ module IncidentManagement
+ class ProjectUpdateHandler
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(params, action)
+ @view = params[:view]
+ @action = action
+ @team_id = params.dig(:view, :team_id)
+ @user_id = params.dig(:user, :id)
+ end
+
+ def execute
+ return if project_unchanged?
+ return unless allowed?
+
+ post_updated_modal
+ end
+
+ private
+
+ def allowed?
+ return false unless current_user
+
+ current_user.can?(:read_project, old_project) &&
+ current_user.can?(:read_project, new_project)
+ end
+
+ def current_user
+ ChatNames::FindUserService.new(team_id, user_id).execute&.user
+ end
+ strong_memoize_attr :current_user
+
+ def slack_installation
+ SlackIntegration.with_bot.find_by_team_id(team_id)
+ end
+ strong_memoize_attr :slack_installation
+
+ def post_updated_modal
+ modal = update_modal
+
+ begin
+ response = ::Slack::API.new(slack_installation).post(
+ 'views.update',
+ {
+ view_id: view[:id],
+ view: modal
+ }
+ )
+ rescue *::Gitlab::HTTP::HTTP_ERRORS => e
+ return ServiceResponse
+ .error(message: 'HTTP exception when calling Slack API')
+ .track_exception(
+ as: e.class,
+ slack_workspace_id: view[:team_id]
+ )
+ end
+
+ return ServiceResponse.success(message: _('Modal updated')) if response['ok']
+
+ ServiceResponse.error(
+ message: _('Something went wrong while updating the modal.'),
+ payload: response
+ ).track_exception(
+ response: response.to_h,
+ slack_workspace_id: view[:team_id],
+ slack_user_id: slack_installation.user_id
+ )
+ end
+
+ def update_modal
+ updated_view = update_incident_template
+ cleanup(updated_view)
+ end
+
+ def update_incident_template
+ updated_view = view.dup
+
+ incident_description_blocks = updated_view[:blocks].select do |block|
+ block[:block_id] == 'incident_description' || block[:block_id] == old_project.id.to_s
+ end
+
+ incident_description_blocks.first[:element][:initial_value] = read_template_content
+ incident_description_blocks.first[:block_id] = new_project.id.to_s
+
+ Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService
+ .cache_write(view[:id], new_project.id.to_s)
+
+ updated_view
+ end
+
+ def new_project
+ Project.find(action.dig(:selected_option, :value))
+ end
+ strong_memoize_attr :new_project
+
+ def old_project
+ old_project_id = Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService
+ .cache_read(view[:id])
+
+ Project.find(old_project_id) if old_project_id
+ end
+ strong_memoize_attr :old_project
+
+ def project_unchanged?
+ old_project == new_project
+ end
+
+ def read_template_content
+ new_project.incident_management_setting&.issue_template_content.to_s
+ end
+
+ def cleanup(view)
+ view.except!(
+ :id, :team_id, :state,
+ :hash, :previous_view_id,
+ :root_view_id, :app_id,
+ :app_installed_team_id,
+ :bot_id)
+ end
+
+ attr_accessor :view, :action, :team_id, :user_id
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/integrations/slack_option_service.rb b/app/services/integrations/slack_option_service.rb
new file mode 100644
index 00000000000..a659f8b0634
--- /dev/null
+++ b/app/services/integrations/slack_option_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Integrations
+ class SlackOptionService
+ UnknownOptionError = Class.new(StandardError)
+
+ OPTIONS = {
+ 'assignee' => SlackOptions::UserSearchHandler,
+ 'labels' => SlackOptions::LabelSearchHandler
+ }.freeze
+
+ def initialize(params)
+ @params = params
+ @search_type = params.delete(:action_id)
+ @selected_value = params.delete(:value)
+ @view_id = params.dig(:view, :id)
+ end
+
+ def execute
+ raise UnknownOptionError, "Unable to handle option: '#{search_type}'" \
+ unless option?(search_type)
+
+ handler_class = OPTIONS[search_type]
+ handler_class.new(current_user, selected_value, view_id).execute
+ end
+
+ private
+
+ def current_user
+ ChatNames::FindUserService.new(
+ params.dig(:team, :id),
+ params.dig(:user, :id)
+ ).execute
+ end
+
+ def option?(option)
+ OPTIONS.key?(option)
+ end
+
+ attr_reader :params, :search_type, :selected_value, :view_id
+ end
+end
diff --git a/app/services/integrations/slack_options/label_search_handler.rb b/app/services/integrations/slack_options/label_search_handler.rb
new file mode 100644
index 00000000000..4e5c9dcb48a
--- /dev/null
+++ b/app/services/integrations/slack_options/label_search_handler.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackOptions
+ class LabelSearchHandler # rubocop:disable Search/NamespacedClass
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(current_user, search_value, view_id)
+ @current_user = current_user.user
+ @search_value = search_value
+ @view_id = view_id
+ end
+
+ def execute
+ return ServiceResponse.success(payload: []) unless current_user.can?(:read_label, project)
+
+ labels = LabelsFinder.new(
+ current_user,
+ {
+ project: project,
+ search: search_value
+ }
+ ).execute
+
+ ServiceResponse.success(payload: build_label_list(labels))
+ end
+
+ private
+
+ def project
+ project_id = Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService
+ .cache_read(view_id)
+
+ return unless project_id
+
+ Project.find(project_id)
+ end
+ strong_memoize_attr :project
+
+ def build_label_list(labels)
+ return [] unless labels
+
+ label_list = labels.map do |label|
+ {
+ text: {
+ type: "plain_text",
+ text: label.name
+ },
+ value: label.id.to_s
+ }
+ end
+
+ {
+ options: label_list
+ }
+ end
+
+ attr_accessor :current_user, :search_value, :view_id
+ end
+ end
+end
diff --git a/app/services/integrations/slack_options/user_search_handler.rb b/app/services/integrations/slack_options/user_search_handler.rb
new file mode 100644
index 00000000000..b7a400b5ee2
--- /dev/null
+++ b/app/services/integrations/slack_options/user_search_handler.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Integrations
+ module SlackOptions
+ class UserSearchHandler # rubocop:disable Search/NamespacedClass
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(current_user, search_value, view_id)
+ @current_user = current_user.user
+ @search_value = search_value
+ @view_id = view_id
+ end
+
+ def execute
+ return ServiceResponse.success(payload: []) unless current_user.can?(:read_project_member, project)
+
+ members = MembersFinder.new(project, current_user, params: { search: search_value }).execute
+
+ ServiceResponse.success(payload: build_user_list(members))
+ end
+
+ private
+
+ def project
+ project_id = SlackInteractions::IncidentManagement::IncidentModalOpenedService
+ .cache_read(view_id)
+
+ return unless project_id
+
+ Project.find(project_id)
+ end
+ strong_memoize_attr :project
+
+ def build_user_list(members)
+ return [] unless members
+
+ user_list = members.map do |member|
+ {
+ text: {
+ type: "plain_text",
+ text: "#{member.user.name} - #{member.user.username}"
+ },
+ value: member.user.id.to_s
+ }
+ end
+
+ {
+ options: user_list
+ }
+ end
+
+ attr_reader :current_user, :search_value, :view_id
+ end
+ end
+end
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/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index 02beaaf5d83..a4e815e70fc 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -7,11 +7,6 @@ module Issuable
alias_method :old_project, :project
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
def execute(original_entity, target_parent)
@original_entity = original_entity
@target_parent = target_parent
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
index 4c3e518d62b..261afb767bb 100644
--- a/app/services/issuable/destroy_service.rb
+++ b/app/services/issuable/destroy_service.rb
@@ -4,7 +4,7 @@ module Issuable
class DestroyService < IssuableBaseService
# TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
+ super(container: container, current_user: current_user, params: params)
end
def execute(issuable)
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 83cf5a67453..9ef9fb76e3c 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -21,7 +21,7 @@ module Issuable
headers.downcase! if headers
return if headers && headers.include?('title') && headers.include?('description')
- raise CSV::MalformedCSVError
+ raise CSV::MalformedCSVError.new('Invalid CSV format - missing required headers.', 1)
end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 911d04d6b7a..e9312bd6b31 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -1,8 +1,33 @@
# frozen_string_literal: true
-class IssuableBaseService < ::BaseProjectService
+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
@@ -10,15 +35,15 @@ class IssuableBaseService < ::BaseProjectService
# Follow on issue to address this:
# https://gitlab.com/gitlab-org/gitlab/-/issues/328438
- { project: value }
+ { container: value }
end
- attr_accessor :params, :skip_milestone_email
+ attr_accessor :params
- def initialize(project:, current_user: nil, params: {})
- super
-
- @skip_milestone_email = @params.delete(:skip_milestone_email)
+ 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))
end
def can_admin_issuable?(issuable)
@@ -34,10 +59,7 @@ class IssuableBaseService < ::BaseProjectService
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)
@@ -61,7 +83,6 @@ class IssuableBaseService < ::BaseProjectService
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)
@@ -102,19 +123,6 @@ class IssuableBaseService < ::BaseProjectService
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)
@@ -206,6 +214,8 @@ class IssuableBaseService < ::BaseProjectService
end
def create(issuable, skip_system_notes: false)
+ initialize_callbacks!(issuable)
+
handle_quick_actions(issuable)
filter_params(issuable)
@@ -229,6 +239,8 @@ class IssuableBaseService < ::BaseProjectService
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 })
@@ -278,19 +290,22 @@ class IssuableBaseService < ::BaseProjectService
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?
@@ -307,13 +322,15 @@ class IssuableBaseService < ::BaseProjectService
# 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]
)
@@ -584,14 +601,6 @@ class IssuableBaseService < ::BaseProjectService
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/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index f244f54b25f..1069c9e0915 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -19,6 +19,10 @@ module IssuableLinks
return error(issuables_already_assigned_message, 409)
end
+ if render_no_permission_error?
+ return error(issuables_no_permission_error_message, 403)
+ end
+
if render_not_found_error?
return error(issuables_not_found_message, 404)
end
@@ -46,6 +50,7 @@ module IssuableLinks
link
end
+
# rubocop: enable CodeReuse/ActiveRecord
private
@@ -54,6 +59,10 @@ module IssuableLinks
referenced_issuables.present? && (referenced_issuables - previous_related_issuables).empty?
end
+ def render_no_permission_error?
+ readonly_issuables(referenced_issuables).present? && linkable_issuables(referenced_issuables).empty?
+ end
+
def render_not_found_error?
linkable_issuables(referenced_issuables).empty?
end
@@ -116,6 +125,10 @@ module IssuableLinks
_('%{issuable}(s) already assigned' % { issuable: target_issuable_type.capitalize })
end
+ def issuables_no_permission_error_message
+ _("Couldn't link %{issuable}. You must have at least the Reporter role in both projects." % { issuable: target_issuable_type })
+ end
+
def issuables_not_found_message
_('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} URL.' % { issuable: target_issuable_type })
end
@@ -133,6 +146,10 @@ module IssuableLinks
raise NotImplementedError
end
+ def readonly_issuables(_issuables)
+ [] # default to empty for non-issues
+ end
+
def previous_related_issuables
raise NotImplementedError
end
diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb
index 80c6af88f21..db05920678e 100644
--- a/app/services/issue_links/create_service.rb
+++ b/app/services/issue_links/create_service.rb
@@ -14,6 +14,10 @@ module IssueLinks
private
+ def readonly_issuables(issuables)
+ @readonly_issuables ||= issuables.select { |issuable| issuable.readable_by?(current_user) }
+ end
+
def track_event
track_incident_action(current_user, issuable, :incident_relate)
end
diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb
index 011a78029c8..e996724ebd6 100644
--- a/app/services/issues/after_create_service.rb
+++ b/app/services/issues/after_create_service.rb
@@ -2,14 +2,8 @@
module Issues
class AfterCreateService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
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 553fb6e2ac9..efe42fb29d5 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -33,6 +33,14 @@ module Issues
private
+ # overriding this because IssuableBaseService#constructor_container_arg returns { project: value }
+ # Issues::ReopenService constructor signature is different now, it takes container instead of project also
+ # IssuableBaseService#change_state dynamically picks one of the `Issues::ReopenService`, `Epics::ReopenService` or
+ # MergeRequests::ReopenService, so we need this method to return { }container: value } for Issues::ReopenService
+ def self.constructor_container_arg(value)
+ { container: value }
+ end
+
def find_work_item_type_id(issue_type)
work_item_type = WorkItems::Type.default_by_type(issue_type)
work_item_type ||= WorkItems::Type.default_issue_type
@@ -45,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
@@ -95,10 +107,10 @@ 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?
+ execute_incident_hooks(issue, issue_data) if issue.work_item_type&.incident?
end
# We can remove this code after proposal in
@@ -106,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 877ce09e065..a65fc0c7c87 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -4,16 +4,21 @@ module Issues
class BuildService < Issues::BaseService
include ResolveDiscussions
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
- 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|
+ set_work_item_type(issue)
+ initialize_callbacks!(issue) if initialize_callbacks
end
end
@@ -66,22 +71,32 @@ module Issues
def issue_params
@issue_params ||= build_issue_params
+ end
+
+ private
+
+ 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 @issue_params[:work_item_type].present?
- @issue_params[:issue_type] = @issue_params[: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 = 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])
+ # If no work item type was provided or not allowed, we need to set it to issue_type,
+ # and that includes the column default
+ issue_type = issue_params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE
+ issue.work_item_type = WorkItems::Type.default_by_type(issue_type)
end
-
- @issue_params
end
- private
-
def model_klass
::Issue
end
@@ -94,11 +109,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
@@ -109,10 +120,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 9fde1cc2ac2..e45033f2b91 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -2,11 +2,6 @@
module Issues
class CloseService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
# Closes the supplied issue if the current user is able to do so.
def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false)
return issue unless can_close?(issue, skip_authorization: skip_authorization)
@@ -48,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
@@ -56,11 +51,6 @@ module Issues
private
- # TODO: remove once MergeRequests::CloseService or IssuableBaseService method is changed.
- def self.constructor_container_arg(value)
- { container: value }
- end
-
def can_close?(issue, skip_authorization: false)
skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue)
end
@@ -103,7 +93,7 @@ module Issues
end
def resolve_incident(issue)
- return unless issue.incident?
+ return unless issue.work_item_type&.incident?
status = issue.incident_management_issuable_escalation_status || issue.build_incident_management_issuable_escalation_status
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index fa5233da489..ba8f00d03d4 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -15,18 +15,22 @@ module Issues
# SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil.
def initialize(container:, spam_params:, current_user: nil, params: {}, build_service: nil)
@extra_params = params.delete(:extra_params) || {}
- super(project: container, current_user: current_user, params: params)
+ super(container: container, current_user: current_user, params: params)
@spam_params = spam_params
- @build_service = build_service || BuildService.new(container: project, current_user: current_user, params: params)
+ @build_service = build_service ||
+ BuildService.new(container: project, current_user: current_user, params: params)
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)
- @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)
+ # 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)
+
+ # issue_type and work_item_type are set in BuildService, so we can delete it from params, in later phase
+ # it can be set also from quick actions
+ [:issue_type, :work_item_type, :work_item_type_id].each { |attribute| params.delete(attribute) }
handle_move_between_ids(@issue)
@@ -59,7 +63,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
@@ -71,7 +76,6 @@ module Issues
handle_escalation_status_change(issue)
create_timeline_event(issue)
try_to_associate_contacts(issue)
- change_additional_attributes(issue)
super
end
@@ -88,6 +92,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)
@@ -100,18 +105,6 @@ module Issues
private
- def self.constructor_container_arg(value)
- { container: value }
- end
-
- 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)
-
- super
- end
-
def authorization_action
:create_issue
end
@@ -119,7 +112,7 @@ module Issues
attr_reader :spam_params, :extra_params
def create_timeline_event(issue)
- return unless issue.incident?
+ return unless issue.work_item_type&.incident?
IncidentManagement::TimelineEvents::CreateService.create_incident(issue, current_user)
end
@@ -143,15 +136,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/duplicate_service.rb b/app/services/issues/duplicate_service.rb
index a3213c50f86..1fff9a4a684 100644
--- a/app/services/issues/duplicate_service.rb
+++ b/app/services/issues/duplicate_service.rb
@@ -2,11 +2,6 @@
module Issues
class DuplicateService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
def execute(duplicate_issue, canonical_issue)
return if canonical_issue == duplicate_issue
return unless can?(current_user, :update_issue, duplicate_issue)
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index d7c1ea276de..9e524d90505 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -18,7 +18,7 @@ module Issues
private
def associations_to_preload
- [:author, :assignees, :timelogs, :milestone, { project: { namespace: :route } }]
+ [:work_item_type, :author, :assignees, :timelogs, :milestone, { project: { namespace: :route } }]
end
def header_to_value_hash
diff --git a/app/services/issues/referenced_merge_requests_service.rb b/app/services/issues/referenced_merge_requests_service.rb
index ba03927136a..ff7cf65e757 100644
--- a/app/services/issues/referenced_merge_requests_service.rb
+++ b/app/services/issues/referenced_merge_requests_service.rb
@@ -2,19 +2,15 @@
module Issues
class ReferencedMergeRequestsService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def execute(issue)
referenced = referenced_merge_requests(issue)
closed_by = closed_by_merge_requests(issue)
- preloader = ActiveRecord::Associations::Preloader.new
- preloader.preload(referenced + closed_by,
- head_pipeline: { project: [:route, { namespace: :route }] })
+ ActiveRecord::Associations::Preloader.new(
+ records: referenced + closed_by,
+ associations: { head_pipeline: { project: [:route, { namespace: :route }] } }
+ ).call
[sort_by_iid(referenced), sort_by_iid(closed_by)]
end
diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb
index 3f4413fdfd7..ef6de83fcf4 100644
--- a/app/services/issues/related_branches_service.rb
+++ b/app/services/issues/related_branches_service.rb
@@ -4,11 +4,6 @@
# those with a merge request open referencing the current issue.
module Issues
class RelatedBranchesService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
def execute(issue)
branch_names_with_mrs = branches_with_merge_request_for(issue)
branches = branches_with_iid_of(issue).reject { |b| branch_names_with_mrs.include?(b[:name]) }
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index ebcf2fb5c83..f4d229ecec7 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -2,11 +2,6 @@
module Issues
class ReopenService < Issues::BaseService
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
def execute(issue, skip_authorization: false)
return issue unless can_reopen?(issue, skip_authorization: skip_authorization)
@@ -18,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
@@ -27,20 +22,12 @@ module Issues
private
- # overriding this because IssuableBaseService#constructor_container_arg returns { project: value }
- # Issues::ReopenService constructor signature is different now, it takes container instead of project also
- # IssuableBaseService#change_state dynamically picks one of the `Issues::ReopenService`, `Epics::ReopenService` or
- # MergeRequests::ReopenService, so we need this method to return { }container: value } for Issues::ReopenService
- def self.constructor_container_arg(value)
- { container: value }
- end
-
def can_reopen?(issue, skip_authorization: false)
skip_authorization || can?(current_user, :reopen_issue, issue)
end
def perform_incident_management_actions(issue)
- return unless issue.incident?
+ return unless issue.work_item_type&.incident?
create_timeline_event(issue)
end
diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb
index 059b4196b23..1afec4c94f4 100644
--- a/app/services/issues/reorder_service.rb
+++ b/app/services/issues/reorder_service.rb
@@ -4,11 +4,6 @@ module Issues
class ReorderService < Issues::BaseService
include Gitlab::Utils::StrongMemoize
- # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container
- def initialize(container:, current_user: nil, params: {})
- super(project: container, current_user: current_user, params: params)
- end
-
def execute(issue)
return false unless can?(current_user, :update_issue, issue)
return false unless move_between_ids
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 71324b3f044..201bf19b535 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -6,7 +6,7 @@ module Issues
# necessary in many cases, and we don't want to require every caller to explicitly pass it as nil
# to disable spam checking.
def initialize(container:, current_user: nil, params: {}, spam_params: nil)
- super(project: container, current_user: current_user, params: params)
+ super(container: container, current_user: current_user, params: params)
@spam_params = spam_params
end
@@ -39,7 +39,8 @@ module Issues
def change_work_item_type(issue)
return unless issue.changed_attributes['issue_type']
- type_id = find_work_item_type_id(issue.issue_type)
+ issue_type = params[:issue_type] || ::Issue::DEFAULT_ISSUE_TYPE
+ type_id = find_work_item_type_id(issue_type)
issue.work_item_type_id = type_id
end
@@ -64,7 +65,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 +76,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,23 +117,6 @@ module Issues
attr_reader :spam_params
- # TODO: remove this once MergeRequests::UpdateService#initialize is changed to take container as named argument.
- #
- # Issues::UpdateService is used together with MergeRequests::UpdateService in Mutations::Assignable#assign! method
- # however MergeRequests::UpdateService#initialize still takes `project` as param and Issues::UpdateService is being
- # changed to take `container` as param. So we are adding this workaround in the meantime.
- def self.constructor_container_arg(value)
- { container: value }
- end
-
- 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?
@@ -175,35 +159,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
@@ -229,7 +184,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/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb
index 4144c293990..bfd3e6a945f 100644
--- a/app/services/issues/zoom_link_service.rb
+++ b/app/services/issues/zoom_link_service.rb
@@ -3,7 +3,7 @@
module Issues
class ZoomLinkService < Issues::BaseService
def initialize(container:, current_user:, params:)
- super(project: container, current_user: current_user, params: params)
+ super
@issue = params.fetch(:issue)
@added_meeting = ZoomMeeting.canonical_meeting(@issue)
diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb
index 92255711399..497c282072d 100644
--- a/app/services/jira_connect/sync_service.rb
+++ b/app/services/jira_connect/sync_service.rb
@@ -31,7 +31,9 @@ module JiraConnect
jira_response: response&.to_json
}
- if response && response['errorMessages'].present?
+ has_errors = response && (response['errorMessage'].present? || response['errorMessages'].present?)
+
+ if has_errors
logger.error(message)
else
logger.info(message)
diff --git a/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb
index d94d9e1324e..9f3b4a37672 100644
--- a/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb
+++ b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb
@@ -82,9 +82,9 @@ module JiraConnectInstallations
Gitlab::IntegrationsLogger.info(
integration: 'JiraConnect',
message: 'Proxy lifecycle event received error response',
- event_type: event,
- status_code: status_code,
- body: body
+ jira_event_type: event,
+ jira_status_code: status_code,
+ jira_body: body
)
end
end
diff --git a/app/services/keys/last_used_service.rb b/app/services/keys/last_used_service.rb
index daef544bac0..3683c03b7a4 100644
--- a/app/services/keys/last_used_service.rb
+++ b/app/services/keys/last_used_service.rb
@@ -2,7 +2,7 @@
module Keys
class LastUsedService
- TIMEOUT = 1.day.to_i
+ TIMEOUT = 1.day
attr_reader :key
@@ -12,26 +12,24 @@ module Keys
end
def execute
+ return unless update?
+
# We _only_ want to update last_used_at and not also updated_at (which
# would be updated when using #touch).
- key.update_column(:last_used_at, Time.zone.now) if update?
+ key.update_column(:last_used_at, Time.zone.now)
end
- def update?
- return false if ::Gitlab::Database.read_only?
-
- last_used = key.last_used_at
+ def execute_async
+ return unless update?
- return false if last_used && (Time.zone.now - last_used) <= TIMEOUT
-
- !!redis_lease.try_obtain
+ ::SshKeys::UpdateLastUsedAtWorker.perform_async(key.id)
end
- private
+ def update?
+ return false if ::Gitlab::Database.read_only?
- def redis_lease
- Gitlab::ExclusiveLease
- .new("key_update_last_used_at:#{key.id}", timeout: TIMEOUT)
+ last_used = key.last_used_at
+ last_used.blank? || last_used <= TIMEOUT.ago
end
end
end
diff --git a/app/services/keys/revoke_service.rb b/app/services/keys/revoke_service.rb
index 42ea9ab73be..9684d4e461e 100644
--- a/app/services/keys/revoke_service.rb
+++ b/app/services/keys/revoke_service.rb
@@ -13,8 +13,6 @@ module Keys
private
def unverify_associated_signatures(key)
- return unless Feature.enabled?(:revoke_ssh_signatures)
-
key.ssh_signatures.each_batch do |batch|
batch.update_all(
verification_status: CommitSignatures::SshSignature.verification_statuses[:revoked_key],
diff --git a/app/services/markup/rendering_service.rb b/app/services/markup/rendering_service.rb
index cd89c170efa..104bdb6dd41 100644
--- a/app/services/markup/rendering_service.rb
+++ b/app/services/markup/rendering_service.rb
@@ -52,6 +52,8 @@ module Markup
def other_markup_unsafe
Gitlab::OtherMarkup.render(file_name, text, context)
+ rescue GitHub::Markup::CommandError
+ ActionController::Base.helpers.simple_format(text)
end
def postprocess(html)
diff --git a/app/services/mattermost/create_team_service.rb b/app/services/mattermost/create_team_service.rb
index 9f6efab1e43..dc448cbc5eb 100644
--- a/app/services/mattermost/create_team_service.rb
+++ b/app/services/mattermost/create_team_service.rb
@@ -9,7 +9,7 @@ module Mattermost
def execute
# The user that creates the team will be Team Admin
- ::Mattermost::Team.new(current_user).create(@group.mattermost_team_params)
+ ::Mattermost::Team.new(current_user).create(**@group.mattermost_team_params)
rescue ::Mattermost::ClientError => e
@group.errors.add(:mattermost_team, e.message)
end
diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb
index 20f96ac2949..f8c91fbae7d 100644
--- a/app/services/members/approve_access_request_service.rb
+++ b/app/services/members/approve_access_request_service.rb
@@ -18,7 +18,7 @@ module Members
def after_execute(member:, skip_log_audit_event:)
super
- resolve_access_request_todos(current_user, member)
+ resolve_access_request_todos(member)
end
def validate_access!(access_requester)
diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb
index 801f77ae082..80fba33b20e 100644
--- a/app/services/members/base_service.rb
+++ b/app/services/members/base_service.rb
@@ -53,8 +53,8 @@ module Members
end
end
- def resolve_access_request_todos(current_user, requester)
- todo_service.resolve_access_request_todos(current_user, requester)
+ def resolve_access_request_todos(member)
+ todo_service.resolve_access_request_todos(member)
end
def enqueue_delete_todos(member)
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/members/destroy_service.rb b/app/services/members/destroy_service.rb
index dd84b890385..e432016795d 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -4,7 +4,15 @@ module Members
class DestroyService < Members::BaseService
include Gitlab::ExclusiveLeaseHelpers
- def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false, destroy_bot: false)
+ def execute(
+ member,
+ skip_authorization: false,
+ skip_subresources: false,
+ unassign_issuables: false,
+ destroy_bot: false,
+ skip_saml_identity: false
+ )
+
unless skip_authorization
raise Gitlab::Access::AccessDeniedError unless authorized?(member, destroy_bot)
@@ -15,18 +23,39 @@ module Members
@skip_auth = skip_authorization
if a_group_owner?(member)
- process_destroy_of_group_owner_member(member, skip_subresources, unassign_issuables)
+ process_destroy_of_group_owner_member(member, skip_subresources, skip_saml_identity)
else
destroy_member(member)
- destroy_data_related_to_member(member, skip_subresources, unassign_issuables)
+ destroy_data_related_to_member(member, skip_subresources, skip_saml_identity)
end
+ enqueue_jobs_that_needs_to_be_run_only_once_per_hierarchy(member, unassign_issuables)
+
member
end
+ # We use this to mark recursive calls made to this service from within the same service.
+ # We do this so as to help us run some tasks that needs to be run only once per hierarchy, and not recursively.
+ def mark_as_recursive_call
+ @recursive_call = true
+ end
+
private
- def process_destroy_of_group_owner_member(member, skip_subresources, unassign_issuables)
+ # These actions need to be executed only once per hierarchy because the underlying services
+ # apply these actions to the entire hierarchy anyway, so there is no need to execute them recursively.
+ def enqueue_jobs_that_needs_to_be_run_only_once_per_hierarchy(member, unassign_issuables)
+ return if recursive_call?
+
+ enqueue_delete_todos(member)
+ enqueue_unassign_issuables(member) if unassign_issuables
+ end
+
+ def recursive_call?
+ @recursive_call == true
+ end
+
+ def process_destroy_of_group_owner_member(member, skip_subresources, skip_saml_identity)
# Deleting 2 different group owners via the API in quick succession could lead to
# wrong results for the `last_owner?` check due to race conditions. To prevent this
# we wrap both the last_owner? check and the deletes of owners within a lock.
@@ -40,34 +69,32 @@ module Members
end
# deletion of related data does not have to be within the lock.
- destroy_data_related_to_member(member, skip_subresources, unassign_issuables) unless last_group_owner
+ destroy_data_related_to_member(member, skip_subresources, skip_saml_identity) unless last_group_owner
end
def destroy_member(member)
member.destroy
end
- def destroy_data_related_to_member(member, skip_subresources, unassign_issuables)
+ def destroy_data_related_to_member(member, skip_subresources, skip_saml_identity)
member.user&.invalidate_cache_counts
- delete_member_associations(member, skip_subresources, unassign_issuables)
+ delete_member_associations(member, skip_subresources, skip_saml_identity)
end
def a_group_owner?(member)
member.is_a?(GroupMember) && member.owner?
end
- def delete_member_associations(member, skip_subresources, unassign_issuables)
+ def delete_member_associations(member, skip_subresources, skip_saml_identity)
if member.request? && member.user != current_user
notification_service.decline_access_request(member)
end
delete_subresources(member) unless skip_subresources
delete_project_invitations_by(member) unless skip_subresources
- resolve_access_request_todos(current_user, member)
- enqueue_delete_todos(member)
- enqueue_unassign_issuables(member) if unassign_issuables
+ resolve_access_request_todos(member)
- after_execute(member: member)
+ after_execute(member: member, skip_saml_identity: skip_saml_identity)
end
def authorized?(member, destroy_bot)
@@ -110,13 +137,17 @@ module Members
def destroy_project_members(members)
members.each do |project_member|
- self.class.new(current_user).execute(project_member, skip_authorization: @skip_auth)
+ service = self.class.new(current_user)
+ service.mark_as_recursive_call
+ service.execute(project_member, skip_authorization: @skip_auth)
end
end
def destroy_group_members(members)
members.each do |group_member|
- self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true)
+ service = self.class.new(current_user)
+ service.mark_as_recursive_call
+ service.execute(group_member, skip_authorization: @skip_auth, skip_subresources: true)
end
end
diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb
index 2ce6073050e..a9ef3e85911 100644
--- a/app/services/merge_requests/add_context_service.rb
+++ b/app/services/merge_requests/add_context_service.rb
@@ -57,7 +57,7 @@ module MergeRequests
def build_context_commit_rows(merge_request_id, commits)
commits.map.with_index do |commit, index|
# generate context commit information for given commit
- commit_hash = commit.to_hash.except(:parent_ids)
+ commit_hash = commit.to_hash.except(:parent_ids, :referenced_by)
sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id))
commit_hash.merge(
merge_request_id: merge_request_id,
@@ -75,7 +75,7 @@ module MergeRequests
diff_order = 0
commits.flat_map.with_index do |commit, index|
- commit_hash = commit.to_hash.except(:parent_ids)
+ commit_hash = commit.to_hash.except(:parent_ids, :referenced_by)
sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id))
# generate context commit diff information for given commit
diffs = commit.diffs
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index 11251e56ee3..f174778e12e 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -7,6 +7,7 @@ module MergeRequests
def execute(merge_request)
merge_request.ensure_merge_request_diff
+ execute_hooks(merge_request)
prepare_for_mergeability(merge_request)
prepare_merge_request(merge_request)
@@ -39,8 +40,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 f6cbe889128..ec8a17162ca 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -5,6 +5,12 @@ module MergeRequests
extend ::Gitlab::Utils::Override
include MergeRequests::AssignsMergeParams
+ delegate :repository, to: :project
+
+ def initialize(project:, current_user: nil, params: {})
+ super(container: project, current_user: current_user, params: params)
+ end
+
def create_note(merge_request, state = merge_request.state)
SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, state, nil)
end
@@ -20,6 +26,10 @@ module MergeRequests
end
def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
+ # NOTE: Due to the async merge request diffs generation, we need to skip this for CreateService and execute it in
+ # AfterCreateService instead so that the webhook consumers receive the update when diffs are ready.
+ return if merge_request.skip_ensure_merge_request_diff
+
merge_data = Gitlab::Lazy.new { hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations) }
merge_request.project.execute_hooks(merge_data, :merge_request_hooks)
merge_request.project.execute_integrations(merge_data, :merge_request_hooks)
@@ -94,6 +104,10 @@ module MergeRequests
private
+ def self.constructor_container_arg(value)
+ { project: value }
+ end
+
def refresh_pipelines_on_merge_requests(merge_request, allow_duplicate: false)
create_pipeline_for(merge_request, current_user, async: true, allow_duplicate: allow_duplicate)
end
@@ -253,12 +267,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..3a7b577d59a 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 = []
@@ -40,17 +42,17 @@ module MergeRequests
attr_accessor :merge_request
delegate :target_branch,
- :target_branch_ref,
- :target_project,
- :source_branch,
- :source_branch_ref,
- :source_project,
- :compare_commits,
- :draft_title,
- :description,
- :first_multiline_commit,
- :errors,
- to: :merge_request
+ :target_branch_ref,
+ :target_project,
+ :source_branch,
+ :source_branch_ref,
+ :source_project,
+ :compare_commits,
+ :draft_title,
+ :description,
+ :first_multiline_commit,
+ :errors,
+ to: :merge_request
def force_remove_source_branch
if params.key?(:force_remove_source_branch)
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 75e1adec41b..39e1594d215 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -4,6 +4,7 @@ module MergeRequests
class CreateService < MergeRequests::BaseService
def execute
set_projects!
+ set_default_attributes!
merge_request = MergeRequest.new
merge_request.target_project = @project
@@ -61,6 +62,10 @@ module MergeRequests
raise Gitlab::Access::AccessDeniedError
end
end
+
+ def set_default_attributes!
+ # Implemented in EE
+ end
end
end
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
index 6e1d1b6ad23..1a83bbf9de6 100644
--- a/app/services/merge_requests/ff_merge_service.rb
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -14,10 +14,12 @@ module MergeRequests
override :execute_git_merge
def execute_git_merge
- repository.ff_merge(current_user,
- source,
- merge_request.target_branch,
- merge_request: merge_request)
+ repository.ff_merge(
+ current_user,
+ source,
+ merge_request.target_branch,
+ merge_request: merge_request
+ )
end
override :merge_success_data
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/merge_service.rb b/app/services/merge_requests/merge_service.rb
index e6b0ffbf716..10301774f96 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -115,8 +115,7 @@ module MergeRequests
def try_merge
execute_git_merge
rescue Gitlab::Git::PreReceiveError => e
- raise MergeError,
- "Something went wrong during merge pre-receive hook. #{e.message}".strip
+ raise MergeError, "Something went wrong during merge pre-receive hook. #{e.message}".strip
rescue StandardError => e
handle_merge_error(log_message: e.message)
raise_error(GENERIC_ERROR_MESSAGE)
@@ -180,9 +179,7 @@ module MergeRequests
end
def log_payload(message)
- Gitlab::ApplicationContext.current
- .merge(merge_request_info: merge_request_info,
- message: message)
+ Gitlab::ApplicationContext.current.merge(merge_request_info: merge_request_info, message: message)
end
def merge_request_info
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index 8519cbac3cb..1bd26f06e41 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -25,9 +25,7 @@ module MergeRequests
commit = project.commit(commit_id)
target_id, source_id = commit.parent_ids
- success(commit_id: commit.id,
- target_id: target_id,
- source_id: source_id)
+ success(commit_id: commit.id, target_id: target_id, source_id: source_id)
rescue MergeError, ArgumentError => error
error(error.message)
end
diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb
index d25234183fd..987d6ce8e9f 100644
--- a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb
+++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb
@@ -10,6 +10,7 @@ module MergeRequests
end
def execute
+ return :preparing if preparing?
return :checking if checking?
return :unchecked if unchecked?
@@ -31,8 +32,12 @@ module MergeRequests
attr_reader :merge_request, :checks, :ci_check
+ def preparing?
+ merge_request.preparing? && !merge_request.merge_request_diff.persisted?
+ end
+
def checking?
- merge_request.cannot_be_merged_rechecking? || merge_request.preparing? || merge_request.checking?
+ merge_request.cannot_be_merged_rechecking? || merge_request.checking?
end
def unchecked?
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
index e9abafceb13..1890addf692 100644
--- a/app/services/merge_requests/push_options_handler_service.rb
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -4,8 +4,7 @@ module MergeRequests
class PushOptionsHandlerService < ::BaseProjectService
LIMIT = 10
- attr_reader :errors, :changes,
- :push_options, :target_project
+ attr_reader :errors, :changes, :push_options, :target_project
def initialize(project:, current_user:, changes:, push_options:, params: {})
super(project: project, current_user: current_user, params: params)
@@ -112,8 +111,10 @@ module MergeRequests
merge_request = ::MergeRequests::CreateService.new(
project: project,
current_user: current_user,
- params: merge_request.attributes.merge(assignee_ids: merge_request.assignee_ids,
- label_ids: merge_request.label_ids)
+ params: merge_request.attributes.merge(
+ assignee_ids: merge_request.assignee_ids,
+ label_ids: merge_request.label_ids
+ )
).execute
end
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/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 61831a624c7..d6740cdf1ac 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -127,16 +127,23 @@ module MergeRequests
merge_requests_array = merge_requests.to_a + merge_requests_from_forks.to_a
filter_merge_requests(merge_requests_array).each do |merge_request|
+ skip_merge_status_trigger = true
+
if branch_and_project_match?(merge_request) || @push.force_push?
merge_request.reload_diff(current_user)
# Clear existing merge error if the push were directed at the
# source branch. Clearing the error when the target branch
# changes will hide the error from the user.
merge_request.merge_error = nil
+
+ # Don't skip trigger since we to update the MR's merge status in real-time
+ # when the push if for the MR's source branch and project.
+ skip_merge_status_trigger = false
elsif merge_request.merge_request_diff.includes_any_commits?(push_commit_ids)
merge_request.reload_diff(current_user)
end
+ merge_request.skip_merge_status_trigger = skip_merge_status_trigger
merge_request.mark_as_unchecked
end
@@ -240,9 +247,11 @@ module MergeRequests
mr_commit_ids.include?(commit.id)
end
- SystemNoteService.add_commits(merge_request, merge_request.project,
- @current_user, new_commits,
- existing_commits, @push.oldrev)
+ SystemNoteService.add_commits(
+ merge_request, merge_request.project,
+ @current_user, new_commits,
+ existing_commits, @push.oldrev
+ )
notification_service.push_to_merge_request(merge_request, @current_user, new_commits: new_commits, existing_commits: existing_commits)
end
diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb
index c64b2e99b52..2c6ec9333b2 100644
--- a/app/services/merge_requests/reload_diffs_service.rb
+++ b/app/services/merge_requests/reload_diffs_service.rb
@@ -22,9 +22,11 @@ module MergeRequests
def update_diff_discussion_positions(old_diff_refs)
new_diff_refs = merge_request.diff_refs
- merge_request.update_diff_discussion_positions(old_diff_refs: old_diff_refs,
- new_diff_refs: new_diff_refs,
- current_user: current_user)
+ merge_request.update_diff_discussion_positions(
+ old_diff_refs: old_diff_refs,
+ new_diff_refs: new_diff_refs,
+ current_user: current_user
+ )
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb
index 33aae4184ae..b4b05ffb08c 100644
--- a/app/services/merge_requests/retarget_chain_service.rb
+++ b/app/services/merge_requests/retarget_chain_service.rb
@@ -21,13 +21,14 @@ module MergeRequests
# Update only MRs on projects that we have access to
next unless can?(current_user, :update_merge_request, other_merge_request.source_project)
- ::MergeRequests::UpdateService
- .new(project: other_merge_request.source_project, current_user: current_user,
- params: {
- target_branch: merge_request.target_branch,
- target_branch_was_deleted: true
- })
- .execute(other_merge_request)
+ ::MergeRequests::UpdateService.new(
+ project: other_merge_request.source_project,
+ current_user: current_user,
+ params: {
+ target_branch: merge_request.target_branch,
+ target_branch_was_deleted: true
+ }
+ ).execute(other_merge_request)
end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 255d96f4969..aaed01403cb 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,
@@ -282,8 +262,6 @@ module MergeRequests
assignees_service.execute(merge_request)
when :spend_time
add_time_spent_service.execute(merge_request)
- else
- nil
end
end
diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb
index b86fa82a5e8..47e9afa36b9 100644
--- a/app/services/metrics/dashboard/annotations/create_service.rb
+++ b/app/services/metrics/dashboard/annotations/create_service.rb
@@ -26,7 +26,7 @@ module Metrics
attr_reader :user, :params
def authorize_environment_access(options)
- if environment.nil? || Ability.allowed?(user, :create_metrics_dashboard_annotation, project)
+ if environment.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, project)
options[:environment] = environment
success(options)
else
@@ -35,7 +35,7 @@ module Metrics
end
def authorize_cluster_access(options)
- if cluster.nil? || Ability.allowed?(user, :create_metrics_dashboard_annotation, cluster)
+ if cluster.nil? || Ability.allowed?(user, :admin_metrics_dashboard_annotation, cluster)
options[:cluster] = cluster
success(options)
else
diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb
index 3cb22f8d3da..34918c89304 100644
--- a/app/services/metrics/dashboard/annotations/delete_service.rb
+++ b/app/services/metrics/dashboard/annotations/delete_service.rb
@@ -24,7 +24,7 @@ module Metrics
attr_reader :user, :annotation
def authorize_action(_options)
- if Ability.allowed?(user, :delete_metrics_dashboard_annotation, annotation)
+ if Ability.allowed?(user, :admin_metrics_dashboard_annotation, annotation)
success
else
error(s_('MetricsDashboardAnnotation|You are not authorized to delete this annotation'))
diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb
index d9bd9423a1b..18623ad336d 100644
--- a/app/services/metrics/dashboard/clone_dashboard_service.rb
+++ b/app/services/metrics/dashboard/clone_dashboard_service.rb
@@ -16,10 +16,6 @@ module Metrics
::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter
].freeze,
- ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [
- ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter
- ].freeze,
-
::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [
::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter
].freeze
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
deleted file mode 100644
index 62264281a02..00000000000
--- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-# Fetches the self-monitoring metrics dashboard and formats the output.
-# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
-module Metrics
- module Dashboard
- class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
- DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
- DASHBOARD_NAME = N_('Overview')
-
- # SHA256 hash of dashboard content
- DASHBOARD_VERSION = '0f7ade2022e09f1a1da8e883cc95d84b9557e1e0e9b015c51eb964296aa73098'
-
- SEQUENCE = [
- STAGES::CustomMetricsInserter,
- STAGES::MetricEndpointInserter,
- STAGES::VariableEndpointInserter,
- STAGES::PanelIdsInserter
- ].freeze
-
- class << self
- def valid_params?(params)
- matching_dashboard?(params[:dashboard_path]) || self_monitoring_project?(params)
- end
-
- def all_dashboard_paths(_project)
- [{
- path: DASHBOARD_PATH,
- display_name: _(DASHBOARD_NAME),
- default: true,
- system_dashboard: true,
- out_of_the_box_dashboard: out_of_the_box_dashboard?
- }]
- end
-
- def self_monitoring_project?(params)
- params[:dashboard_path].nil? && params[:environment]&.project&.self_monitoring?
- end
- end
-
- private
-
- def dashboard_version
- DASHBOARD_VERSION
- end
- end
- end
-end
diff --git a/app/services/metrics/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/metrics_service.rb b/app/services/metrics_service.rb
index d27328f89cd..f39cc1a8534 100644
--- a/app/services/metrics_service.rb
+++ b/app/services/metrics_service.rb
@@ -4,7 +4,11 @@ require 'prometheus/client/formats/text'
class MetricsService
def prometheus_metrics_text
- ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ if Feature.enabled?(:prom_metrics_rust)
+ ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path, use_rust: true)
+ else
+ ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ end
end
def metrics_text
diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb
index f1fd93d7816..2399da3e182 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
)
@@ -47,6 +48,8 @@ module Ml
end
def add_tag!(candidate, name, value)
+ handle_gitlab_tags(candidate, [{ key: name, value: value }])
+
candidate.metadata.create!(name: name, value: value)
end
@@ -60,11 +63,23 @@ module Ml
end
def add_tags(candidate, tag_definitions)
+ return unless tag_definitions.present?
+
+ handle_gitlab_tags(candidate, tag_definitions)
+
insert_many(candidate, tag_definitions, ::Ml::CandidateMetadata)
end
private
+ def handle_gitlab_tags(candidate, tag_definitions)
+ return unless tag_definitions.any? { |t| t[:key]&.starts_with?('gitlab.') }
+
+ Ml::ExperimentTracking::HandleCandidateGitlabMetadataService
+ .new(candidate, tag_definitions)
+ .execute
+ end
+
def timestamps
current_time = Time.zone.now
diff --git a/app/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service.rb b/app/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service.rb
new file mode 100644
index 00000000000..918e4d10ac3
--- /dev/null
+++ b/app/services/ml/experiment_tracking/handle_candidate_gitlab_metadata_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Ml
+ module ExperimentTracking
+ class HandleCandidateGitlabMetadataService
+ def initialize(candidate, metadata)
+ @candidate = candidate
+ @metadata = metadata.index_by { |m| m[:key] }
+ end
+
+ def execute
+ handle_build_metadata(@metadata['gitlab.CI_JOB_ID'])
+
+ @candidate.save
+ end
+
+ private
+
+ def handle_build_metadata(build_metadata)
+ return unless build_metadata
+
+ build = Ci::Build.find_by_id(build_metadata[:value])
+
+ raise ArgumentError, 'gitlab.CI_JOB_ID must refer to an existing build' unless build
+
+ @candidate.ci_build = build
+ end
+ end
+ end
+end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index e6766273441..91993700e25 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -4,8 +4,15 @@ module Notes
class BuildService < ::BaseService
def execute
in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
+ external_author = params.delete(:external_author)
+
discussion = nil
+ if external_author.present?
+ note_metadata = Notes::NoteMetadata.new(email_participant: external_author)
+ params[:note_metadata] = note_metadata
+ end
+
if in_reply_to_discussion_id.present?
discussion = find_discussion(in_reply_to_discussion_id)
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index f5efc480fef..7dd6cd9a87c 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -4,7 +4,7 @@ module Notes
class CreateService < ::Notes::BaseService
include IncidentManagement::UsageData
- def execute(skip_capture_diff_note_position: false, skip_merge_status_trigger: false)
+ def execute(skip_capture_diff_note_position: false, skip_merge_status_trigger: false, skip_set_reviewed: false)
note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440
@@ -38,7 +38,8 @@ module Notes
when_saved(
note,
skip_capture_diff_note_position: skip_capture_diff_note_position,
- skip_merge_status_trigger: skip_merge_status_trigger
+ skip_merge_status_trigger: skip_merge_status_trigger,
+ skip_set_reviewed: skip_set_reviewed
)
end
end
@@ -54,6 +55,7 @@ module Notes
content, update_params, message, command_names = quick_actions_service.execute(note, quick_action_options)
only_commands = content.empty?
note.note = content
+ note.command_names = command_names
yield(only_commands)
@@ -78,7 +80,9 @@ module Notes
end
end
- def when_saved(note, skip_capture_diff_note_position: false, skip_merge_status_trigger: false)
+ def when_saved(
+ note, skip_capture_diff_note_position: false, skip_merge_status_trigger: false,
+ skip_set_reviewed: false)
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
@@ -86,6 +90,8 @@ module Notes
track_event(note, current_user)
if note.for_merge_request? && note.start_of_discussion?
+ set_reviewed(note) unless skip_set_reviewed
+
if !skip_capture_diff_note_position && note.diff_note?
Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion)
end
@@ -161,24 +167,19 @@ module Notes
track_note_creation_usage_for_merge_requests(note) if note.for_merge_request?
track_incident_action(user, note.noteable, 'incident_comment') if note.for_issue?
track_note_creation_in_ipynb(note)
+ track_note_creation_visual_review(note)
- if Feature.enabled?(:notes_create_service_tracking, project)
- Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
- end
+ metric_key_path = 'counts.commit_comment'
- 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
+ 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)
@@ -191,8 +192,10 @@ module Notes
end
def track_note_creation_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author,
- project: project)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(
+ author: note.author,
+ project: project
+ )
end
def track_note_creation_usage_for_merge_requests(note)
@@ -208,6 +211,15 @@ module Notes
Gitlab::UsageDataCounters::IpynbDiffActivityCounter.note_created(note)
end
+
+ def track_note_creation_visual_review(note)
+ Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
+ end
+
+ def set_reviewed(note)
+ ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user)
+ .execute(note.noteable)
+ end
end
end
diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb
index ccee94a5cea..76ddd32a76b 100644
--- a/app/services/notes/destroy_service.rb
+++ b/app/services/notes/destroy_service.rb
@@ -16,8 +16,10 @@ module Notes
private
def track_note_removal_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author,
- project: project)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(
+ author: note.author,
+ project: project
+ )
end
def track_note_removal_usage_for_merge_requests(note)
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 900ace24ab4..38f7a23ce29 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -13,23 +13,18 @@ module Notes
delegate :commands_executed_count, to: :interpret_service, allow_nil: true
- UPDATE_SERVICES = {
- 'Issue' => Issues::UpdateService,
- 'MergeRequest' => MergeRequests::UpdateService,
- 'Commit' => Commits::TagService
- }.freeze
- private_constant :UPDATE_SERVICES
-
- def self.update_services
- UPDATE_SERVICES
- end
+ SUPPORTED_NOTEABLES = %w[WorkItem Issue MergeRequest Commit].freeze
+
+ private_constant :SUPPORTED_NOTEABLES
- def self.noteable_update_service_class(note)
- update_services[note.noteable_type]
+ def self.supported_noteables
+ SUPPORTED_NOTEABLES
end
def self.supported?(note)
- !!noteable_update_service_class(note)
+ return true if note.for_work_item?
+
+ supported_noteables.include? note.noteable_type
end
def supported?(note)
@@ -55,21 +50,28 @@ module Notes
update_params[:spend_time][:note_id] = note.id
end
- noteable_update_service_class = self.class.noteable_update_service_class(note)
-
- # TODO: This conditional is necessary because we have not fully converted all possible
- # noteable_update_service_class classes to use named arguments. See more details
- # on the partial conversion at https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59182
- # Follow-on issue to address this is here:
- # https://gitlab.com/gitlab-org/gitlab/-/issues/328734
- service =
- if noteable_update_service_class.respond_to?(:constructor_container_arg)
- noteable_update_service_class.new(**noteable_update_service_class.constructor_container_arg(note.resource_parent), current_user: current_user, params: update_params)
- else
- noteable_update_service_class.new(note.resource_parent, current_user, update_params)
- end
-
- service.execute(note.noteable)
+ noteable_update_service(note, update_params).execute(note.noteable)
+ end
+
+ def noteable_update_service(note, update_params)
+ if note.for_work_item?
+ parsed_params = note.noteable.transform_quick_action_params(update_params)
+
+ WorkItems::UpdateService.new(
+ container: note.resource_parent,
+ current_user: current_user,
+ params: parsed_params[:common],
+ widget_params: parsed_params[:widgets]
+ )
+ elsif note.for_issue?
+ Issues::UpdateService.new(container: note.resource_parent, current_user: current_user, params: update_params)
+ elsif note.for_merge_request?
+ MergeRequests::UpdateService.new(
+ project: note.resource_parent, current_user: current_user, params: update_params
+ )
+ elsif note.for_commit?
+ Commits::TagService.new(note.resource_parent, current_user, update_params)
+ end
end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 2dae76feb0b..e04891da7f8 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -86,8 +86,10 @@ module Notes
end
def track_note_edit_usage_for_issues(note)
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author,
- project: project)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(
+ author: note.author,
+ project: project
+ )
end
def track_note_edit_usage_for_merge_requests(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/conan/search_service.rb b/app/services/packages/conan/search_service.rb
index df22a895c00..c65c9a85da8 100644
--- a/app/services/packages/conan/search_service.rb
+++ b/app/services/packages/conan/search_service.rb
@@ -8,10 +8,6 @@ module Packages
WILDCARD = '*'
RECIPE_SEPARATOR = '@'
- def initialize(user, params)
- super(nil, user, params)
- end
-
def execute
ServiceResponse.success(payload: { results: search_results })
end
@@ -23,35 +19,34 @@ module Packages
return search_for_single_package(sanitized_query) if params[:query].include?(RECIPE_SEPARATOR)
- search_packages(build_query)
+ search_packages
end
def wildcard_query?
params[:query] == WILDCARD
end
- def build_query
- return "#{sanitized_query}%" if params[:query].end_with?(WILDCARD)
-
- sanitized_query
- end
-
- def search_packages(query)
- ::Packages::Conan::PackageFinder.new(current_user, query: query).execute.map(&:conan_recipe)
+ def sanitized_query
+ @sanitized_query ||= sanitize_sql_like(params[:query].delete(WILDCARD))
end
def search_for_single_package(query)
- name, version, username, _ = query.split(%r{[@/]})
- full_path = Packages::Conan::Metadatum.full_path_from(package_username: username)
- project = Project.find_by_full_path(full_path)
- return unless Ability.allowed?(current_user, :read_package, project&.packages_policy_subject)
+ ::Packages::Conan::SinglePackageSearchService
+ .new(query, current_user)
+ .execute[:results]
+ end
- result = project.packages.with_name(name).with_version(version).order_created.last
- [result&.conan_recipe].compact
+ def search_packages
+ ::Packages::Conan::PackageFinder
+ .new(current_user, { query: build_query }, project: project)
+ .execute
+ .map(&:conan_recipe)
end
- def sanitized_query
- @sanitized_query ||= sanitize_sql_like(params[:query].delete(WILDCARD))
+ def build_query
+ return "#{sanitized_query}%" if params[:query].end_with?(WILDCARD)
+
+ sanitized_query
end
end
end
diff --git a/app/services/packages/conan/single_package_search_service.rb b/app/services/packages/conan/single_package_search_service.rb
new file mode 100644
index 00000000000..e133b35c2cf
--- /dev/null
+++ b/app/services/packages/conan/single_package_search_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Packages
+ module Conan
+ class SinglePackageSearchService # rubocop:disable Search/NamespacedClass
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(query, current_user)
+ @name, @version, @username, _ = query.split(%r{[@/]})
+ @current_user = current_user
+ end
+
+ def execute
+ ServiceResponse.success(payload: { results: search_results })
+ end
+
+ private
+
+ attr_reader :name, :version, :username, :current_user
+
+ def search_results
+ return [] unless can_access_project_package?
+
+ [package&.conan_recipe].compact
+ end
+
+ def package
+ project
+ .packages
+ .with_name(name)
+ .with_version(version)
+ .order_created
+ .last
+ end
+
+ def project
+ Project.find_by_full_path(full_path)
+ end
+ strong_memoize_attr :project
+
+ def full_path
+ ::Packages::Conan::Metadatum.full_path_from(package_username: username)
+ end
+
+ def can_access_project_package?
+ Ability.allowed?(current_user, :read_package, project.try(:packages_policy_subject))
+ end
+ end
+ end
+end
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/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb
index eb8227d1296..cc9defd2e73 100644
--- a/app/services/packages/debian/extract_metadata_service.rb
+++ b/app/services/packages/debian/extract_metadata_service.rb
@@ -14,6 +14,10 @@ module Packages
def execute
raise ExtractionError, 'invalid package file' unless valid_package_file?
+ if file_type == :unsupported
+ raise ExtractionError, "unsupported file extension for file #{package_file.file_name}"
+ end
+
extract_metadata
end
@@ -28,7 +32,7 @@ module Packages
end
def file_type_basic
- %i[dsc deb udeb buildinfo changes].each do |format|
+ %i[dsc deb udeb buildinfo changes ddeb].each do |format|
return format if package_file.file_name.end_with?(".#{format}")
end
@@ -36,8 +40,8 @@ module Packages
end
def file_type_source
- # https://manpages.debian.org/buster/dpkg-dev/dpkg-source.1.en.html
- %i[gzip bzip2 lzma xz].each do |format|
+ # https://manpages.debian.org/buster/dpkg-dev/dpkg-source.1.en.html#Format:_3.0_(quilt)
+ %i[gz bz2 lzma xz].each do |format|
return :source if package_file.file_name.end_with?(".tar.#{format}")
end
@@ -45,13 +49,12 @@ module Packages
end
def file_type
- strong_memoize(:file_type) do
- file_type_basic || file_type_source || :unknown
- end
+ file_type_basic || file_type_source || :unsupported
end
+ strong_memoize_attr :file_type
def file_type_debian?
- file_type == :deb || file_type == :udeb
+ file_type == :deb || file_type == :udeb || file_type == :ddeb
end
def file_type_meta?
@@ -59,18 +62,17 @@ module Packages
end
def fields
- strong_memoize(:fields) do
- if file_type_debian?
- package_file.file.use_open_file(unlink_early: false) do |file|
- ::Packages::Debian::ExtractDebMetadataService.new(file.file_path).execute
- end
- elsif file_type_meta?
- package_file.file.use_open_file do |file|
- ::Packages::Debian::ParseDebian822Service.new(file.read).execute.each_value.first
- end
+ if file_type_debian?
+ package_file.file.use_open_file(unlink_early: false) do |file|
+ ::Packages::Debian::ExtractDebMetadataService.new(file.file_path).execute
+ end
+ elsif file_type_meta?
+ package_file.file.use_open_file do |file|
+ ::Packages::Debian::ParseDebian822Service.new(file.read).execute.each_value.first
end
end
end
+ strong_memoize_attr :fields
def extract_metadata
architecture = fields['Architecture'] if file_type_debian?
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 12ae6c68918..d69f6eb1511 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -163,26 +163,38 @@ module Packages
end
def reuse_or_create_component_file(component, component_file_type, architecture, content)
- file_md5 = Digest::MD5.hexdigest(content)
file_sha256 = Digest::SHA256.hexdigest(content)
- component_file = component.files
- .with_file_type(component_file_type)
- .with_architecture(architecture)
- .with_compression_type(nil)
- .with_file_sha256(file_sha256)
- .last
-
- if component_file
+ component_files = component.files
+ .with_file_type(component_file_type)
+ .with_architecture(architecture)
+ .with_compression_type(nil)
+ .order_updated_asc
+ component_file = component_files.with_file_sha256(file_sha256).last
+ last_component_file = component_files.last
+
+ if content.empty? && (!last_component_file || last_component_file.file_sha256 == file_sha256)
+ # Do not create empty component file for empty content
+ # when there is no last component file or when the last component file is empty too
+ component_file = last_component_file || component.files.build(
+ updated_at: release_date,
+ file_type: component_file_type,
+ architecture: architecture,
+ compression_type: nil,
+ size: 0
+ )
+ elsif component_file
+ # Reuse existing component file
component_file.touch(time: release_date)
else
+ # Create a new component file
component_file = component.files.create!(
updated_at: release_date,
file_type: component_file_type,
architecture: architecture,
compression_type: nil,
file: CarrierWaveStringFile.new(content),
- file_md5: file_md5,
- file_sha256: file_sha256
+ file_sha256: file_sha256,
+ size: content.bytesize
)
end
@@ -255,7 +267,7 @@ module Packages
# used by ExclusiveLeaseGuard
def lease_key
- "packages:debian:generate_distribution_service:distribution:#{@distribution.id}"
+ "packages:debian:generate_distribution_service:#{@distribution.class.container_type}_distribution:#{@distribution.id}"
end
# used by ExclusiveLeaseGuard
diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb
index 7d2d71184e6..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
@@ -41,7 +41,9 @@ module Packages
raise ArgumentError, 'package file without Debian metadata' unless @package_file.debian_file_metadatum
raise ArgumentError, 'already processed package file' unless @package_file.debian_file_metadatum.unknown?
- return if file_metadata[:file_type] == :deb || file_metadata[:file_type] == :udeb
+ if file_metadata[:file_type] == :deb || file_metadata[:file_type] == :udeb || file_metadata[:file_type] == :ddeb
+ return
+ end
raise ArgumentError, "invalid package file type: #{file_metadata[:file_type]}"
end
@@ -52,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/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
index 78c97000654..09e3fb4a825 100644
--- a/app/services/packages/generic/create_package_file_service.rb
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -47,7 +47,11 @@ module Packages
end
def target_file_is_duplicate?(package)
- package.package_files.with_file_name(params[:file_name]).exists?
+ package
+ .package_files
+ .with_file_name(params[:file_name])
+ .not_pending_destruction
+ .exists?
end
end
end
diff --git a/app/services/packages/mark_package_for_destruction_service.rb b/app/services/packages/mark_package_for_destruction_service.rb
index 3417febe79a..8ccc242ae36 100644
--- a/app/services/packages/mark_package_for_destruction_service.rb
+++ b/app/services/packages/mark_package_for_destruction_service.rb
@@ -13,7 +13,8 @@ module Packages
package.sync_maven_metadata(current_user)
service_response_success('Package was successfully marked as pending destruction')
- rescue StandardError
+ rescue StandardError => e
+ track_exception(e)
service_response_error('Failed to mark the package as pending destruction', 400)
end
@@ -30,5 +31,13 @@ module Packages
def user_can_delete_package?
can?(current_user, :destroy_package, package.project)
end
+
+ def track_exception(error)
+ Gitlab::ErrorTracking.track_exception(
+ error,
+ project_id: package.project_id,
+ package_id: package.id
+ )
+ end
end
end
diff --git a/app/services/packages/mark_packages_for_destruction_service.rb b/app/services/packages/mark_packages_for_destruction_service.rb
index 023392cf2d9..ade9ad2c974 100644
--- a/app/services/packages/mark_packages_for_destruction_service.rb
+++ b/app/services/packages/mark_packages_for_destruction_service.rb
@@ -31,13 +31,15 @@ module Packages
def execute(batch_size: BATCH_SIZE)
no_access = false
min_batch_size = [batch_size, BATCH_SIZE].min
+ package_ids = []
@packages.each_batch(of: min_batch_size) do |batched_packages|
loaded_packages = batched_packages.including_project_route.to_a
+ package_ids = loaded_packages.map(&:id)
break no_access = true unless can_destroy_packages?(loaded_packages)
- ::Packages::Package.id_in(loaded_packages.map(&:id))
+ ::Packages::Package.id_in(package_ids)
.update_all(status: :pending_destruction)
sync_maven_metadata(loaded_packages)
@@ -47,7 +49,8 @@ module Packages
return UNAUTHORIZED_RESPONSE if no_access
SUCCESS_RESPONSE
- rescue StandardError
+ rescue StandardError => e
+ track_exception(e, package_ids)
ERROR_RESPONSE
end
@@ -75,5 +78,9 @@ module Packages
can?(@current_user, :destroy_package, package)
end
end
+
+ def track_exception(error, package_ids)
+ Gitlab::ErrorTracking.track_exception(error, package_ids: package_ids)
+ end
end
end
diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb
index b29adf4e11a..ac0c77391d7 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -3,10 +3,13 @@ module Packages
module Maven
class FindOrCreatePackageService < BaseService
SNAPSHOT_TERM = '-SNAPSHOT'
+ MAX_FILE_NAME_LENGTH = 5000
def execute
+ return ServiceResponse.error(message: 'File name is too long') if file_name_too_long?
+
package =
- ::Packages::Maven::PackageFinder.new(current_user, project, path: params[:path])
+ ::Packages::Maven::PackageFinder.new(current_user, project, path: path)
.execute
unless Namespace::PackageSetting.duplicates_allowed?(package)
@@ -32,16 +35,16 @@ module Packages
# - my-company/my-app/maven-metadata.xml
#
# The first upload has to create the proper package (the one with the version set).
- if params[:file_name] == Packages::Maven::Metadata.filename && !params[:path]&.ends_with?(SNAPSHOT_TERM)
- package_name = params[:path]
+ if file_name == Packages::Maven::Metadata.filename && !snapshot_version?
+ package_name = path
version = nil
else
- package_name, _, version = params[:path].rpartition('/')
+ package_name, _, version = path.rpartition('/')
end
package_params = {
name: package_name,
- path: params[:path],
+ path: path,
status: params[:status],
version: version
}
@@ -58,21 +61,55 @@ module Packages
private
- def extname(filename)
- return if filename.blank?
+ def file_name_too_long?
+ return false unless file_name
- File.extname(filename)
+ file_name.size > MAX_FILE_NAME_LENGTH
end
def target_package_is_duplicate?(package)
# duplicate metadata files can be uploaded multiple times
return false if package.version.nil?
- package
- .package_files
- .map { |file| extname(file.file_name) }
- .compact
- .include?(extname(params[:file_name]))
+ existing_file_names = strip_snapshot_parts(
+ package.package_files
+ .map(&:file_name)
+ .compact
+ )
+
+ published_file_name = strip_snapshot_parts_from(file_name)
+ existing_file_names.include?(published_file_name)
+ end
+
+ def strip_snapshot_parts(file_names)
+ return file_names unless snapshot_version?
+
+ Array.wrap(file_names).map { |f| strip_snapshot_parts_from(f) }
+ end
+
+ def strip_snapshot_parts_from(file_name)
+ return file_name unless snapshot_version?
+ return unless file_name
+
+ match_data = file_name.match(Gitlab::Regex::Packages::MAVEN_SNAPSHOT_DYNAMIC_PARTS)
+
+ if match_data
+ file_name.gsub(match_data.captures.last, "")
+ else
+ file_name
+ end
+ end
+
+ def snapshot_version?
+ path&.ends_with?(SNAPSHOT_TERM)
+ end
+
+ def path
+ params[:path]
+ end
+
+ def file_name
+ params[:file_name]
end
end
end
diff --git a/app/services/packages/npm/create_metadata_cache_service.rb b/app/services/packages/npm/create_metadata_cache_service.rb
new file mode 100644
index 00000000000..1cc5f7f34e7
--- /dev/null
+++ b/app/services/packages/npm/create_metadata_cache_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class CreateMetadataCacheService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ # used by ExclusiveLeaseGuard
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+
+ def initialize(project, package_name, packages)
+ @project = project
+ @package_name = package_name
+ @packages = packages
+ end
+
+ def execute
+ try_obtain_lease do
+ Packages::Npm::MetadataCache
+ .find_or_build(package_name: package_name, project_id: project.id)
+ .update!(
+ file: CarrierWaveStringFile.new(metadata_content),
+ size: metadata_content.bytesize
+ )
+ end
+ end
+
+ private
+
+ attr_reader :package_name, :packages, :project
+
+ def metadata_content
+ metadata.payload.to_json
+ end
+ strong_memoize_attr :metadata_content
+
+ def metadata
+ Packages::Npm::GenerateMetadataService.new(package_name, packages).execute
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ "packages:npm:create_metadata_cache_service:metadata_caches:#{project.id}_#{package_name}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index dd074f7472b..c71ae060dd9 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -3,15 +3,24 @@ 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('Attachment data is empty.', 400) if attachment['data'].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! }
+ package = try_obtain_lease do
+ ApplicationRecord.transaction { create_npm_package! }
+ end
+
+ return error('Could not obtain package lease.', 400) unless package
+
+ package
end
private
@@ -23,11 +32,21 @@ module Packages
::Packages::CreateDependencyService.new(package, package_dependencies).execute
::Packages::Npm::CreateTagService.new(package, dist_tag).execute
- package.create_npm_metadatum!(package_json: package_json)
+ create_npm_metadatum!(package)
package
end
+ def create_npm_metadatum!(package)
+ package.create_npm_metadatum!(package_json: package_json)
+ rescue ActiveRecord::RecordInvalid => e
+ if package.npm_metadatum && package.npm_metadatum.errors.added?(:package_json, 'structure is too large')
+ Gitlab::ErrorTracking.track_exception(e, field_sizes: field_sizes_for_error_tracking)
+ end
+
+ raise
+ end
+
def current_package_exists?
project.packages
.npm
@@ -103,6 +122,45 @@ 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
+
+ def field_sizes
+ strong_memoize(:field_sizes) do
+ package_json.transform_values do |value|
+ value.to_s.size
+ end
+ end
+ end
+
+ def filtered_field_sizes
+ strong_memoize(:filtered_field_sizes) do
+ field_sizes.select do |_, size|
+ size >= ::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING
+ end
+ end
+ end
+
+ def largest_fields
+ strong_memoize(:largest_fields) do
+ field_sizes
+ .sort_by { |a| a[1] }
+ .reverse[0..::Packages::Npm::Metadatum::NUM_FIELDS_FOR_ERROR_TRACKING - 1]
+ .to_h
+ end
+ end
+
+ def field_sizes_for_error_tracking
+ filtered_field_sizes.empty? ? largest_fields : filtered_field_sizes
+ 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/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
index e2f2e220750..adb7924f35e 100644
--- a/app/services/personal_access_tokens/create_service.rb
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -2,11 +2,12 @@
module PersonalAccessTokens
class CreateService < BaseService
- def initialize(current_user:, target_user:, params: {})
+ def initialize(current_user:, target_user:, params: {}, concatenate_errors: true)
@current_user = current_user
@target_user = target_user
@params = params.dup
@ip_address = @params.delete(:ip_address)
+ @concatenate_errors = concatenate_errors
end
def execute
@@ -19,7 +20,10 @@ module PersonalAccessTokens
notification_service.access_token_created(target_user, token.name)
ServiceResponse.success(payload: { personal_access_token: token })
else
- ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token })
+ message = token.errors.full_messages
+ message = message.to_sentence if @concatenate_errors
+
+ ServiceResponse.error(message: message, payload: { personal_access_token: token })
end
end
diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb
new file mode 100644
index 00000000000..64b0c5c98a9
--- /dev/null
+++ b/app/services/personal_access_tokens/rotate_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class RotateService
+ EXPIRATION_PERIOD = 1.week
+
+ def initialize(current_user, token)
+ @current_user = current_user
+ @token = token
+ end
+
+ def execute
+ return ServiceResponse.error(message: _('token already revoked')) if token.revoked?
+
+ response = ServiceResponse.success
+
+ PersonalAccessToken.transaction do
+ unless token.revoke!
+ response = ServiceResponse.error(message: _('failed to revoke token'))
+ raise ActiveRecord::Rollback
+ end
+
+ target_user = token.user
+ new_token = target_user.personal_access_tokens.create(create_token_params(token))
+
+ if new_token.persisted?
+ response = ServiceResponse.success(payload: { personal_access_token: new_token })
+ else
+ response = ServiceResponse.error(message: new_token.errors.full_messages.to_sentence)
+
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ response
+ end
+
+ private
+
+ attr_reader :current_user, :token
+
+ def create_token_params(token)
+ { name: token.name,
+ impersonation: token.impersonation,
+ scopes: token.scopes,
+ expires_at: Date.today + EXPIRATION_PERIOD }
+ end
+ end
+end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index b3a9beabba5..c8ccbe1465e 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -24,7 +24,7 @@ class PreviewMarkdownService < BaseService
return text, [] unless quick_action_types.include?(target_type)
quick_actions_service = QuickActions::InterpretService.new(project, current_user)
- quick_actions_service.explain(text, find_commands_target)
+ quick_actions_service.explain(text, find_commands_target, keep_actions: params[:render_quick_actions])
end
def find_user_references(text)
diff --git a/app/services/projects/android_target_platform_detector_service.rb b/app/services/projects/android_target_platform_detector_service.rb
deleted file mode 100644
index 11635ad18d5..00000000000
--- a/app/services/projects/android_target_platform_detector_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- # Service class to detect if a project is made to run on the Android platform.
- #
- # This service searches for an AndroidManifest.xml file which all Android app
- # project must have. It returns the symbol :android if the given project is an
- # Android app project.
- #
- # Ref: https://developer.android.com/guide/topics/manifest/manifest-intro
- #
- # Example usage:
- # > AndroidTargetPlatformDetectorService.new(a_project).execute
- # => nil
- # > AndroidTargetPlatformDetectorService.new(an_android_project).execute
- # => :android
- class AndroidTargetPlatformDetectorService < BaseService
- # <manifest> element is required and must occur once inside AndroidManifest.xml
- MANIFEST_FILE_SEARCH_QUERY = '<manifest filename:AndroidManifest.xml'
-
- def execute
- detect
- end
-
- private
-
- def file_finder
- @file_finder ||= ::Gitlab::FileFinder.new(project, project.default_branch)
- end
-
- def detect
- return :android if file_finder.find(MANIFEST_FILE_SEARCH_QUERY).present?
- end
- end
-end
diff --git a/app/services/projects/batch_open_merge_requests_count_service.rb b/app/services/projects/batch_open_merge_requests_count_service.rb
new file mode 100644
index 00000000000..62d1b018a55
--- /dev/null
+++ b/app/services/projects/batch_open_merge_requests_count_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# Service class for getting and caching the number of merge requests of several projects
+# Warning: do not user this service with a really large set of projects
+# because the service use maps to retrieve the project ids
+module Projects
+ class BatchOpenMergeRequestsCountService < Projects::BatchCountService
+ # rubocop: disable CodeReuse/ActiveRecord
+ def global_count
+ @global_count ||= count_service.query(project_ids).group(:project_id).count
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def count_service
+ ::Projects::OpenMergeRequestsCountService
+ end
+ end
+end
diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb
deleted file mode 100644
index 58e146e5a32..00000000000
--- a/app/services/projects/blame_service.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-# Service class to correctly initialize Gitlab::Blame and Kaminari pagination
-# objects
-module Projects
- class BlameService
- PER_PAGE = 1000
-
- def initialize(blob, commit, params)
- @blob = blob
- @commit = commit
- @page = extract_page(params)
- @pagination_enabled = pagination_state(params)
- end
-
- attr_reader :page
-
- 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
- PER_PAGE
- end
-
- private
-
- attr_reader :blob, :commit, :pagination_enabled
-
- def blame_range
- return unless pagination_enabled
-
- first_line = (page - 1) * per_page + 1
- 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 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 * per_page >= blob_lines_count + per_page
- end
-
- def blob_lines_count
- @blob_lines_count ||= blob.data.lines.count
- end
- end
-end
diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
index b69a3cc1a2c..714a9d43333 100644
--- a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb
@@ -45,12 +45,12 @@ module Projects
end
def with_timeout
- result = {
+ result = success(
original_size: 0,
before_delete_size: 0,
deleted_size: 0,
deleted: []
- }
+ )
yield Time.zone.now, result
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 94cc4700a49..8ad2b0ac761 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -5,7 +5,7 @@ module Projects
include ValidatesClassificationLabel
ImportSourceDisabledError = Class.new(StandardError)
- INTERNAL_IMPORT_SOURCES = %w[bare_repository gitlab_custom_project_template gitlab_project_migration].freeze
+ INTERNAL_IMPORT_SOURCES = %w[gitlab_custom_project_template gitlab_project_migration].freeze
def initialize(user, params)
@current_user = user
@@ -58,6 +58,7 @@ module Projects
return @project if @project.errors.any?
validate_create_permissions
+ validate_import_permissions
return @project if @project.errors.any?
@relations_block&.call(@project)
@@ -98,6 +99,13 @@ module Projects
@project.errors.add(:namespace, "is not valid")
end
+ def validate_import_permissions
+ return unless @project.import?
+ return if current_user.can?(:import_projects, parent_namespace)
+
+ @project.errors.add(:user, 'is not allowed to import projects')
+ end
+
def after_create_actions
log_info("#{current_user.name} created a new project \"#{@project.full_name}\"")
@@ -144,8 +152,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!(
@@ -187,7 +197,7 @@ module Projects
def create_readme
commit_attrs = {
- branch_name: @default_branch.presence || @project.default_branch_or_main,
+ branch_name: default_branch,
commit_message: 'Initial commit',
file_path: 'README.md',
file_content: readme_content
@@ -201,7 +211,11 @@ module Projects
end
def readme_content
- @readme_template.presence || ReadmeRendererService.new(@project, current_user).execute
+ readme_attrs = {
+ default_branch: default_branch
+ }
+
+ @readme_template.presence || ReadmeRendererService.new(@project, current_user, readme_attrs).execute
end
def skip_wiki?
@@ -217,8 +231,10 @@ module Projects
@project.create_labels unless @project.gitlab_project_import?
- unless @project.import?
- raise 'Failed to create repository' unless @project.create_repository
+ break if @project.import?
+
+ unless @project.create_repository(default_branch: default_branch)
+ raise 'Failed to create repository'
end
end
end
@@ -267,6 +283,10 @@ module Projects
private
+ def default_branch
+ @default_branch.presence || @project.default_branch_or_main
+ end
+
def validate_import_source_enabled!
return unless @params[:import_type]
@@ -274,6 +294,9 @@ module Projects
return if INTERNAL_IMPORT_SOURCES.include?(import_type)
+ # Skip validation when creating project from a built in template
+ return if @params[:import_export_upload].present? && import_type == 'gitlab_project'
+
unless ::Gitlab::CurrentSettings.import_sources&.include?(import_type)
raise ImportSourceDisabledError, "#{import_type} import source is disabled"
end
@@ -289,7 +312,7 @@ module Projects
def import_schedule
if @project.errors.empty?
- @project.import_state.schedule if @project.import? && !@project.bare_repository_import? && !@project.gitlab_project_migration?
+ @project.import_state.schedule if @project.import? && !@project.gitlab_project_migration?
else
fail(error: @project.errors.full_messages.join(', '))
end
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/forks/sync_service.rb b/app/services/projects/forks/sync_service.rb
new file mode 100644
index 00000000000..4c70d7f17f5
--- /dev/null
+++ b/app/services/projects/forks/sync_service.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+module Projects
+ module Forks
+ # A service for fetching upstream default branch and merging it to the fork's specified branch.
+ class SyncService < BaseService
+ ONGOING_MERGE_ERROR = 'The synchronization did not happen due to another merge in progress'
+
+ MergeError = Class.new(StandardError)
+
+ def initialize(project, user, target_branch)
+ super(project, user)
+
+ @source_project = project.fork_source
+ @head_sha = project.repository.commit(target_branch).sha
+ @target_branch = target_branch
+ @details = Projects::Forks::Details.new(project, target_branch)
+ end
+
+ def execute
+ execute_service
+
+ ServiceResponse.success
+ rescue MergeError => e
+ Gitlab::ErrorTracking.log_exception(e, { project_id: project.id, user_id: current_user.id })
+
+ ServiceResponse.error(message: e.message)
+ ensure
+ details.exclusive_lease.cancel
+ end
+
+ private
+
+ attr_reader :source_project, :head_sha, :target_branch, :details
+
+ # The method executes multiple steps:
+ #
+ # 1. Gitlab::Git::CrossRepo fetches upstream default branch into a temporary ref and returns new source sha.
+ # 2. New divergence counts are calculated using the source sha.
+ # 3. If the fork is not behind, there is nothing to merge -> exit.
+ # 4. Otherwise, continue with the new source sha.
+ # 5. If Gitlab::Git::CommandError is raised it means that merge couldn't happen due to a merge conflict. The
+ # details are updated to transfer this error to the user.
+ def execute_service
+ counts = []
+ source_sha = source_project.commit.sha
+
+ Gitlab::Git::CrossRepo.new(repository, source_project.repository)
+ .execute(source_sha) do |cross_repo_source_sha|
+ counts = repository.diverging_commit_count(head_sha, cross_repo_source_sha)
+ ahead, behind = counts
+ next if behind == 0
+
+ execute_with_fetched_source(cross_repo_source_sha, ahead)
+ end
+ rescue Gitlab::Git::CommandError => e
+ details.update!({ sha: head_sha, source_sha: source_sha, counts: counts, has_conflicts: true })
+
+ raise MergeError, e.message
+ end
+
+ def execute_with_fetched_source(cross_repo_source_sha, ahead)
+ with_linked_lfs_pointers(cross_repo_source_sha) do
+ merge_commit_id = perform_merge(cross_repo_source_sha, ahead)
+ raise MergeError, ONGOING_MERGE_ERROR unless merge_commit_id
+ end
+ end
+
+ # This method merges the upstream default branch to the fork specified branch.
+ # Depending on whether the fork branch is ahead of upstream or not, a different type of
+ # merge is performed.
+ #
+ # If the fork's branch is not ahead of the upstream (only behind), fast-forward merge is performed.
+ # However, if the fork's branch contains commits that don't exist upstream, a merge commit is created.
+ # In this case, a conflict may happen, which interrupts the merge and returns a message to the user.
+ def perform_merge(cross_repo_source_sha, ahead)
+ if ahead > 0
+ message = "Merge branch #{source_project.path}:#{source_project.default_branch} into #{target_branch}"
+
+ repository.merge_to_branch(current_user,
+ source_sha: cross_repo_source_sha,
+ target_branch: target_branch,
+ target_sha: head_sha,
+ message: message)
+ else
+ repository.ff_merge(current_user, cross_repo_source_sha, target_branch, target_sha: head_sha)
+ end
+ end
+
+ # This method links the newly merged lfs objects (if any) with the existing ones upstream.
+ # The LfsLinkService service has a limit and may raise an error if there are too many lfs objects to link.
+ # This is the reason why the block is passed:
+ #
+ # 1. Verify that there are not too many lfs objects to link
+ # 2. Execute the block (which basically performs the merge)
+ # 3. Link lfs objects
+ def with_linked_lfs_pointers(newrev, &block)
+ return yield unless project.lfs_enabled?
+
+ oldrev = head_sha
+ new_lfs_oids =
+ Gitlab::Git::LfsChanges
+ .new(repository, newrev)
+ .new_pointers(not_in: [oldrev])
+ .map(&:lfs_oid)
+
+ Projects::LfsPointers::LfsLinkService.new(project).execute(new_lfs_oids, &block)
+ rescue Projects::LfsPointers::LfsLinkService::TooManyOidsError => e
+ raise MergeError, e.message
+ end
+ end
+ end
+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_export/relation_export_service.rb b/app/services/projects/import_export/relation_export_service.rb
index dce40cf18ba..33da5b39c20 100644
--- a/app/services/projects/import_export/relation_export_service.rb
+++ b/app/services/projects/import_export/relation_export_service.rb
@@ -85,6 +85,7 @@ module Projects
logger.error(
message: 'Project relation export failed',
export_error: error_message,
+ relation: relation_export.relation,
project_export_job_id: project_export_job.id,
project_name: project.name,
project_id: project.id
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/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb
index cf3cc5cd8e0..f8f03d481af 100644
--- a/app/services/projects/lfs_pointers/lfs_link_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_link_service.rb
@@ -15,9 +15,9 @@ module Projects
def execute(oids)
return [] unless project&.lfs_enabled?
- if oids.size > MAX_OIDS
- raise TooManyOidsError, 'Too many LFS object ids to link, please push them manually'
- end
+ validate!(oids)
+
+ yield if block_given?
# Search and link existing LFS Object
link_existing_lfs_objects(oids)
@@ -25,6 +25,12 @@ module Projects
private
+ def validate!(oids)
+ return if oids.size <= MAX_OIDS
+
+ raise TooManyOidsError, 'Too many LFS object ids to link, please push them manually'
+ end
+
def link_existing_lfs_objects(oids)
linked_existing_objects = []
iterations = 0
diff --git a/app/services/projects/open_issues_count_service.rb b/app/services/projects/open_issues_count_service.rb
index 925512f31d7..d31f4596fa5 100644
--- a/app/services/projects/open_issues_count_service.rb
+++ b/app/services/projects/open_issues_count_service.rb
@@ -26,7 +26,7 @@ module Projects
def user_is_at_least_reporter?
strong_memoize(:user_is_at_least_reporter) do
- @user && @project.team.member?(@user, Gitlab::Access::REPORTER)
+ @project.member?(@user, Gitlab::Access::REPORTER)
end
end
diff --git a/app/services/projects/open_merge_requests_count_service.rb b/app/services/projects/open_merge_requests_count_service.rb
index 76ec13952ab..c67ebf2f26a 100644
--- a/app/services/projects/open_merge_requests_count_service.rb
+++ b/app/services/projects/open_merge_requests_count_service.rb
@@ -4,12 +4,12 @@ module Projects
# Service class for counting and caching the number of open merge requests of
# a project.
class OpenMergeRequestsCountService < Projects::CountService
- def relation_for_count
- @project.merge_requests.opened
- end
-
def cache_key_name
'open_merge_requests_count'
end
+
+ def self.query(project_ids)
+ MergeRequest.opened.of_projects(project_ids)
+ end
end
end
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index b2166dc84c7..d0bef9da329 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -93,7 +93,7 @@ module Projects
sentry_project_id: settings.dig(:project, :sentry_project_id)
}
}
- params[:error_tracking_setting_attributes][:token] = settings[:token] unless /\A\*+\z/.match?(settings[:token]) # Don't update token if we receive masked value
+ params[:error_tracking_setting_attributes][:token] = settings[:token] unless ::ErrorTracking::SentryClient::Token.masked_token?(settings[:token]) # Don't update token if we receive masked value
params[:error_tracking_setting_attributes][:integrated] = settings[:integrated] unless settings[:integrated].nil?
params
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/protect_default_branch_service.rb b/app/services/projects/protect_default_branch_service.rb
index 5360902038b..0aca525921c 100644
--- a/app/services/projects/protect_default_branch_service.rb
+++ b/app/services/projects/protect_default_branch_service.rb
@@ -45,11 +45,7 @@ module Projects
end
def protected_branch_exists?
- if Feature.enabled?(:group_protected_branches)
- project.all_protected_branches.find_by_name(default_branch).present?
- else
- project.protected_branches.find_by_name(default_branch).present?
- end
+ project.all_protected_branches.find_by_name(default_branch).present?
end
def default_branch
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index ed99c69be07..4a9d96d266c 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -63,8 +63,8 @@ module Projects
raise TransferError, s_('TransferProject|Project cannot be transferred, because tags are present in its container registry')
end
- if project.has_packages?(:npm) && !new_namespace_has_same_root?(project)
- raise TransferError, s_("TransferProject|Root namespace can't be updated if project has NPM packages")
+ if !new_namespace_has_same_root?(project) && project.has_namespaced_npm_packages?
+ raise TransferError, s_("TransferProject|Root namespace can't be updated if the project has NPM packages scoped to the current root level namespace.")
end
proceed_to_transfer
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_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index 7c63216af5e..cadf3012131 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -25,19 +25,6 @@ module Projects
end
end
- # The underlying FetchInternalRemote call uses a `git fetch` to move data
- # to the new repository, which leaves it in a less-well-packed state,
- # lacking bitmaps and commit graphs. Housekeeping will boost performance
- # significantly.
- def enqueue_housekeeping
- return unless Gitlab::CurrentSettings.housekeeping_enabled?
- return unless Feature.enabled?(:repack_after_shard_migration, project)
-
- Repositories::HousekeepingService.new(project, :gc).execute
- rescue Repositories::HousekeepingService::LeaseTaken
- # No action required
- end
-
def remove_old_paths
super
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 301d11d841c..7f25ab5883f 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -10,6 +10,8 @@ module Projects
def execute
build_topics
remove_unallowed_params
+ add_pages_unique_domain
+
validate!
ensure_wiki_exists if enabling_wiki?
@@ -48,6 +50,24 @@ module Projects
private
+ def add_pages_unique_domain
+ if Feature.disabled?(:pages_unique_domain, project)
+ params[:project_setting_attributes]&.delete(:pages_unique_domain_enabled)
+
+ return
+ end
+
+ return unless params.dig(:project_setting_attributes, :pages_unique_domain_enabled)
+
+ # If the project used a unique domain once, it'll always use the same
+ return if project.project_setting.pages_unique_domain_in_database.present?
+
+ params[:project_setting_attributes][:pages_unique_domain] = Gitlab::Pages::RandomDomain.generate(
+ project_path: project.path,
+ namespace_path: project.parent.full_path
+ )
+ end
+
def validate!
unless valid_visibility_level_change?(project, project.visibility_attribute_value(params))
raise ValidationError, s_('UpdateProject|New visibility level not allowed!')
@@ -100,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/base_service.rb b/app/services/protected_branches/base_service.rb
index 951017b2d01..0ab46bf236c 100644
--- a/app/services/protected_branches/base_service.rb
+++ b/app/services/protected_branches/base_service.rb
@@ -18,6 +18,10 @@ module ProtectedBranches
def refresh_cache
CacheService.new(@project_or_group, @current_user, @params).refresh
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e)
end
end
end
+
+ProtectedBranches::BaseService.prepend_mod
diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb
index 4a9fc335421..cb2977796d7 100644
--- a/app/services/protected_branches/cache_service.rb
+++ b/app/services/protected_branches/cache_service.rb
@@ -73,20 +73,25 @@ module ProtectedBranches
end
def redis_key
- @redis_key ||= if Feature.enabled?(:group_protected_branches)
+ group = project_or_group.is_a?(Group) ? project_or_group : project_or_group.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/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index f1e4dac8835..b5f6bff756b 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -11,6 +11,7 @@ module QuickActions
include Gitlab::QuickActions::CommitActions
include Gitlab::QuickActions::CommonActions
include Gitlab::QuickActions::RelateActions
+ include Gitlab::QuickActions::WorkItemActions
attr_reader :quick_action_target
@@ -49,12 +50,13 @@ module QuickActions
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and array of changes explained.
- def explain(content, quick_action_target)
+ # `keep_actions: true` will keep the quick actions in the content.
+ def explain(content, quick_action_target, keep_actions: false)
return [content, []] unless current_user.can?(:use_quick_actions)
@quick_action_target = quick_action_target
- content, commands = extractor.extract_commands(content)
+ content, commands = extractor(keep_actions).extract_commands(content)
commands = explain_commands(commands)
[content, commands]
end
@@ -65,8 +67,8 @@ module QuickActions
raise Gitlab::QuickActions::CommandDefinition::ParseError, message
end
- def extractor
- Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
+ def extractor(keep_actions = false)
+ Gitlab::QuickActions::Extractor.new(self.class.command_definitions, keep_actions: keep_actions)
end
# Find users for commands like /assign
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/releases/links/base_service.rb b/app/services/releases/links/base_service.rb
new file mode 100644
index 00000000000..8bab258f80a
--- /dev/null
+++ b/app/services/releases/links/base_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Releases
+ module Links
+ REASON_BAD_REQUEST = :bad_request
+ REASON_NOT_FOUND = :not_found
+ REASON_FORBIDDEN = :forbidden
+
+ class BaseService
+ attr_accessor :release, :current_user, :params
+
+ def initialize(release, current_user = nil, params = {})
+ @release = release
+ @current_user = current_user
+ @params = params.dup
+ end
+
+ private
+
+ def allowed_params
+ @allowed_params ||= params.slice(:name, :url, :link_type).tap do |hash|
+ hash[:filepath] = filepath if provided_filepath?
+ end
+ end
+
+ def provided_filepath?
+ params.key?(:direct_asset_path) || params.key?(:filepath)
+ end
+
+ def filepath
+ params[:direct_asset_path] || params[:filepath]
+ end
+ end
+ end
+end
diff --git a/app/services/releases/links/create_service.rb b/app/services/releases/links/create_service.rb
new file mode 100644
index 00000000000..94823c54596
--- /dev/null
+++ b/app/services/releases/links/create_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Releases
+ module Links
+ class CreateService < BaseService
+ def execute
+ return ServiceResponse.error(reason: REASON_FORBIDDEN, message: _('Access Denied')) unless allowed?
+
+ link = release.links.create(allowed_params)
+
+ if link.persisted?
+ ServiceResponse.success(payload: { link: link })
+ else
+ ServiceResponse.error(reason: REASON_BAD_REQUEST, message: link.errors.full_messages)
+ end
+ end
+
+ private
+
+ def allowed?
+ Ability.allowed?(current_user, :create_release, release)
+ end
+ end
+ end
+end
diff --git a/app/services/releases/links/destroy_service.rb b/app/services/releases/links/destroy_service.rb
new file mode 100644
index 00000000000..1c1158017bb
--- /dev/null
+++ b/app/services/releases/links/destroy_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Releases
+ module Links
+ class DestroyService < BaseService
+ def execute(link)
+ return ServiceResponse.error(reason: REASON_FORBIDDEN, message: _('Access Denied')) unless allowed?
+ return ServiceResponse.error(reason: REASON_NOT_FOUND, message: _('Link does not exist')) unless link
+
+ if link.destroy
+ ServiceResponse.success(payload: { link: link })
+ else
+ ServiceResponse.error(reason: REASON_BAD_REQUEST, message: link.errors.full_messages)
+ end
+ end
+
+ private
+
+ def allowed?
+ Ability.allowed?(current_user, :destroy_release, release)
+ end
+ end
+ end
+end
diff --git a/app/services/releases/links/update_service.rb b/app/services/releases/links/update_service.rb
new file mode 100644
index 00000000000..c29de86f31b
--- /dev/null
+++ b/app/services/releases/links/update_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Releases
+ module Links
+ class UpdateService < BaseService
+ def execute(link)
+ return ServiceResponse.error(reason: REASON_FORBIDDEN, message: _('Access Denied')) unless allowed?
+ return ServiceResponse.error(reason: REASON_NOT_FOUND, message: _('Link does not exist')) unless link
+
+ if link.update(allowed_params)
+ ServiceResponse.success(payload: { link: link })
+ else
+ ServiceResponse.error(reason: REASON_BAD_REQUEST, message: link.errors.full_messages)
+ end
+ end
+
+ private
+
+ def allowed?
+ Ability.allowed?(current_user, :update_release, release)
+ end
+ end
+ end
+end
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index f6fe23b4555..553315f08f9 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -2,6 +2,8 @@
module ResourceAccessTokens
class CreateService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
def initialize(current_user, resource, params = {})
@resource_type = resource.class.name.downcase
@resource = resource
@@ -25,7 +27,7 @@ module ResourceAccessTokens
unless member.persisted?
delete_failed_user(user)
- return error("Could not provision #{Gitlab::Access.human_access(access_level).downcase} access to project access token")
+ return error("Could not provision #{Gitlab::Access.human_access(access_level.to_i).downcase} access to the access token. ERROR: #{member.errors.full_messages.to_sentence}")
end
token_response = create_personal_access_token(user)
@@ -43,6 +45,14 @@ module ResourceAccessTokens
attr_reader :resource_type, :resource
+ def username_and_email_generator
+ Gitlab::Utils::UsernameAndEmailGenerator.new(
+ username_prefix: "#{resource_type}_#{resource.id}_bot",
+ email_domain: "noreply.#{Gitlab.config.gitlab.host}"
+ )
+ end
+ strong_memoize_attr :username_and_email_generator
+
def has_permission_to_create?
%w(project group).include?(resource_type) && can?(current_user, :create_resource_access_tokens, resource)
end
@@ -63,31 +73,13 @@ module ResourceAccessTokens
def default_user_params
{
name: params[:name] || "#{resource.name.to_s.humanize} bot",
- email: generate_email,
- username: generate_username,
+ email: username_and_email_generator.email,
+ username: username_and_email_generator.username,
user_type: :project_bot,
skip_confirmation: true # Bot users should always have their emails confirmed.
}
end
- def generate_username
- base_username = "#{resource_type}_#{resource.id}_bot"
-
- uniquify.string(base_username) { |s| User.find_by_username(s) }
- end
-
- def generate_email
- email_pattern = "#{resource_type}#{resource.id}_bot%s@noreply.#{Gitlab.config.gitlab.host}"
-
- uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s|
- User.find_by_email(s)
- end
- end
-
- def uniquify
- Uniquify.new
- end
-
def create_personal_access_token(user)
PersonalAccessTokens::CreateService.new(
current_user: user, target_user: user, params: personal_access_token_params
@@ -108,7 +100,15 @@ module ResourceAccessTokens
end
def create_membership(resource, user, access_level)
- resource.add_member(user, access_level, expires_at: params[:expires_at])
+ resource.add_member(user, access_level, expires_at: default_pat_expiration)
+ end
+
+ def default_pat_expiration
+ if Feature.enabled?(:default_pat_expiration)
+ params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
+ else
+ params[:expires_at]
+ end
end
def log_event(token)
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index 02182bc3a77..69e68922b91 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -55,7 +55,7 @@ module ResourceEvents
end
def create_timeline_events_from(added_labels: [], removed_labels: [])
- return unless resource.incident?
+ return unless resource.incident_type_issue?
IncidentManagement::TimelineEvents::CreateService.change_labels(
resource,
diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb
index 3e8865d3dff..b60a949fd4e 100644
--- a/app/services/security/ci_configuration/base_create_service.rb
+++ b/app/services/security/ci_configuration/base_create_service.rb
@@ -19,7 +19,8 @@ module Security
target: '_blank',
rel: 'noopener noreferrer'
raise Gitlab::Graphql::Errors::MutationError,
- _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe
+ Gitlab::Utils::ErrorMessage.to_user_facing(
+ _(format('You must %s before using Security features.', docs_link.html_safe)).html_safe)
end
project.repository.add_branch(current_user, branch_name, project.default_branch)
@@ -51,14 +52,15 @@ module Security
end
def existing_gitlab_ci_content
- root_ref = root_ref_sha(project)
+ root_ref = root_ref_sha(project.repository)
return if root_ref.nil?
@gitlab_ci_yml ||= project.ci_config_for(root_ref)
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,
@@ -82,13 +84,10 @@ module Security
)
end
- def root_ref_sha(project)
- project.repository.root_ref_sha
- rescue StandardError => e
- # this might fail on the very first commit,
- # and unfortunately it raises a StandardError
- Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
- nil
+ def root_ref_sha(repository)
+ commit = repository.commit(repository.root_ref)
+
+ commit&.sha
end
end
end
diff --git a/app/services/serverless/associate_domain_service.rb b/app/services/serverless/associate_domain_service.rb
deleted file mode 100644
index 0c6ee58924c..00000000000
--- a/app/services/serverless/associate_domain_service.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Serverless
- class AssociateDomainService
- PLACEHOLDER_HOSTNAME = 'example.com'
-
- def initialize(knative, pages_domain_id:, creator:)
- @knative = knative
- @pages_domain_id = pages_domain_id
- @creator = creator
- end
-
- def execute
- return if unchanged?
-
- knative.hostname ||= PLACEHOLDER_HOSTNAME
-
- knative.pages_domain = knative.find_available_domain(pages_domain_id)
- knative.serverless_domain_cluster.update(creator: creator) if knative.pages_domain
- end
-
- private
-
- attr_reader :knative, :pages_domain_id, :creator
-
- def unchanged?
- knative.pages_domain&.id == pages_domain_id
- end
- end
-end
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 9c52e9f0cd3..7c96f003e46 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -53,7 +53,7 @@ module Spam
end
def allowlisted?(user)
- user.try(:gitlab_employee?) || user.try(:gitlab_bot?) || user.try(:gitlab_service_user?)
+ user.try(:gitlab_bot?) || user.try(:gitlab_service_user?)
end
##
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 4ec07bb4c5f..1279adf327b 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -14,57 +14,47 @@ module Spam
end
def execute
- spamcheck_result = nil
- spamcheck_attribs = {}
- spamcheck_error = false
+ spamcheck_verdict = nil
external_spam_check_round_trip_time = Benchmark.realtime do
- spamcheck_result, spamcheck_attribs, spamcheck_error = spamcheck_verdict
+ spamcheck_verdict = get_spamcheck_verdict
end
- label = spamcheck_error ? 'ERROR' : spamcheck_result.to_s.upcase
+ histogram.observe({ result: spamcheck_verdict.upcase }, external_spam_check_round_trip_time) if spamcheck_verdict
- histogram.observe({ result: label }, external_spam_check_round_trip_time)
-
- # assign result to a var for logging it before reassigning to nil when monitorMode is true
- original_spamcheck_result = spamcheck_result
-
- spamcheck_result = nil if spamcheck_attribs&.fetch("monitorMode", "false") == "true"
-
- akismet_result = akismet_verdict
+ akismet_verdict = get_akismet_verdict
# filter out anything we don't recognise, including nils.
- valid_results = [spamcheck_result, akismet_result].compact.select { |r| SUPPORTED_VERDICTS.key?(r) }
+ valid_verdicts = [spamcheck_verdict, akismet_verdict].compact.select { |r| SUPPORTED_VERDICTS.key?(r) }
# Treat nils - such as service unavailable - as ALLOW
- return ALLOW unless valid_results.any?
+ return ALLOW unless valid_verdicts.any?
- # Favour the most restrictive result.
- verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] }
+ # Favour the most restrictive verdict
+ final_verdict = valid_verdicts.min_by { |v| SUPPORTED_VERDICTS[v][:priority] }
# The target can override the verdict via the `allow_possible_spam` application setting
- verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: verdict)
+ final_verdict = OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM if override_via_allow_possible_spam?(verdict: final_verdict)
logger.info(class: self.class.name,
akismet_verdict: akismet_verdict,
- spam_check_verdict: original_spamcheck_result,
- extra_attributes: spamcheck_attribs,
+ spam_check_verdict: spamcheck_verdict,
spam_check_rtt: external_spam_check_round_trip_time.real,
- final_verdict: verdict,
+ final_verdict: final_verdict,
username: user.username,
user_id: user.id,
target_type: target.class.to_s,
project_id: target.project_id
)
- verdict
+ final_verdict
end
private
attr_reader :user, :target, :options, :context, :extra_features
- def akismet_verdict
+ def get_akismet_verdict
if akismet.spam?
Gitlab::Recaptcha.enabled? ? CONDITIONAL_ALLOW : DISALLOW
else
@@ -72,23 +62,21 @@ module Spam
end
end
- def spamcheck_verdict
+ def get_spamcheck_verdict
return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled
begin
- result, attribs, _error = spamcheck_client.spam?(spammable: target, user: user, context: context,
- extra_features: extra_features)
- # @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545
+ result = spamcheck_client.spam?(spammable: target, user: user, context: context, extra_features: extra_features)
- return [nil, attribs] unless result
+ if result.evaluated? && Feature.enabled?(:user_spam_scores)
+ Abuse::TrustScore.create!(user: user, score: result.score, source: :spamcheck)
+ end
- [result, attribs]
+ result.verdict
rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e, error: ERROR_TYPE)
-
- # Default to ALLOW if any errors occur
- [ALLOW, attribs, true]
+ nil
end
end
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/commit_service.rb b/app/services/system_notes/commit_service.rb
index 592351079aa..e4d89ecb930 100644
--- a/app/services/system_notes/commit_service.rb
+++ b/app/services/system_notes/commit_service.rb
@@ -2,6 +2,8 @@
module SystemNotes
class CommitService < ::SystemNotes::BaseService
+ NEW_COMMIT_DISPLAY_LIMIT = 10
+
# Called when commits are added to a merge request
#
# new_commits - Array of Commits added since last push
@@ -36,25 +38,73 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
end
+ private
+
# Build an Array of lines detailing each commit added in a merge request
#
# new_commits - Array of new Commit objects
#
# Returns an Array of Strings
- def new_commit_summary(new_commits)
+ def new_commits_list(new_commits)
new_commits.collect do |commit|
content_tag('li', "#{commit.short_id} - #{commit.title}")
end
end
- private
+ # Builds an Array of lines describing each commit and truncate them based on the limit
+ # to avoid creating a note with a large number of commits.
+ #
+ # commits - Array of Commit objects
+ #
+ # Returns an Array of Strings
+ #
+ # rubocop: disable CodeReuse/ActiveRecord
+ def new_commit_summary(commits, start_rev)
+ if commits.size > NEW_COMMIT_DISPLAY_LIMIT
+ no_of_commits_to_truncate = commits.size - NEW_COMMIT_DISPLAY_LIMIT
+ commits_to_truncate = commits.take(no_of_commits_to_truncate)
+ remaining_commits = commits.drop(no_of_commits_to_truncate)
+
+ [truncated_new_commits(commits_to_truncate, start_rev)] + new_commits_list(remaining_commits)
+ else
+ new_commits_list(commits)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # Builds a summary line that describes given truncated commits.
+ #
+ # commits - Array of Commit objects
+ # start_rev - String SHA of a Commit that will be used as the starting SHA of the range
+ #
+ # Returns a String wrapped in 'li' tag.
+ def truncated_new_commits(commits, start_rev)
+ count = commits.size
+
+ commit_ids = if count == 1
+ commits.first.short_id
+ elsif start_rev && !Gitlab::Git.blank_ref?(start_rev)
+ "#{Commit.truncate_sha(start_rev)}...#{commits.last.short_id}"
+ else
+ # This two-dots notation seems to be not functioning as expected, but we should
+ # fallback to it as start_rev can be empty.
+ #
+ # For more information, please see https://gitlab.com/gitlab-org/gitlab/-/issues/391809
+ "#{commits.first.short_id}..#{commits.last.short_id}"
+ end
+
+ commits_text = "#{count} earlier commit".pluralize(count)
+
+ content_tag('li', "#{commit_ids} - #{commits_text}")
+ end
# Builds a list of existing and new commits according to existing_commits and
# new_commits methods.
# Returns a String wrapped in `ul` and `li` tags.
def commits_list(noteable, new_commits, existing_commits, oldrev)
existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
- new_commit_summary = new_commit_summary(new_commits).join
+ start_rev = existing_commits.empty? ? oldrev : existing_commits.last.id
+ new_commit_summary = new_commit_summary(new_commits, start_rev).join
content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
end
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/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index c5bdbc6799e..b7a2afbaf15 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -147,7 +147,7 @@ module SystemNotes
readable_date = date_key.humanize.downcase
if changed_date.nil?
- "removed #{readable_date}"
+ "removed #{readable_date} #{changed_dates[date_key].first.to_s(:long)}"
else
"changed #{readable_date} to #{changed_date.to_s(:long)}"
end
diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb
index 5851a2cb9e5..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: [])
@@ -11,7 +11,7 @@ module TasksToBeDone
description: description,
add_labels: label_name
}
- super(project: container, current_user: current_user, params: params)
+ super(container: container, current_user: current_user, params: params)
end
def execute
@@ -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/todo_service.rb b/app/services/todo_service.rb
index 42a8aca17d3..c55e1680bfe 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -175,13 +175,26 @@ class TodoService
TodosFinder.new(current_user).any_for_target?(issuable, :pending)
end
- # Resolves all todos related to target
+ # Resolves all todos related to target for the current_user
def resolve_todos_for_target(target, current_user)
attributes = attributes_for_target(target)
resolve_todos(pending_todos([current_user], attributes), current_user)
end
+ # Resolves all todos related to target for all users
+ def resolve_todos_with_attributes_for_target(target, attributes, resolution: :done, resolved_by_action: :system_done)
+ target_attributes = { target_id: target.id, target_type: target.class.polymorphic_name }
+ attributes.merge!(target_attributes)
+ attributes[:preload_user_association] = true
+
+ todos = PendingTodosFinder.new(attributes).execute
+ users = todos.map(&:user)
+ todos_ids = todos.batch_update(state: resolution, resolved_by_action: resolved_by_action)
+ users.each(&:update_todos_count_cache)
+ todos_ids
+ end
+
def resolve_todos(todos, current_user, resolution: :done, resolved_by_action: :system_done)
todos_ids = todos.batch_update(state: resolution, resolved_by_action: resolved_by_action)
@@ -198,21 +211,20 @@ class TodoService
current_user.update_todos_count_cache
end
- def resolve_access_request_todos(current_user, member)
- return if current_user.nil? || member.nil?
+ def resolve_access_request_todos(member)
+ return if member.nil?
+ # Group or Project
target = member.source
- finder_params = {
+ todos_params = {
state: :pending,
author_id: member.user_id,
- action_id: ::Todo::MEMBER_ACCESS_REQUESTED,
- type: target.class.polymorphic_name,
- target: target.id
+ action: ::Todo::MEMBER_ACCESS_REQUESTED,
+ type: target.class.polymorphic_name
}
- todos = TodosFinder.new(current_user, finder_params).execute
- resolve_todos(todos, current_user)
+ resolve_todos_with_attributes_for_target(target, todos_params)
end
def restore_todos(todos, current_user)
@@ -419,7 +431,7 @@ class TodoService
end
def pending_todos(users, criteria = {})
- PendingTodosFinder.new(users, criteria).execute
+ PendingTodosFinder.new(criteria.merge(users: users)).execute
end
def track_todo_creation(user, issue_type, namespace, project)
@@ -428,8 +440,6 @@ class TodoService
event = "incident_management_incident_todo"
track_usage_event(event, user.id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace)
-
Gitlab::Tracking.event(
self.class.to_s,
event,
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index c8f9c28061f..24aa4aa1061 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -41,8 +41,6 @@ module Users
Gitlab::UsageDataCounters::HLLRedisCounter.track_event('unique_active_user', values: user.id)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase3)
-
Gitlab::Tracking.event(
'Users::ActivityService',
'perform_action',
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/build_service.rb b/app/services/users/build_service.rb
index 934dccf2f76..04a11f41eb1 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -2,6 +2,8 @@
module Users
class BuildService < BaseService
+ ALLOWED_USER_TYPES = %i[project_bot security_policy_bot].freeze
+
delegate :user_default_internal_regex_enabled?,
:user_default_internal_regex_instance,
to: :'Gitlab::CurrentSettings.current_application_settings'
@@ -70,7 +72,7 @@ module Users
@user_params[:created_by_id] = current_user&.id
@user_params[:external] = user_external? if set_external_param?
- @user_params.delete(:user_type) unless project_bot?
+ @user_params.delete(:user_type) unless allowed_user_type?
end
def set_external_param?
@@ -81,8 +83,8 @@ module Users
user_default_internal_regex_instance.match(params[:email]).nil?
end
- def project_bot?
- user_params[:user_type]&.to_sym == :project_bot
+ def allowed_user_type?
+ ALLOWED_USER_TYPES.include?(user_params[:user_type]&.to_sym)
end
def password_reset
diff --git a/app/services/users/deactivate_service.rb b/app/services/users/deactivate_service.rb
new file mode 100644
index 00000000000..e69ce13d3cc
--- /dev/null
+++ b/app/services/users/deactivate_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Users
+ class DeactivateService < BaseService
+ def initialize(current_user, skip_authorization: false)
+ @current_user = current_user
+ @skip_authorization = skip_authorization
+ end
+
+ def execute(user)
+ unless allowed?
+ return ::ServiceResponse.error(message: _('You are not authorized to perform this action'),
+ reason: :forbidden)
+ end
+
+ if user.blocked?
+ return ::ServiceResponse.error(message: _('Error occurred. A blocked user cannot be deactivated'),
+ reason: :forbidden)
+ end
+
+ if user.internal?
+ return ::ServiceResponse.error(message: _('Internal users cannot be deactivated'),
+ reason: :forbidden)
+ end
+
+ return ::ServiceResponse.success(message: _('User has already been deactivated')) if user.deactivated?
+
+ unless user.can_be_deactivated?
+ message = _(
+ 'The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days ' \
+ 'and cannot be deactivated')
+
+ deactivation_error_message = format(message,
+ minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period)
+ return ::ServiceResponse.error(message: deactivation_error_message, reason: :forbidden)
+ end
+
+ unless user.deactivate
+ return ::ServiceResponse.error(message: user.errors.full_messages.to_sentence,
+ reason: :bad_request)
+ end
+
+ log_event(user)
+
+ ::ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def allowed?
+ return true if @skip_authorization
+
+ can?(current_user, :admin_all_resources)
+ end
+
+ def log_event(user)
+ Gitlab::AppLogger.info(message: 'User deactivated', user: user.username.to_s, email: user.email.to_s,
+ deactivated_by: current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s)
+ end
+ end
+end
+
+Users::DeactivateService.prepend_mod_with('Users::DeactivateService')
diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb
index 3337beec195..721290fe056 100644
--- a/app/services/users/email_verification/base_service.rb
+++ b/app/services/users/email_verification/base_service.rb
@@ -5,22 +5,23 @@ module Users
class BaseService
VALID_ATTRS = %i[unlock_token confirmation_token].freeze
- def initialize(attr:)
+ def initialize(attr:, user:)
@attr = attr
+ @user = user
validate_attr!
end
protected
- attr_reader :attr, :token
+ attr_reader :attr, :user, :token
def validate_attr!
raise ArgumentError, 'Invalid attribute' unless attr.in?(VALID_ATTRS)
end
def digest
- Devise.token_generator.digest(User, attr, token)
+ Devise.token_generator.digest(User, user.email, token)
end
end
end
diff --git a/app/services/users/email_verification/validate_token_service.rb b/app/services/users/email_verification/validate_token_service.rb
index b1b34e94f49..30413de805c 100644
--- a/app/services/users/email_verification/validate_token_service.rb
+++ b/app/services/users/email_verification/validate_token_service.rb
@@ -8,9 +8,8 @@ module Users
TOKEN_VALID_FOR_MINUTES = 60
def initialize(attr:, user:, token:)
- super(attr: attr)
+ super(attr: attr, user: user)
- @user = user
@token = token
end
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/users/update_service.rb b/app/services/users/update_service.rb
index 96018db5974..36c41c03303 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -6,6 +6,7 @@ module Users
attr_reader :user, :identity_params
ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze
+ BATCH_SIZE = 100
def initialize(current_user, params = {})
@current_user = current_user
@@ -34,7 +35,7 @@ module Users
reset_unconfirmed_email
if @user.save(validate: validate) && update_status
- notify_success(user_exists)
+ after_update(user_exists)
else
messages = @user.errors.full_messages + Array(@user.status&.errors&.full_messages)
error(messages.uniq.join('. '))
@@ -80,8 +81,6 @@ module Users
def notify_success(user_exists)
notify_new_user(@user, nil) unless user_exists
-
- success
end
def discard_read_only_attributes
@@ -118,6 +117,30 @@ module Users
def provider_params
identity_params.slice(*provider_attributes)
end
+
+ def after_update(user_exists)
+ notify_success(user_exists)
+ remove_followers_and_followee! if ::Feature.enabled?(:disable_follow_users, user)
+
+ success
+ end
+
+ def remove_followers_and_followee!
+ return false unless user.user_preference.enabled_following_previously_changed?(from: true, to: false)
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ loop do
+ inner_query = Users::UserFollowUser
+ .where(follower_id: user.id).or(Users::UserFollowUser.where(followee_id: user.id))
+ .select(:follower_id, :followee_id)
+ .limit(BATCH_SIZE)
+
+ deleted_records = Users::UserFollowUser.where('(follower_id, followee_id) IN (?)', inner_query).delete_all
+
+ break if deleted_records == 0
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
end
end
diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb
index 7190c82bea3..61cf598f178 100644
--- a/app/services/users/upsert_credit_card_validation_service.rb
+++ b/app/services/users/upsert_credit_card_validation_service.rb
@@ -2,9 +2,8 @@
module Users
class UpsertCreditCardValidationService < BaseService
- def initialize(params, user)
+ def initialize(params)
@params = params.to_h.with_indifferent_access
- @current_user = user
end
def execute
@@ -19,8 +18,6 @@ module Users
::Users::CreditCardValidation.upsert(@params)
- ::Users::UpdateService.new(current_user, user: current_user, requires_credit_card_verification: false).execute!
-
ServiceResponse.success(message: 'CreditCardValidation was set')
rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation => e
ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}")
diff --git a/app/services/users/validate_manual_otp_service.rb b/app/services/users/validate_manual_otp_service.rb
index 96a827db13c..8ba76f5f593 100644
--- a/app/services/users/validate_manual_otp_service.rb
+++ b/app/services/users/validate_manual_otp_service.rb
@@ -3,6 +3,7 @@
module Users
class ValidateManualOtpService < BaseService
include ::Gitlab::Auth::Otp::Fortinet
+ include ::Gitlab::Auth::Otp::DuoAuth
def initialize(current_user)
@current_user = current_user
@@ -10,6 +11,8 @@ module Users
::Gitlab::Auth::Otp::Strategies::FortiAuthenticator::ManualOtp.new(current_user)
elsif forti_token_cloud_enabled?(current_user)
::Gitlab::Auth::Otp::Strategies::FortiTokenCloud.new(current_user)
+ elsif duo_auth_enabled?(current_user)
+ ::Gitlab::Auth::Otp::Strategies::DuoAuth::ManualOtp.new(current_user)
else
::Gitlab::Auth::Otp::Strategies::Devise.new(current_user)
end
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 9bef75e2c40..ee20a2832ce 100644
--- a/app/services/work_items/export_csv_service.rb
+++ b/app/services/work_items/export_csv_service.rb
@@ -11,24 +11,38 @@ module WorkItems
end
def email(mail_to_user)
- # TODO - will be implemented as part of https://gitlab.com/gitlab-org/gitlab/-/issues/379082
+ Notify.export_work_items_csv_email(mail_to_user, resource_parent, csv_data, csv_builder.status).deliver_now
end
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/import_csv_service.rb b/app/services/work_items/import_csv_service.rb
new file mode 100644
index 00000000000..e7043cc882a
--- /dev/null
+++ b/app/services/work_items/import_csv_service.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module WorkItems
+ class ImportCsvService < ImportCsv::BaseService
+ extend ::Gitlab::Utils::Override
+
+ FeatureNotAvailableError = StandardError.new(
+ 'This feature is currently behind a feature flag and it is not available.'
+ )
+ NotAuthorizedError = StandardError.new('You do not have permission to import work items in this project.')
+
+ override :initialize
+ def initialize(*args)
+ super
+
+ @type_errors = {
+ blank: [],
+ missing: {},
+ disallowed: {}
+ }
+ end
+
+ def self.required_headers
+ %w[title type].freeze
+ end
+
+ def execute
+ raise FeatureNotAvailableError if ::Feature.disabled?(:import_export_work_items_csv, project)
+ raise NotAuthorizedError unless Ability.allowed?(user, :import_work_items, project)
+
+ super
+ end
+
+ def email_results_to_user
+ Notify.import_work_items_csv_email(user.id, project.id, results).deliver_later
+ end
+
+ private
+
+ attr_accessor :type_errors
+
+ def create_object(attributes)
+ super[:work_item]
+ end
+
+ def create_object_class
+ ::WorkItems::CreateService
+ end
+
+ override :attributes_for
+ def attributes_for(row)
+ {
+ title: row[:title],
+ work_item_type: match_work_item_type(row[:type])
+ }
+ end
+
+ override :validate_headers_presence!
+ def validate_headers_presence!(headers)
+ required_headers = self.class.required_headers
+
+ headers.downcase!
+ return if headers && required_headers.all? { |rh| headers.include?(rh) }
+
+ required_headers_message = "Required headers are missing. Required headers are #{required_headers.join(', ')}"
+ raise CSV::MalformedCSVError.new(required_headers_message, 1)
+ end
+
+ def match_work_item_type(work_item_type)
+ match = available_work_item_types[work_item_type&.downcase]
+ match[:type] if match
+ end
+
+ def available_work_item_types
+ {
+ issue: {
+ allowed: Ability.allowed?(user, :create_issue, project),
+ type: WorkItems::Type.default_by_type(:issue)
+ }
+ }.with_indifferent_access
+ end
+ strong_memoize_attr :available_work_item_types
+
+ def preprocess!
+ with_csv_lines.each do |row, line_no|
+ work_item_type = row[:type]&.strip&.downcase
+
+ if work_item_type.blank?
+ type_errors[:blank] << line_no
+ elsif missing?(work_item_type)
+ # does this work item exist in the range of work items we support?
+ (type_errors[:missing][work_item_type] ||= []) << line_no
+ elsif !allowed?(work_item_type)
+ (type_errors[:disallowed][work_item_type] ||= []) << line_no
+ end
+ end
+
+ return if type_errors[:blank].empty? &&
+ type_errors[:missing].blank? &&
+ type_errors[:disallowed].blank?
+
+ results[:type_errors] = type_errors
+ raise PreprocessError
+ end
+
+ def missing?(work_item_type_name)
+ !available_work_item_types.key?(work_item_type_name)
+ end
+
+ def allowed?(work_item_type_name)
+ !!available_work_item_types[work_item_type_name][:allowed]
+ end
+ end
+end
+
+WorkItems::ImportCsvService.prepend_mod
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 288ca152f93..4747d2f17e4 100644
--- a/app/services/work_items/parent_links/create_service.rb
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -2,57 +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
deleted file mode 100644
index f373e6daea3..00000000000
--- a/app/services/work_items/widgets/milestone_service/base_service.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module MilestoneService
- class BaseService < WorkItems::Widgets::BaseService
- private
-
- def handle_milestone_change(params:)
- return unless params.present? && params.key?(:milestone_id)
-
- unless has_permission?(:set_work_item_metadata)
- params.delete(:milestone_id)
- return
- end
-
- if params[:milestone_id].nil?
- work_item.milestone = nil
-
- return
- end
-
- project = work_item.project
- milestone = MilestonesFinder.new({
- project_ids: [project.id],
- group_ids: project.group&.self_and_ancestors&.select(:id),
- ids: [params[:milestone_id]]
- }).execute.first
-
- if milestone
- work_item.milestone = milestone
- else
- params.delete(:milestone_id)
- end
- end
- end
- end
- end
-end
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/notifications_service/update_service.rb b/app/services/work_items/widgets/notifications_service/update_service.rb
new file mode 100644
index 00000000000..b301e2ca7db
--- /dev/null
+++ b/app/services/work_items/widgets/notifications_service/update_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module NotificationsService
+ class UpdateService < WorkItems::Widgets::BaseService
+ def before_update_in_transaction(params:)
+ return unless params.present? && params.key?(:subscribed)
+ return unless has_permission?(:update_subscription)
+
+ update_subscription(work_item, params)
+ end
+
+ private
+
+ def update_subscription(work_item, subscription_params)
+ work_item.set_subscription(
+ current_user,
+ subscription_params[:subscribed],
+ work_item.project
+ )
+ 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/ci/pipeline_artifact_uploader.rb b/app/uploaders/ci/pipeline_artifact_uploader.rb
index d3a83c5d633..62e00fe1a66 100644
--- a/app/uploaders/ci/pipeline_artifact_uploader.rb
+++ b/app/uploaders/ci/pipeline_artifact_uploader.rb
@@ -4,7 +4,7 @@ module Ci
class PipelineArtifactUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.artifacts
+ storage_location :artifacts
alias_method :upload, :model
diff --git a/app/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb
index 11cbfc6c1f2..09d9b3abafb 100644
--- a/app/uploaders/ci/secure_file_uploader.rb
+++ b/app/uploaders/ci/secure_file_uploader.rb
@@ -4,7 +4,7 @@ module Ci
class SecureFileUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.ci_secure_files
+ storage_location :ci_secure_files
# Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks)
encrypt(key: :key)
diff --git a/app/uploaders/deleted_object_uploader.rb b/app/uploaders/deleted_object_uploader.rb
index fc0f62b920c..eaf584c5dfa 100644
--- a/app/uploaders/deleted_object_uploader.rb
+++ b/app/uploaders/deleted_object_uploader.rb
@@ -3,7 +3,7 @@
class DeletedObjectUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.artifacts
+ storage_location :artifacts
def store_dir
model.store_dir
diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb
index f0222d4cf06..d4e486bfe84 100644
--- a/app/uploaders/dependency_proxy/file_uploader.rb
+++ b/app/uploaders/dependency_proxy/file_uploader.rb
@@ -5,7 +5,7 @@ class DependencyProxy::FileUploader < GitlabUploader
include ObjectStorage::Concern
before :cache, :set_content_type
- storage_options Gitlab.config.dependency_proxy
+ storage_location :dependency_proxy
alias_method :upload, :model
diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb
index d2707cd0777..86c3d734174 100644
--- a/app/uploaders/external_diff_uploader.rb
+++ b/app/uploaders/external_diff_uploader.rb
@@ -3,7 +3,7 @@
class ExternalDiffUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.external_diffs
+ storage_location :external_diffs
alias_method :upload, :model
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 62024bff4c0..2eb34288bd7 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -3,7 +3,7 @@
class GitlabUploader < CarrierWave::Uploader::Base
include ContentTypeWhitelist::Concern
- class_attribute :options
+ class_attribute :storage_location_identifier
PROTECTED_METHODS = %i(filename cache_dir work_dir store_dir).freeze
@@ -11,8 +11,13 @@ class GitlabUploader < CarrierWave::Uploader::Base
class << self
# DSL setter
- def storage_options(options)
- self.options = options
+ def storage_location(location)
+ self.storage_location_identifier = location
+ _ = options # Ensures that we have a valid storage_location_identifier
+ end
+
+ def options
+ ObjectStorage::Config::LOCATIONS.fetch(storage_location_identifier)
end
def root
@@ -41,7 +46,7 @@ class GitlabUploader < CarrierWave::Uploader::Base
end
end
- storage_options Gitlab.config.uploads
+ storage_location :uploads
delegate :base_dir, :file_storage?, to: :class
@@ -51,6 +56,10 @@ class GitlabUploader < CarrierWave::Uploader::Base
super(model, mounted_as)
end
+ def options
+ self.class.options
+ end
+
def file_cache_storage?
cache_storage.is_a?(CarrierWave::Storage::File)
end
diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb
index b38e7d93eac..5ee8c42f510 100644
--- a/app/uploaders/job_artifact_uploader.rb
+++ b/app/uploaders/job_artifact_uploader.rb
@@ -7,7 +7,7 @@ class JobArtifactUploader < GitlabUploader
UnknownFileLocationError = Class.new(StandardError)
- storage_options Gitlab.config.artifacts
+ storage_location :artifacts
alias_method :upload, :model
diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb
index 027857500f4..4111bb92322 100644
--- a/app/uploaders/lfs_object_uploader.rb
+++ b/app/uploaders/lfs_object_uploader.rb
@@ -4,7 +4,7 @@ class LfsObjectUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
- storage_options Gitlab.config.lfs
+ storage_location :lfs
alias_method :upload, :model
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 1b47400d5e8..4188e0caa8e 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -23,6 +23,19 @@ module ObjectStorage
end
end
+ class DirectUploadStorage < ::CarrierWave::Storage::Fog
+ def store!(file)
+ return super unless @uploader.direct_upload_final_path.present?
+
+ # The direct_upload_final_path is defined which means
+ # file was uploaded to its final location so no need to move it.
+ # Now we delete the pending upload entry as the upload is considered complete.
+ ObjectStorage::PendingDirectUpload.complete(@uploader.class.storage_location_identifier, file.path)
+
+ file
+ end
+ end
+
TMP_UPLOAD_PATH = 'tmp/uploads'
module Store
@@ -91,6 +104,7 @@ module ObjectStorage
module Concern
extend ActiveSupport::Concern
+ extend ::Gitlab::Utils::Override
included do |base|
base.include(ObjectStorage)
@@ -111,6 +125,10 @@ module ObjectStorage
object_store_options&.direct_upload
end
+ def direct_upload_to_object_store?
+ object_store_enabled? && direct_upload_enabled?
+ end
+
def proxy_download_enabled?
object_store_options.proxy_download
end
@@ -131,10 +149,26 @@ module ObjectStorage
model_class.uploader_options.dig(mount_point, :mount_on) || mount_point
end
- def workhorse_authorize(has_length:, maximum_size: nil)
+ def generate_remote_id
+ [CarrierWave.generate_cache_id, SecureRandom.hex].join('-')
+ end
+
+ def generate_final_store_path
+ hash = Digest::SHA2.hexdigest(SecureRandom.uuid)
+
+ # We prefix '@final' to prevent clashes and make the files easily recognizable
+ # as having been created by this code.
+ File.join('@final', hash[0..1], hash[2..3], hash[4..])
+ end
+
+ def workhorse_authorize(has_length:, maximum_size: nil, use_final_store_path: false)
{}.tap do |hash|
- if self.object_store_enabled? && self.direct_upload_enabled?
- hash[:RemoteObject] = workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size)
+ if self.direct_upload_to_object_store?
+ hash[:RemoteObject] = workhorse_remote_upload_options(
+ has_length: has_length,
+ maximum_size: maximum_size,
+ use_final_store_path: use_final_store_path
+ )
else
hash[:TempPath] = workhorse_local_upload_path
end
@@ -148,21 +182,38 @@ module ObjectStorage
File.join(self.root, TMP_UPLOAD_PATH)
end
+ def with_bucket_prefix(path)
+ File.join([object_store_options.bucket_prefix, path].compact)
+ end
+
def object_store_config
ObjectStorage::Config.new(object_store_options)
end
- def workhorse_remote_upload_options(has_length:, maximum_size: nil)
- return unless self.object_store_enabled?
- return unless self.direct_upload_enabled?
+ def workhorse_remote_upload_options(has_length:, maximum_size: nil, use_final_store_path: false)
+ return unless direct_upload_to_object_store?
+
+ if use_final_store_path
+ id = generate_final_store_path
+ upload_path = with_bucket_prefix(id)
+ prepare_pending_direct_upload(id)
+ else
+ id = generate_remote_id
+ upload_path = File.join(TMP_UPLOAD_PATH, id)
+ end
- id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-')
- upload_path = File.join(TMP_UPLOAD_PATH, id)
direct_upload = ObjectStorage::DirectUpload.new(self.object_store_config, upload_path,
- has_length: has_length, maximum_size: maximum_size)
+ has_length: has_length, maximum_size: maximum_size, skip_delete: use_final_store_path)
direct_upload.to_hash.merge(ID: id)
end
+
+ def prepare_pending_direct_upload(path)
+ ObjectStorage::PendingDirectUpload.prepare(
+ storage_location_identifier,
+ path
+ )
+ end
end
class OpenFile
@@ -277,8 +328,7 @@ module ObjectStorage
end
# Set ACL of uploaded objects to not-public (fog-aws)[1] or no ACL at all
- # (fog-google). Value is ignored by other supported backends (fog-aliyun,
- # fog-openstack, fog-rackspace)
+ # (fog-google). Value is ignored by fog-aliyun
# [1]: https://github.com/fog/fog-aws/blob/daa50bb3717a462baf4d04d0e0cbfc18baacb541/lib/fog/aws/models/storage/file.rb#L152-L159
def fog_public
nil
@@ -305,13 +355,16 @@ module ObjectStorage
def store_path(*args)
if self.object_store == Store::REMOTE
+ path = direct_upload_final_path
+ path ||= super
+
# We allow administrators to create "sub buckets" by setting a prefix.
# This makes it possible to deploy GitLab with only one object storage
# bucket. Because the prefix is configuration data we do not want to
# store it in the uploads table via RecordsUploads. That means that the
# prefix cannot be part of store_dir. This is why we chose to implement
# the prefix support here in store_path.
- File.join([self.class.object_store_options.bucket_prefix, super].compact)
+ self.class.with_bucket_prefix(path)
else
super
end
@@ -336,7 +389,7 @@ module ObjectStorage
def store!(new_file = nil)
# when direct upload is enabled, always store on remote storage
- if self.class.object_store_enabled? && self.class.direct_upload_enabled?
+ if self.class.direct_upload_to_object_store?
self.object_store = Store::REMOTE
end
@@ -347,12 +400,44 @@ module ObjectStorage
"object_storage_migrate:#{model.class}:#{model.id}"
end
+ override :delete_tmp_file_after_storage
+ def delete_tmp_file_after_storage
+ # If final path is present then the file is not on temporary location
+ # so we don't want carrierwave to delete it.
+ return false if direct_upload_final_path.present?
+
+ super
+ end
+
+ def retrieve_from_store!(identifier)
+ # We need to force assign the value of @filename so that we will still
+ # get the original_filename in cases wherein the file points to a random generated
+ # path format. This happens for direct uploaded files to final location.
+ #
+ # If we don't set @filename value here, the result of uploader.filename (see ObjectStorage#filename) will result
+ # to the value of uploader.file.filename which will then contain the random generated path.
+ # The `identifier` variable contains the value of the `file` column which is the original_filename.
+ #
+ # In cases wherein we are not uploading to final location, it is still fine to set the
+ # @filename with the `identifier` value because it still contains the original filename from the `file` column,
+ # which is what we want in either case.
+ @filename = identifier # rubocop: disable Gitlab/ModuleWithInstanceVariables
+
+ super
+ end
+
private
def cache_remote_file!(remote_object_id, original_filename)
- file_path = File.join(TMP_UPLOAD_PATH, remote_object_id)
- file_path = Pathname.new(file_path).cleanpath.to_s
- raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/')
+ if ObjectStorage::PendingDirectUpload.exists?(self.class.storage_location_identifier, remote_object_id) # rubocop:disable CodeReuse/ActiveRecord
+ # This is an assumption that a model with matching pending direct upload will have this attribute
+ model.write_attribute(direct_upload_final_path_attribute_name, remote_object_id)
+ file_path = self.class.with_bucket_prefix(remote_object_id)
+ else
+ file_path = File.join(TMP_UPLOAD_PATH, remote_object_id)
+ file_path = Pathname.new(file_path).cleanpath.to_s
+ raise RemoteStoreError, 'Bad file path' unless file_path.start_with?(TMP_UPLOAD_PATH + '/')
+ end
# TODO:
# This should be changed to make use of `tmp/cache` mechanism
@@ -399,7 +484,7 @@ module ObjectStorage
when Store::REMOTE
raise "Object Storage is not enabled for #{self.class}" unless self.class.object_store_enabled?
- CarrierWave::Storage::Fog.new(self)
+ DirectUploadStorage.new(self)
when Store::LOCAL
CarrierWave::Storage::File.new(self)
else
@@ -465,6 +550,14 @@ module ObjectStorage
cache_storage.delete_dir!(cache_path(nil))
end
end
+
+ def direct_upload_final_path_attribute_name
+ "#{mounted_as}_final_path"
+ end
+
+ def direct_upload_final_path
+ model.try(direct_upload_final_path_attribute_name)
+ end
end
ObjectStorage::Concern.include_mod_with('ObjectStorage::Concern')
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/packages/composer/cache_uploader.rb b/app/uploaders/packages/composer/cache_uploader.rb
index ad7c017c4ba..ef581b5d6a1 100644
--- a/app/uploaders/packages/composer/cache_uploader.rb
+++ b/app/uploaders/packages/composer/cache_uploader.rb
@@ -2,7 +2,7 @@
class Packages::Composer::CacheUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.packages
+ storage_location :packages
alias_method :upload, :model
diff --git a/app/uploaders/packages/debian/component_file_uploader.rb b/app/uploaders/packages/debian/component_file_uploader.rb
index 2de4743d7f7..b1ed8d853f1 100644
--- a/app/uploaders/packages/debian/component_file_uploader.rb
+++ b/app/uploaders/packages/debian/component_file_uploader.rb
@@ -3,7 +3,7 @@ class Packages::Debian::ComponentFileUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
- storage_options Gitlab.config.packages
+ storage_location :packages
alias_method :upload, :model
diff --git a/app/uploaders/packages/debian/distribution_release_file_uploader.rb b/app/uploaders/packages/debian/distribution_release_file_uploader.rb
index 268d42796e9..fe10861b77f 100644
--- a/app/uploaders/packages/debian/distribution_release_file_uploader.rb
+++ b/app/uploaders/packages/debian/distribution_release_file_uploader.rb
@@ -3,7 +3,7 @@ class Packages::Debian::DistributionReleaseFileUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
- storage_options Gitlab.config.packages
+ storage_location :packages
alias_method :upload, :model
diff --git a/app/uploaders/packages/npm/metadata_cache_uploader.rb b/app/uploaders/packages/npm/metadata_cache_uploader.rb
new file mode 100644
index 00000000000..75a3a94c0b4
--- /dev/null
+++ b/app/uploaders/packages/npm/metadata_cache_uploader.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class MetadataCacheUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ FILENAME = 'metadata.json'
+
+ storage_location :packages
+
+ alias_method :upload, :model
+
+ def filename
+ FILENAME
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ raise ObjectNotReadyError, 'Packages::Npm::MetadataCache model not ready' unless model.object_storage_key
+
+ model.object_storage_key
+ end
+ end
+ end
+end
diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb
index c8a09c50dc6..57feee9f19d 100644
--- a/app/uploaders/packages/package_file_uploader.rb
+++ b/app/uploaders/packages/package_file_uploader.rb
@@ -3,7 +3,7 @@ class Packages::PackageFileUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
- storage_options Gitlab.config.packages
+ storage_location :packages
alias_method :upload, :model
diff --git a/app/uploaders/packages/rpm/repository_file_uploader.rb b/app/uploaders/packages/rpm/repository_file_uploader.rb
index f95f861585c..399e9fa07d5 100644
--- a/app/uploaders/packages/rpm/repository_file_uploader.rb
+++ b/app/uploaders/packages/rpm/repository_file_uploader.rb
@@ -4,7 +4,7 @@ module Packages
class RepositoryFileUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.packages
+ storage_location :packages
alias_method :upload, :model
diff --git a/app/uploaders/pages/deployment_uploader.rb b/app/uploaders/pages/deployment_uploader.rb
index c5ba65673ab..bb4f1a2235d 100644
--- a/app/uploaders/pages/deployment_uploader.rb
+++ b/app/uploaders/pages/deployment_uploader.rb
@@ -4,7 +4,7 @@ module Pages
class DeploymentUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.pages
+ storage_location :pages
alias_method :upload, :model
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/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb
index 61e7ed7b0e6..5fe3048f7b0 100644
--- a/app/uploaders/terraform/state_uploader.rb
+++ b/app/uploaders/terraform/state_uploader.rb
@@ -4,7 +4,7 @@ module Terraform
class StateUploader < GitlabUploader
include ObjectStorage::Concern
- storage_options Gitlab.config.terraform_state
+ storage_location :terraform_state
# TODO: Remove this line
# See https://gitlab.com/gitlab-org/gitlab/-/issues/232917
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
index c6d9bd73566..3e6ec0b6f29 100644
--- a/app/validators/addressable_url_validator.rb
+++ b/app/validators/addressable_url_validator.rb
@@ -31,6 +31,8 @@
# * <tt>allow_blank</tt> - Allow urls to be +blank+. Default: +false+
# * <tt>allow_nil</tt> - Allow urls to be +nil+. Default: +false+
# * <tt>ports</tt> - Allowed ports. Default: +all+.
+# * <tt>deny_all_requests_except_allowed</tt> - Deny all requests. Default: Respects the instance app setting.
+# Note: Regardless of whether enforced during validation, an HTTP request that uses the URI may still be blocked.
# * <tt>enforce_user</tt> - Validate user format. Default: +false+
# * <tt>enforce_sanitization</tt> - Validate that there are no html/css/js tags. Default: +false+
#
@@ -54,6 +56,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator
allow_localhost: true,
allow_local_network: true,
ascii_only: false,
+ deny_all_requests_except_allowed: Gitlab::UrlBlocker::DENY_ALL_REQUESTS_EXCEPT_ALLOWED_DEFAULT,
enforce_user: false,
enforce_sanitization: false,
dns_rebind_protection: false
diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb
index 4896c2ea2ef..9c246a114f6 100644
--- a/app/validators/json_schema_validator.rb
+++ b/app/validators/json_schema_validator.rb
@@ -25,6 +25,7 @@ class JsonSchemaValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
value = value.to_h.stringify_keys if options[:hash_conversion] == true
+ value = Gitlab::Json.parse(value.to_s) if options[:parse_json] == true && !value.nil?
unless valid_schema?(value)
record.errors.add(attribute, _("must be a valid json schema"))
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/google_service_account_key.json b/app/validators/json_schemas/google_service_account_key.json
new file mode 100644
index 00000000000..d040ef19f66
--- /dev/null
+++ b/app/validators/json_schemas/google_service_account_key.json
@@ -0,0 +1,48 @@
+{
+ "description": "Google service account key",
+ "type": "object",
+ "required": [
+ "type",
+ "project_id",
+ "private_key_id",
+ "private_key",
+ "client_email",
+ "client_id",
+ "auth_uri",
+ "token_uri",
+ "auth_provider_x509_cert_url",
+ "client_x509_cert_url"
+ ],
+ "properties": {
+ "type": {
+ "const": "service_account"
+ },
+ "project_id": {
+ "type": "string"
+ },
+ "private_key_id": {
+ "type": "string"
+ },
+ "private_key": {
+ "type": "string"
+ },
+ "client_email": {
+ "type": "string"
+ },
+ "client_id": {
+ "type": "string"
+ },
+ "auth_uri": {
+ "type": "string"
+ },
+ "token_uri": {
+ "type": "string"
+ },
+ "auth_provider_x509_cert_url": {
+ "type": "string"
+ },
+ "client_x509_cert_url": {
+ "type": "string"
+ }
+ }
+}
diff --git a/app/validators/json_schemas/import_failure_external_identifiers.json b/app/validators/json_schemas/import_failure_external_identifiers.json
new file mode 100644
index 00000000000..19d4e51ad21
--- /dev/null
+++ b/app/validators/json_schemas/import_failure_external_identifiers.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Import failure external identifiers",
+ "type": "object",
+ "maxProperties": 4,
+ "patternProperties": {
+ ".*": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ]
+ }
+ }
+}
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/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index eeedd58ec15..aa5543700a7 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -7,30 +7,33 @@
- if user
= link_to user.name, user
.light.small
- = _('Joined %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(user.created_at) }
+ = html_escape(_('Joined %{time_ago}')) % { time_ago: time_ago_with_tooltip(user.created_at).html_safe }
- else
= _('(removed)')
%td
- %strong.subheading.d-block.d-sm-none
- = _('Reported by %{reporter}').html_safe % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') }
- .light.gl-display-none.gl-sm-display-block
- = link_to(reporter.name, reporter)
- .light.small
- = time_ago_with_tooltip(abuse_report.created_at)
+ - if reporter
+ %strong.subheading.d-block.d-sm-none
+ = _('Reported by %{reporter}').html_safe % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') }
+ .light.gl-display-none.gl-sm-display-block
+ = link_to(reporter.name, reporter)
+ .light.small
+ = time_ago_with_tooltip(abuse_report.created_at)
+ - else
+ = _('(removed)')
%td
%strong.subheading.d-block.d-sm-none
= _('Message')
.message
= markdown_field(abuse_report, :message)
%td
- - if user
- = render Pajamas::ButtonComponent.new(href: admin_abuse_report_path(abuse_report, remove_user: true), variant: :danger, block: true, button_options: { data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger", remote: true, method: :delete }, class: "js-remove-tr gl-mb-5" }) do
+ - if user && user != current_user
+ = render Pajamas::ButtonComponent.new(href: admin_abuse_report_path(abuse_report, remove_user: true), variant: :danger, block: true, button_options: { data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger", remote: true, method: :delete }, class: "js-remove-tr" }) do
= _('Remove user & report')
- - if user && !user.blocked?
- = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put }, class: "gl-mb-5" }) do
- = _('Block user')
- - else
- = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, disabled: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put }, class: "gl-mb-5" }) do
- = _('Already blocked')
- = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do
- = _('Remove report')
+ - if user.blocked?
+ = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, disabled: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do
+ = _('Already blocked')
+ - else
+ = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put } }) do
+ = _('Block user')
+ = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do
+ = _('Remove report')
diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml
index 20499a2e3bf..fee3a846849 100644
--- a/app/views/admin/abuse_reports/index.html.haml
+++ b/app/views/admin/abuse_reports/index.html.haml
@@ -2,31 +2,35 @@
%h1.page-title.gl-font-size-h-display= _('Abuse Reports')
-.row-content-block.second-block
- = form_tag admin_abuse_reports_path, method: :get, class: 'filter-form' do
- .filter-categories.flex-fill
- .filter-item.inline
- = dropdown_tag(user_dropdown_label(params[:user_id], 'User'),
- options: { toggle_class: 'js-filter-submit js-user-search',
- title: _('Filter by user'), filter: true, filterInput: 'input#user-search',
- dropdown_class: 'dropdown-menu-selectable dropdown-menu-user js-filter-submit',
- placeholder: _('Search users'),
- data: { current_user: true, field_name: 'user_id' }})
+- if Feature.enabled?(:abuse_reports_list)
+ #js-abuse-reports-list-app{ data: abuse_reports_list_data(@abuse_reports) }
+ = gl_loading_icon(css_class: 'gl-my-5', size: 'md')
+- else
+ .row-content-block.second-block
+ = form_tag admin_abuse_reports_path, method: :get, class: 'filter-form' do
+ .filter-categories.flex-fill
+ .filter-item.inline
+ = dropdown_tag(user_dropdown_label(params[:user_id], 'User'),
+ options: { toggle_class: 'js-filter-submit js-user-search',
+ title: _('Filter by user'), filter: true, filterInput: 'input#user-search',
+ dropdown_class: 'dropdown-menu-selectable dropdown-menu-user js-filter-submit',
+ placeholder: _('Search users'),
+ data: { current_user: true, field_name: 'user_id' }})
-.abuse-reports
- - if @abuse_reports.present?
- .table-holder
- %table.table.responsive-table
- %thead.d-none.d-md-table-header-group
- %tr
- %th= _('User')
- %th= _('Reported by')
- %th.wide= _('Message')
- %th= _('Action')
- = render @abuse_reports
- = paginate @abuse_reports, theme: 'gitlab'
- - else
- .empty-state
- .text-center
- %h4= _("There are no abuse reports!")
- %h3= emoji_icon('tada')
+ .abuse-reports
+ - if @abuse_reports.present?
+ .table-holder
+ %table.table.responsive-table
+ %thead.d-none.d-md-table-header-group
+ %tr
+ %th= _('User')
+ %th= _('Reported by')
+ %th.wide= _('Message')
+ %th= _('Action')
+ = render @abuse_reports
+ = paginate @abuse_reports, theme: 'gitlab'
+ - else
+ .empty-state
+ .text-center
+ %h4= _("There are no abuse reports!")
+ %h3= emoji_icon('tada')
diff --git a/app/views/admin/abuse_reports/show.html.haml b/app/views/admin/abuse_reports/show.html.haml
new file mode 100644
index 00000000000..bd7a1054b5d
--- /dev/null
+++ b/app/views/admin/abuse_reports/show.html.haml
@@ -0,0 +1,6 @@
+- add_to_breadcrumbs _('Abuse Reports'), admin_abuse_reports_path
+- breadcrumb_title @abuse_report.user&.name
+- page_title @abuse_report.user&.name, _('Abuse Reports')
+
+#js-abuse-reports-detail-view{ data: abuse_report_data(@abuse_report) }
+ = gl_loading_icon(css_class: 'gl-my-5', size: 'md')
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 87c251aa10c..df08a1123c7 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -30,6 +30,11 @@
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.')
+ .form-group
+ = f.label :remember_me_enabled, _('Remember me'), class: 'label-light'
+ - remember_me_help_link = help_page_path('user/profile/index.md', anchor: 'stay-signed-in-for-two-weeks')
+ - remember_me_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: remember_me_help_link }
+ = f.gitlab_ui_checkbox_component :remember_me_enabled, _('Allow users to extend their session'), help_text: _("Users can select 'Remember me' on sign-in to keep their session active beyond the session duration. %{link_start}Learn more.%{link_end}").html_safe % { link_start: remember_me_help_link_start, link_end: '</a>'.html_safe }
= render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f
= render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 8fafa52cd4c..0c9d5a5a8df 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -46,6 +46,11 @@
.form-group
= f.gitlab_ui_checkbox_component :protected_ci_variables, s_('AdminSettings|Protect CI/CD variables by default'), help_text: s_('AdminSettings|New CI/CD variables in projects and groups default to protected.')
.form-group
+ = f.label :ci_max_includes, s_('AdminSettings|Maximum includes'), class: 'label-bold'
+ = f.number_field :ci_max_includes, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = s_('AdminSettings|The maximum number of included files per pipeline.')
+ .form-group
= f.label :ci_config_path, _('Default CI/CD configuration file'), class: 'label-bold'
= f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
@@ -77,31 +82,28 @@
%fieldset
= f.hidden_field(:plan_id, value: plan.id)
.form-group
- = f.label :ci_pipeline_size, s_('AdminSettings|Maximum number of jobs in a single pipeline')
+ = f.label :ci_pipeline_size, plan_limit_setting_description(:ci_pipeline_size)
= f.number_field :ci_pipeline_size, class: 'form-control gl-form-input'
.form-group
- = f.label :ci_active_jobs, s_('AdminSettings|Total number of jobs in currently active pipelines')
+ = f.label :ci_active_jobs, plan_limit_setting_description(:ci_active_jobs)
= f.number_field :ci_active_jobs, class: 'form-control gl-form-input'
.form-group
- = f.label :ci_active_pipelines, s_('AdminSettings|Maximum number of active pipelines per project')
- = f.number_field :ci_active_pipelines, class: 'form-control gl-form-input'
- .form-group
- = f.label :ci_project_subscriptions, s_('AdminSettings|Maximum number of pipeline subscriptions to and from a project')
+ = f.label :ci_project_subscriptions, plan_limit_setting_description(:ci_project_subscriptions)
= f.number_field :ci_project_subscriptions, class: 'form-control gl-form-input'
.form-group
- = f.label :ci_pipeline_schedules, s_('AdminSettings|Maximum number of pipeline schedules')
+ = f.label :ci_pipeline_schedules, plan_limit_setting_description(:ci_pipeline_schedules)
= f.number_field :ci_pipeline_schedules, class: 'form-control gl-form-input'
.form-group
- = f.label :ci_needs_size_limit, s_('AdminSettings|Maximum number of DAG dependencies that a job can have')
+ = f.label :ci_needs_size_limit, plan_limit_setting_description(:ci_needs_size_limit)
= f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input'
.form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.')
.form-group
- = f.label :ci_registered_group_runners, s_('AdminSettings|Maximum number of runners registered per group')
+ = f.label :ci_registered_group_runners, plan_limit_setting_description(:ci_registered_group_runners)
= f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input'
.form-group
- = f.label :ci_registered_project_runners, s_('AdminSettings|Maximum number of runners registered per project')
+ = f.label :ci_registered_project_runners, plan_limit_setting_description(:ci_registered_project_runners)
= f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input'
.form-group
- = f.label :pipeline_hierarchy_size, s_("AdminSettings|Maximum number of downstream pipelines in a pipeline's hierarchy tree")
+ = f.label :pipeline_hierarchy_size, plan_limit_setting_description(:pipeline_hierarchy_size)
= f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input'
= f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index 9ec4afec484..669c47bafba 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -20,7 +20,7 @@
= _('Default language')
= f.select :default_preferred_language, default_preferred_language_choices, {}, class: 'gl-form-select custom-select'
.form-text.text-muted
- = s_('Default language for users who are not logged in.')
+ = _('Default language for users who are not logged in.')
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 1821c8ef4bb..8cb25627dfa 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -1,25 +1,36 @@
+- deny_all_requests = @application_setting.deny_all_requests_except_allowed
+
= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-outbound-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
.form-group
+ = f.gitlab_ui_checkbox_component :deny_all_requests_except_allowed,
+ s_('OutboundRequests|Block all requests, except for IP addresses, IP ranges, and domain names defined in the allowlist'),
+ checkbox_options: { class: 'js-deny-all-requests' }
+ = render Pajamas::AlertComponent.new(variant: :warning,
+ dismissible: false,
+ alert_options: { class: "gl-mb-3 js-deny-all-requests-warning #{'gl-display-none' unless deny_all_requests}" }) do |c|
+ - c.with_body do
+ = s_('OutboundRequests|Webhooks and integrations might not work properly.')
= f.gitlab_ui_checkbox_component :allow_local_requests_from_web_hooks_and_services,
- s_('OutboundRequests|Allow requests to the local network from web hooks and services'),
- checkbox_options: { data: { qa_selector: 'allow_requests_from_services_checkbox' } }
+ s_('OutboundRequests|Allow requests to the local network from webhooks and integrations'),
+ checkbox_options: { disabled: deny_all_requests, class: 'js-allow-local-requests', data: { qa_selector: 'allow_requests_from_services_checkbox' } }
= f.gitlab_ui_checkbox_component :allow_local_requests_from_system_hooks,
- s_('OutboundRequests|Allow requests to the local network from system hooks')
+ s_('OutboundRequests|Allow requests to the local network from system hooks'),
+ checkbox_options: { disabled: deny_all_requests, class: 'js-allow-local-requests' }
.form-group
= f.label :outbound_local_requests_allowlist_raw, class: 'label-bold' do
- = s_('OutboundRequests|Local IP addresses and domain names that hooks and services may access')
+ = s_('OutboundRequests|Local IP addresses and domain names that hooks and integrations can access')
= f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1, xn--itlab-j1a.com", class: 'form-control gl-form-input', rows: 8
%span.form-text.text-muted
- = s_('OutboundRequests|Requests to these domains and IP addresses are accessible to both system hooks and web hooks even when local requests are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 and 127.0.0.0/28 are supported. Domain wildcards are not supported. To separate entries use commas, semicolons, or newlines. The allowlist can hold a maximum of 1000 entries. Domains must be IDNA encoded.')
- = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'create-an-allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer'
+ = s_('OutboundRequests|Requests can be made to these IP addresses and domains even when local requests are not allowed. IP ranges such as %{code_start}1:0:0:0:0:0:0:0/124%{code_end} and %{code_start}127.0.0.0/28%{code_end} are supported. Domain wildcards are not supported. To separate entries, use commas, semicolons, or newlines. The allowlist can have a maximum of 1000 entries. Domains must be IDNA-encoded.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
+ = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'allow-outbound-requests-to-certain-ip-addresses-and-domains'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled,
- s_('OutboundRequests|Enforce DNS rebinding attack protection'),
- help_text: s_('OutboundRequests|Resolve IP addresses once and uses them to submit requests.')
+ s_('OutboundRequests|Enforce DNS-rebinding attack protection'),
+ help_text: s_('OutboundRequests|Resolve IP addresses for outbound requests to prevent DNS-rebinding attacks.')
= f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml
new file mode 100644
index 00000000000..4efab4d77a9
--- /dev/null
+++ b/app/views/admin/application_settings/_projects_api_limits.html.haml
@@ -0,0 +1,21 @@
+%section.settings.as-projects-api-limits.no-animate#js-projects-api-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('Projects API rate limit')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Set the per-IP address rate limit applicable to unauthenticated requests for getting a list of projects via the API.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-projects-api-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :projects_api_rate_limit_unauthenticated, _('Maximum requests per 10 minutes per IP address'), class: 'label-bold'
+ = f.number_field :projects_api_rate_limit_unauthenticated, class: 'form-control gl-form-input'
+ .form-text.gl-text-gray-600
+ = _("Set this number to 0 to disable the limit.")
+
+ = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
index 6a7ec05d206..2b8b023baea 100644
--- a/app/views/admin/application_settings/_realtime.html.haml
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-realtime-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-realtime-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -8,4 +8,4 @@
.form-text.text-muted
= _('Multiplier to apply to polling intervals. Decimal values are supported. Defaults to 1.')
- = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), pajamas_button: true
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/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index b67cc29f296..5751ae9059a 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -13,7 +13,9 @@
= _("If you get a lot of false alarms from repository checks, you can clear all repository check information from the database.")
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This clears repository check states for all projects in the database and cannot be undone. Are you sure?')
- = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message, confirm_btn_variant: 'danger' }, aria: { label: _('Clear repository checks') }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3"
+ = render Pajamas::ButtonComponent.new(variant: :danger, href: clear_repository_check_states_admin_application_settings_path, method: :put, button_options: { class: 'btn-sm gl-mt-3', data: { confirm: clear_repository_checks_message, confirm_btn_variant: 'danger' }, aria: { label: _('Clear repository checks') } }) do
+ = clear_repository_checks_link
+
.sub-section
%h4= _("Housekeeping")
diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml
index baf7c5de7b9..53832e93ed2 100644
--- a/app/views/admin/application_settings/_runner_registrars_form.html.haml
+++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml
@@ -2,7 +2,18 @@
= form_errors(@application_setting)
%fieldset
+ .form-group
+ %h5
+ = s_('Runners|Runner version management')
+ %span.form-text.gl-mb-3.gl-mt-0
+ - help_text = s_('Runners|Official runner version data is periodically fetched from GitLab.com to determine whether the runners need upgrades.')
+ - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/configure_runners.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer'
+ = f.gitlab_ui_checkbox_component :update_runner_versions_enabled,
+ s_('Runners|Fetch GitLab Runner release version data from GitLab.com'),
+ help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link }
.gl-form-group
+ %h5
+ = s_('Runners|Runner registration')
%span.form-text.gl-mb-3.gl-mt-0
= s_('Runners|If both settings are disabled, new runners cannot be registered.')
= link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/_slack.html.haml b/app/views/admin/application_settings/_slack.html.haml
new file mode 100644
index 00000000000..69a5e284b4c
--- /dev/null
+++ b/app/views/admin/application_settings/_slack.html.haml
@@ -0,0 +1,33 @@
+- return unless Gitlab.dev_or_test_env? || Gitlab.com?
+
+- expanded = integration_expanded?('slack_app_')
+%section.settings.as-slack.no-animate#js-slack-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('Slack application')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Slack integration allows you to interact with GitLab via slash commands in a chat window.')
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-slack-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting) if expanded
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :slack_app_enabled, s_('ApplicationSettings|Enable Slack application'),
+ help_text: s_('ApplicationSettings|This option is only available on GitLab.com')
+ .form-group
+ = f.label :slack_app_id, s_('SlackIntegration|Client ID'), class: 'label-bold'
+ = f.text_field :slack_app_id, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :slack_app_secret, s_('SlackIntegration|Client secret'), class: 'label-bold'
+ = f.text_field :slack_app_secret, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :slack_app_signing_secret, s_('SlackIntegration|Signing secret'), class: 'label-bold'
+ = f.text_field :slack_app_signing_secret, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :slack_app_verification_token, s_('SlackIntegration|Verification token'), class: 'label-bold'
+ = f.text_field :slack_app_verification_token, class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index 0305a9487ca..7142128d2cd 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -28,16 +28,16 @@
%span.form-text.gl-mt-0.gl-mb-3#import-sources-help
= _('Code can be imported from enabled sources during project creation. OmniAuth must be configured for GitHub')
= link_to sprite_icon('question-o'), help_page_path("integration/github")
- , Bitbucket
+ and Bitbucket
= link_to sprite_icon('question-o'), help_page_path("integration/bitbucket")
- and GitLab.com
- = link_to sprite_icon('question-o'), help_page_path("integration/gitlab")
= hidden_field_tag 'application_setting[import_sources][]'
- import_sources_checkboxes(f).each do |source|
= source
= render_if_exists 'admin/application_settings/ldap_access_setting', form: f
+ = render_if_exists 'admin/application_settings/saml_group_locks_setting', form: f
+
.form-group{ data: { testid: 'project-export' } }
= f.label :project_export, s_('AdminSettings|Project export'), class: 'label-bold'
= f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Enabled')
diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml
index 6c6334905ca..1b0e974a0ca 100644
--- a/app/views/admin/application_settings/appearances/_form.html.haml
+++ b/app/views/admin/application_settings/appearances/_form.html.haml
@@ -16,7 +16,8 @@
= image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: header_logos_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Header logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove header logo') } }) do
+ = _('Remove header logo')
%hr
= f.hidden_field :header_logo_cache
= f.file_field :header_logo, class: "", accept: 'image/*'
@@ -35,7 +36,8 @@
= image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: favicon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Favicon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove favicon') } }) do
+ = _('Remove favicon')
%hr
= f.hidden_field :favicon_cache
= f.file_field :favicon, class: '', accept: 'image/*'
@@ -67,7 +69,8 @@
= image_tag @appearance.logo_path, class: 'appearance-logo-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo"
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: logo_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Logo will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove logo') } }) do
+ = _('Remove logo')
%hr
= f.hidden_field :logo_cache
= f.file_field :logo, class: "", accept: 'image/*'
@@ -98,7 +101,8 @@
= image_tag @appearance.pwa_icon_path, class: 'appearance-pwa-icon-preview'
- if @appearance.persisted?
%br
- = link_to _('Remove icon'), pwa_icon_admin_application_settings_appearances_path, data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo"
+ = render Pajamas::ButtonComponent.new(variant: :danger, category: :secondary, size: :small, method: :delete, href: pwa_icon_admin_application_settings_appearances_path, button_options: { data: { confirm: _("Icon will be removed. Are you sure?"), confirm_btn_variant: "danger" }, aria: { label: _('Remove icon') } }) do
+ = _('Remove icon')
%hr
= f.hidden_field :pwa_icon_cache
= f.file_field :pwa_icon, class: "", accept: 'image/*'
diff --git a/app/views/admin/application_settings/appearances/show.html.haml b/app/views/admin/application_settings/appearances/show.html.haml
index 1e55190d53b..82fdd13672a 100644
--- a/app/views/admin/application_settings/appearances/show.html.haml
+++ b/app/views/admin/application_settings/appearances/show.html.haml
@@ -1,5 +1,5 @@
- page_title _("Appearance")
-- @content_class = "limit-container-width" unless fluid_layout
- add_page_specific_style 'page_bundles/settings'
+- @force_desktop_expanded_sidebar = true
= render 'form'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index bd0ce766f81..a9a16f72ebe 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("CI/CD")
- page_title _("CI/CD")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.no-animate#js-ci-cd-variables{ class: ('expanded' if expanded_by_default?) }
.settings-header
@@ -42,7 +42,7 @@
%section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = s_('Runners|Runner registration')
+ = s_('Runners|Runners')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? 'Collapse' : 'Expand'
.settings-content
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index a4af1913d22..e6c27c1bc84 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("General")
- page_title _("General")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'admin-visibility-access-settings' } }
.settings-header
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index fd1ad5cd304..68c62eb98ee 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title s_('Integrations|Instance-level integration management')
- page_title s_('Integrations|Instance-level integration management')
- add_page_specific_style 'page_bundles/settings'
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%h3= s_('Integrations|Instance-level integration management')
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index b5981578866..8bc5d5cbaa6 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -3,7 +3,7 @@
- breadcrumb_title _("Metrics and profiling")
- page_title _("Metrics and profiling")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
@@ -41,8 +41,6 @@
.settings-content
= render 'performance_bar'
-.js-self-monitoring-settings{ data: self_monitoring_project_data }
-
%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } }
.settings-header#usage-statistics
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 779263b439f..18ce7c1ceba 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("Network")
- page_title _("Network")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
@@ -92,7 +92,7 @@
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = s_('OutboundRequests|Allow requests to the local network from hooks and services.')
+ = s_('OutboundRequests|Allow requests to the local network from hooks and integrations.')
= link_to _('Learn more.'), help_page_path('security/webhooks.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'outbound'
@@ -146,6 +146,8 @@
.settings-content
= render 'users_api_limits'
+= render 'projects_api_limits'
+
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index dd6666542ca..ab59e05c10f 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("Preferences")
- page_title _("Preferences")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } }
.settings-header
diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml
index 3d803e95cd0..6ea2fb80505 100644
--- a/app/views/admin/application_settings/reporting.html.haml
+++ b/app/views/admin/application_settings/reporting.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("Reporting")
- page_title _("Reporting")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index 50798ad476c..1907544ea14 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("Repository")
- page_title _("Repository")
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
index d6860cc08ac..e42c1091bf2 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -3,8 +3,8 @@
- breadcrumb_title name
- page_title name
- add_page_specific_style 'page_bundles/settings'
-- @content_class = "limit-container-width" unless fluid_layout
- payload_class = 'js-service-ping-payload'
+- @force_desktop_expanded_sidebar = true
%section.js-search-settings-section
%h3= name
@@ -22,7 +22,7 @@
dismissible: false,
title: _('Service Ping payload not found in the application cache')) do |c|
- = c.body do
+ - c.with_body do
- enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
- enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url }
- generate_manually_link_url = help_page_path('development/service_ping/troubleshooting', anchor: 'generate-service-ping')
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..e32a50e252d 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -10,17 +10,17 @@
- 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
- = s_('New application')
+ = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
+ = _('New application')
- else
%hr
- = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do
- = s_('New application')
+ = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm, button_options: { data: { qa_selector: 'new_application_button' } }) do
+ = _('New application')
.table-responsive
%table.b-table.gl-table.gl-w-full{ role: 'table' }
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 212e3eeb951..b93a3c5d7fe 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -7,4 +7,5 @@
edit_path: edit_admin_application_path(@application),
delete_path: admin_application_path(@application),
index_path: admin_applications_path,
+ renew_path: renew_admin_application_path(@application),
show_trusted_row: true
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index 0f76fdce416..9550ea2884e 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -5,7 +5,7 @@
.gl-flex-grow-1
%h3= s_('BackgroundMigrations|Background Migrations')
%p.light.gl-mb-0
- - learnmore_link = help_page_path('user/admin_area/monitoring/background_migrations')
+ - learnmore_link = help_page_path('update/background_migrations')
- learnmore_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learnmore_link }
= html_escape(s_('BackgroundMigrations|Background migrations are used to perform data migrations whenever a migration exceeds the time limits in our guidelines. %{linkStart}Learn more%{linkEnd}')) % { linkStart: learnmore_link_start, linkEnd: '</a>'.html_safe }
@@ -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/broadcast_messages/_preview.html.haml b/app/views/admin/broadcast_messages/_preview.html.haml
deleted file mode 100644
index 56168926a6e..00000000000
--- a/app/views/admin/broadcast_messages/_preview.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-.js-broadcast-banner-message-preview
- = render "shared/broadcast_message", { message: @broadcast_message, preview: true } do
- = _('Your message here')
diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml
index 212cc437d3d..63ce08eef85 100644
--- a/app/views/admin/broadcast_messages/edit.html.haml
+++ b/app/views/admin/broadcast_messages/edit.html.haml
@@ -2,15 +2,4 @@
- breadcrumb_title @broadcast_message.id
- page_title _("Broadcast Messages")
-#js-broadcast-message{ data: {
- id: @broadcast_message.id,
- message: @broadcast_message.message,
- broadcast_type: @broadcast_message.broadcast_type,
- theme: @broadcast_message.theme,
- dismissable: @broadcast_message.dismissable.to_s,
- target_access_levels: @broadcast_message.target_access_levels,
- target_path: @broadcast_message.target_path,
- starts_at: @broadcast_message.starts_at,
- ends_at: @broadcast_message.ends_at,
- target_access_level_options: target_access_level_options.to_json,
-} }
+#js-broadcast-message{ data: broadcast_message_data(@broadcast_message) }
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 010cc493ddf..fb63e761f69 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -9,5 +9,7 @@
#js-broadcast-messages{ data: {
page: params[:page] || 1,
target_access_level_options: target_access_level_options.to_json,
+ messages_path: admin_broadcast_messages_path,
+ preview_path: preview_admin_broadcast_messages_path,
messages_count: @broadcast_messages.total_count,
messages: admin_broadcast_messages_data(@broadcast_messages) } }
diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
index 7495298936d..0bdeef9acea 100644
--- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml
+++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml
@@ -7,8 +7,8 @@
dismiss_endpoint: callouts_path,
defer_links: 'true' }},
close_button_options: { data: { testid: 'close-security-newsletter-callout' }}) do |c|
- = c.body do
+ - c.with_body do
= s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.')
- = c.actions do
+ - c.with_actions do
= render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://about.gitlab.com/company/preference-center/', target: '_blank', button_options: { class: 'deferred-link gl-alert-action', rel: 'noreferrer noopener' }) do
= s_('AdminArea|Sign up for the GitLab newsletter')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 8afddd99451..4973c0f985c 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -26,7 +26,7 @@
footer_options: { class: 'gl-bg-transparent'} }
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new(**component_params) do |c|
- = c.body do
+ - c.with_body do
%span
.d-flex.align-items-center
= sprite_icon('project', size: 16, css_class: 'gl-text-gray-700')
@@ -34,13 +34,13 @@
.gl-mt-3.text-uppercase= s_('AdminArea|Projects')
= render Pajamas::ButtonComponent.new(href: new_project_path) do
= s_('AdminArea|New project')
- = c.footer do
+ - c.with_footer do
.d-flex.align-items-center
= link_to(s_('AdminArea|View latest projects'), admin_projects_path(sort: 'created_desc'))
= sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new(**component_params) do |c|
- = c.body do
+ - c.with_body do
%span
.d-flex.align-items-center
= sprite_icon('users', size: 16, css_class: 'gl-text-gray-700')
@@ -58,13 +58,13 @@
= link_to(s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: "text-capitalize gl-ml-2")
= render Pajamas::ButtonComponent.new(href: new_admin_user_path) do
= s_('AdminArea|New user')
- = c.footer do
+ - c.with_footer do
.d-flex.align-items-center
= link_to(s_('AdminArea|View latest users'), admin_users_path({ sort: 'created_desc' }))
= sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new(**component_params) do |c|
- = c.body do
+ - c.with_body do
%span
.d-flex.align-items-center
= sprite_icon('group', size: 16, css_class: 'gl-text-gray-700')
@@ -72,7 +72,7 @@
.gl-mt-3.text-uppercase= s_('AdminArea|Groups')
= render Pajamas::ButtonComponent.new(href: new_admin_group_path) do
= s_('AdminArea|New group')
- = c.footer do
+ - c.with_footer do
.d-flex.align-items-center
= link_to(s_('AdminArea|View latest groups'), admin_groups_path(sort: 'created_desc'))
= sprite_icon('chevron-right', size: 12, css_class: 'gl-text-gray-700 gl-ml-2')
@@ -81,7 +81,7 @@
#js-admin-statistics-container
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new do |c|
- = c.body do
+ - c.with_body do
%h4= s_('AdminArea|Features')
= feature_entry(_('Sign up'),
href: general_admin_application_settings_path(anchor: 'js-signup-settings'),
@@ -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'
@@ -120,13 +120,13 @@
enabled: Gitlab.config.gitlab_ci.shared_runners_enabled)
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new do |c|
- = c.body do
+ - c.with_body do
%h4
= s_('AdminArea|Components')
- 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
@@ -178,7 +178,7 @@
.row
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new do |c|
- = c.body do
+ - c.with_body do
%h4= s_('AdminArea|Latest projects')
- @projects.each do |project|
.gl-display-flex.gl-py-3
@@ -188,7 +188,7 @@
#{time_ago_with_tooltip(project.created_at)}
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new do |c|
- = c.body do
+ - c.with_body do
%h4= s_('AdminArea|Latest users')
- @users.each do |user|
.gl-display-flex.gl-py-3
@@ -199,7 +199,7 @@
#{time_ago_with_tooltip(user.created_at)}
.col-md-4.gl-mb-6
= render Pajamas::CardComponent.new do |c|
- = c.body do
+ - c.with_body do
%h4= s_('AdminArea|Latest groups')
- @groups.each do |group|
.gl-display-flex.gl-py-3
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index a2425b93ad3..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_report'
+- 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/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 20ee8c9f310..b708564e23a 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -1,11 +1,11 @@
= gitlab_ui_form_for [:admin, @group] do |f|
= form_errors(@group)
= render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-mb-6' }) do |c|
- = c.title { _('Naming, visibility') }
- = c.description do
+ - c.with_title { _('Naming, visibility') }
+ - c.with_description do
= _('Update your group name, description, avatar, and visibility.')
= link_to _('Learn more about groups.'), help_page_path('user/group/index')
- = c.body do
+ - c.with_body do
= render 'shared/groups/group_name_and_path_fields', f: f
= render 'shared/group_form_description', f: f
.form-group.gl-form-group{ role: 'group' }
@@ -14,10 +14,10 @@
= render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
= render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c|
- = c.title { _('Permissions and group features') }
- = c.description do
+ - c.with_title { _('Permissions and group features') }
+ - c.with_description do
= _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.')
- = c.body do
+ - c.with_body do
= render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
= render_if_exists 'admin/namespace_plan', f: f
.form-group.gl-form-group{ role: 'group' }
@@ -26,13 +26,13 @@
= render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f
= render ::Layouts::HorizontalSectionComponent.new(border: false, options: { class: 'gl-pb-3' }) do |c|
- = c.title { _('Admin notes') }
- = c.body do
+ - c.with_title { _('Admin notes') }
+ - c.with_body do
= render 'shared/admin/admin_note_form', f: f
- if @group.new_record?
= render Pajamas::AlertComponent.new(dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= render 'shared/group_tips'
.gl-mt-5
= f.submit _('Create group'), pajamas_button: true
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index f9ebda2bc21..20d24161c57 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -1,10 +1,9 @@
- group = local_assigns.fetch(:group)
%li.group-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'group_row_content' } }
- .avatar-container.rect-avatar.s40.gl-flex-shrink-0
- = group_icon(group, class: "avatar s40")
+ = render Pajamas::AvatarComponent.new(group, size: 32, alt: '')
- .gl-min-w-0.gl-flex-grow-1
+ .gl-min-w-0.gl-flex-grow-1.gl-ml-3
.title
= link_to [:admin, group], class: 'group-name', data: { qa_selector: 'group_name_link' } do
= group.full_name
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index c8b0704c35d..4ba69126906 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -15,13 +15,12 @@
.row
.col-md-6
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Group info:')
- - c.body do
+ - c.with_body do
%ul.content-list.content-list-items-padding
%li
- .avatar-container.rect-avatar.s60
- = group_icon(@group, class: "avatar s60")
+ = render Pajamas::AvatarComponent.new(@group, size: 64, alt: '')
%li
%span.light= _('Name:')
%strong
@@ -70,10 +69,10 @@
= render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Projects')
= gl_badge_tag @group.projects.count
- - c.body do
+ - c.with_body do
%ul.content-list.content-list-items-padding
- @projects.each do |project|
%li
@@ -83,16 +82,16 @@
%span.float-right.light
%span.monospace= project.full_path + '.git'
- unless @projects.size < Kaminari.config.default_per_page
- - c.footer do
+ - c.with_footer do
= paginate @projects, param_name: 'projects_page', theme: 'gitlab'
- shared_projects = @group.shared_projects.sort_by(&:name)
- unless shared_projects.empty?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Projects shared with %{group_name}') % { group_name: @group.name }
= gl_badge_tag shared_projects.size
- - c.body do
+ - c.with_body do
%ul.content-list.content-list-items-padding
- shared_projects.each do |project|
%li
@@ -109,11 +108,11 @@
= render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe }
= gl_badge_tag @group.users_count
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
- - c.body do
+ - c.with_body do
%ul.content-list.group-users-list.members-list
= render partial: 'shared/members/member',
collection: @members, as: :member,
@@ -121,5 +120,5 @@
group: @group,
current_user_is_group_owner: current_user_is_group_owner }
- unless @members.size < Kaminari.config.default_per_page
- - c.footer do
+ - c.with_footer do
= paginate @members, param_name: 'members_page', theme: 'gitlab'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 98427cb6419..662234bf56a 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -8,9 +8,8 @@
#{ s_('HealthCheck|Access token is') }
%code#health-check-token= Gitlab::CurrentSettings.health_check_access_token
.gl-mt-3
- = button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path,
- method: :put, class: 'gl-button btn btn-default',
- data: { confirm: _('Are you sure you want to reset the health check token?') }
+ = render Pajamas::ButtonComponent.new(href: reset_health_check_token_admin_application_settings_path, method: :put, button_options: { data: { confirm: _('Are you sure you want to reset the health check token?') } }) do
+ = _("Reset health check access token")
%p.light
#{ _('Health information can be retrieved from the following endpoints. More information is available') }
= link_to s_('More information is available|here'), help_page_path('user/admin_area/monitoring/health_check')
@@ -24,7 +23,7 @@
= render_if_exists 'admin/health_check/health_check_url'
%hr
= render Pajamas::CardComponent.new do |c|
- = c.header do
+ - c.with_header do
Current Status:
- if no_errors
= sprite_icon('check', css_class: 'cgreen')
@@ -32,7 +31,7 @@
- else
= sprite_icon('warning-solid', css_class: 'cred')
#{ s_('HealthCheck|Unhealthy') }
- = c.body do
+ - c.with_body do
- if no_errors
#{ s_('HealthCheck|No Health Problems Detected') }
- else
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index 333c865629f..f4f64eadf21 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,17 @@
-%li.label-list-item{ id: dom_id(label) }
- = render "shared/label_row", label: label.present(issuable_subject: nil)
- .label-actions-list
- = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria: { label: _('Edit') } do
- = sprite_icon('pencil')
- = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
- = sprite_icon('remove')
+%li.label-list-item.gl-list-style-none.gl-py-3{ id: dom_id(label) }
+ .label-content.gl-px-3.gl-py-2.gl-rounded-base
+ = render "shared/label_row", label: label.present(issuable_subject: nil)
+ .label-actions-list.gl-display-inline-block
+ .dropdown
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ size: :small,
+ icon: 'ellipsis_v',
+ button_options: { class: 'js-label-options-dropdown gl-ml-3', 'aria_label': _('Label actions dropdown'), title: _('Label actions dropdown'), data: { toggle: 'dropdown' } })
+ .dropdown-menu.dropdown-menu-right
+ %ul
+ %li
+ = link_to edit_admin_label_path(label), class: 'btn gl-btn label-action dropdown-item btn-link' do
+ = _('Edit')
+ %li
+ = link_to admin_label_path(label), class: 'btn gl-btn js-remove-label dropdown-item btn-link gl-text-red-500!', data: { confirm: _('Are you sure you want to delete this label?'), confirm_btn_variant: 'danger' }, aria: { label: _('Delete label') }, method: :delete, remote: true do
+ = _('Delete')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 8d6df064c3c..8680bae5207 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,33 +1,34 @@
- page_title _("Labels")
-%div
- = render Pajamas::ButtonComponent.new(variant: :confirm,
- href: new_admin_label_path,
- button_options: { class: 'float-right' }) do
- = _('New label')
- %h1.page-title.gl-font-size-h-display
- = _('Labels')
-%hr
-- if @labels.present?
- .labels.labels-container.admin-labels.js-admin-labels-container.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
- %ul.manage-labels-list
- = render @labels
+.gl-sm-display-flex.gl-border-bottom-0.gl-mt-4.gl-lg-align-items-center
+ .gl-text-gray-600.gl-flex-grow-1
+ = s_('AdminLabels|Labels created here will be automatically added to new projects.')
+ .nav-controls.gl-mt-2.gl-sm-mt-0.gl-display-flex.gl-align-items-center
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ href: new_admin_label_path) do
+ = _('New label')
- = paginate @labels, theme: 'gitlab'
+.labels.labels-container.admin-labels.js-admin-labels-container.gl-mt-4
+ .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
+ = _('Labels')
+ %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
+ - if @labels.present?
+ = render @labels
+ .js-admin-labels-empty-state{ class: ('gl-display-none' if @labels.present?) }
+ %section.row.empty-state.gl-text-center
+ .col-12
+ .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
+ = s_('AdminLabels|Define your default set of project labels')
+ %p
+ = s_('AdminLabels|They can be used to categorize issues and merge requests.')
+ .gl-display-flex.gl-flex-wrap.gl-justify-content-center
+ = render Pajamas::ButtonComponent.new(href: new_admin_label_path) do
+ = _('New label')
-.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'
- .col-12
- .gl-mx-auto.gl-my-0.gl-p-5
- %h1.gl-font-size-h-display.gl-line-height-36.h4
- = s_('AdminLabels|Define your default set of project labels')
- %p.gl-mb-0
- = s_('AdminLabels|Labels created here will be automatically added to new projects.')
- %p
- = s_('AdminLabels|They can be used to categorize issues and merge requests.')
- .gl-display-flex.gl-flex-wrap.gl-justify-content-center
- = render Pajamas::ButtonComponent.new(href: new_admin_label_path) do
- = _('New label')
+ = paginate @labels, theme: 'gitlab'
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
new file mode 100644
index 00000000000..61bf97d8214
--- /dev/null
+++ b/app/views/admin/projects/_form.html.haml
@@ -0,0 +1,38 @@
+= gitlab_ui_form_for [:admin, @project] do |f|
+ = form_errors(@project)
+ = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c|
+ - c.with_title { _('Naming') }
+ - c.with_description do
+ = _('Update your project name and description.')
+ - c.with_body do
+ .form-group.gl-form-group
+ = f.label :name, _('Project name')
+ = f.text_field :name, class: 'form-control gl-form-input gl-md-form-input-md'
+
+ .form-group.gl-form-group
+ = f.label :id, _('Project ID')
+ = f.text_field :id, class: 'form-control gl-form-input gl-md-form-input-sm', readonly: true
+
+ .form-group.gl-form-group
+ = 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.with_title { _('Permissions and project features') }
+ - c.with_description do
+ = _('Configure advanced permissions')
+ - c.with_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
+ = _('Cancel')
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index cf1bd2a8022..df1653cdd71 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -3,9 +3,8 @@
%ul.content-list
- @projects.each do |project|
%li.project-row.gl-align-items-center{ class: 'gl-display-flex!' }
- .avatar-container.rect-avatar.s40.gl-flex-shrink-0
- = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
- .gl-min-w-0.gl-flex-grow-1
+ = render Pajamas::AvatarComponent.new(project, size: 32, alt: '')
+ .gl-min-w-0.gl-flex-grow-1.gl-ml-3
.title
= link_to(admin_project_path(project)) do
%span.project-full-name
@@ -24,7 +23,7 @@
= render_if_exists 'admin/projects/archived', project: project
.controls.gl-flex-shrink-0.gl-ml-5
- = render Pajamas::ButtonComponent.new(href: edit_project_path(project), button_options: { id: dom_id(project, :edit) }) do
+ = render Pajamas::ButtonComponent.new(href: edit_admin_namespace_project_path({ id: project.to_param, namespace_id: project.namespace.to_param }), button_options: { id: dom_id(project) }) do
= _('Edit')
= render Pajamas::ButtonComponent.new(variant: :danger, button_options: { class: 'delete-project-button', data: { delete_project_url: admin_project_path(project), project_name: project.name } }) do
= s_('AdminProjects|Delete')
diff --git a/app/views/admin/projects/edit.html.haml b/app/views/admin/projects/edit.html.haml
new file mode 100644
index 00000000000..ade0f543d58
--- /dev/null
+++ b/app/views/admin/projects/edit.html.haml
@@ -0,0 +1,4 @@
+- page_title _("Edit"), @project.name, _("Projects")
+%h1.page-title.gl-font-size-h-display= _('Edit project: %{project_name}') % { project_name: @project.name }
+%hr
+= render 'form'
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 464027e73f4..8eb72fa281e 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -6,7 +6,9 @@
%h1.page-title.gl-font-size-h-display
= _('Project: %{name}') % { name: @project.full_name }
- = render Pajamas::ButtonComponent.new(href: edit_project_path(@project), icon: 'pencil', button_options: { class: 'gl-float-right' }) do
+ = render Pajamas::ButtonComponent.new(href: edit_admin_namespace_project_path({ id: @project.to_param, namespace_id: @project.namespace.to_param }),
+ icon: 'pencil',
+ button_options: { class: 'gl-float-right'}) do
= _('Edit')
%hr
- if @project.last_repository_check_failed?
@@ -15,16 +17,16 @@
= render Pajamas::AlertComponent.new(variant: :danger,
alert_options: { class: 'gl-mb-5',
data: { testid: 'last-repository-check-failed-alert' }}) do |c|
- = c.body do
+ - c.with_body do
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
- last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
= last_check_message.html_safe
.row
.col-md-6
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Project info:')
- - c.body do
+ - c.with_body do
%ul.content-list
%li{ class: 'gl-px-5!' }
%span.light
@@ -132,9 +134,9 @@
= render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project }
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c|
- - c.header do
+ - c.with_header do
= s_('ProjectSettings|Transfer project')
- - c.body do
+ - c.with_body do
= gitlab_ui_form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
.col-sm-3.col-form-label
@@ -147,9 +149,9 @@
= f.submit _('Transfer'), pajamas_button: true
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5 repository-check' }) do |c|
- - c.header do
+ - c.with_header do
= _("Repository check")
- - c.body do
+ - c.with_body do
= gitlab_ui_form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f|
.form-group
- if @project.last_repository_check_at.nil?
@@ -169,35 +171,35 @@
.col-md-6
- if @group
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c|
- - c.header do
+ - c.with_header do
%strong= @group.name
= _('group members')
= gl_badge_tag @group_members.size
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
- - c.body do
+ - c.with_body do
%ul.content-list.members-list
= render partial: 'shared/members/member',
collection: @group_members, as: :member,
locals: { membership_source: @project,
group: @group,
current_user_is_group_owner: current_user_is_group_owner }
- - c.footer do
+ - c.with_footer do
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
= render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c|
- - c.header do
+ - c.with_header do
%strong= @project.name
= _('project members')
= gl_badge_tag @project.users.size
= render 'shared/members/manage_access_button', path: project_project_members_path(@project)
- - c.body do
+ - c.with_body do
%ul.content-list.project_members.members-list
= render partial: 'shared/members/member',
collection: @project_members, as: :member,
locals: { membership_source: @project,
group: @group,
current_user_is_group_owner: current_user_is_group_owner }
- - c.footer do
+ - c.with_footer do
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/runners/edit.html.haml b/app/views/admin/runners/edit.html.haml
index e586a7a965e..3d245722270 100644
--- a/app/views/admin/runners/edit.html.haml
+++ b/app/views/admin/runners/edit.html.haml
@@ -1,4 +1,4 @@
-- runner_name = "##{@runner.id} (#{@runner.short_sha})"
+- runner_name = runner_short_name(@runner)
- breadcrumb_title _('Edit')
- page_title _('Edit'), runner_name
- add_to_breadcrumbs _('Runners'), admin_runners_path
@@ -23,7 +23,7 @@
= render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
title: project.full_name) do |c|
- = c.actions do
+ - c.with_actions do
= render Pajamas::ButtonComponent.new(variant: :confirm, href: admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete) do
= _('Disable')
diff --git a/app/views/admin/runners/new.html.haml b/app/views/admin/runners/new.html.haml
index dd93ecfcf8c..c4e87761fee 100644
--- a/app/views/admin/runners/new.html.haml
+++ b/app/views/admin/runners/new.html.haml
@@ -2,4 +2,4 @@
- breadcrumb_title s_('Runners|New')
- page_title s_('Runners|Create an instance runner')
-#js-admin-new-runner{ data: { legacy_registration_token: Gitlab::CurrentSettings.runners_registration_token } }
+#js-admin-new-runner
diff --git a/app/views/admin/runners/register.html.haml b/app/views/admin/runners/register.html.haml
new file mode 100644
index 00000000000..7aa58f9078e
--- /dev/null
+++ b/app/views/admin/runners/register.html.haml
@@ -0,0 +1,7 @@
+- runner_name = runner_short_name(@runner)
+- breadcrumb_title s_('Runners|Register')
+- page_title s_('Runners|Register'), runner_name
+- add_to_breadcrumbs _('Runners'), admin_runners_path
+- add_to_breadcrumbs runner_name, register_admin_runner_path(@runner)
+
+#js-admin-register-runner{ data: { runner_id: @runner.id, runners_path: admin_runners_path } }
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index a9dbcf4a6a5..3942c427cc4 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -1,9 +1,9 @@
- add_page_specific_style 'page_bundles/ci_status'
- add_page_specific_style 'page_bundles/runner_details'
-- title = "##{@runner.id} (#{@runner.short_sha})"
-- breadcrumb_title title
-- page_title title
+- runner_name = runner_short_name(@runner)
+- breadcrumb_title runner_name
+- page_title runner_name
- add_to_breadcrumbs _('Runners'), admin_runners_path
#js-admin-runner-show{ data: {runner_id: @runner.id, runners_path: admin_runners_path} }
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index b755b4a442c..13c647cd45f 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -1,7 +1,7 @@
= form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do
.form-group
= label_tag :user_password, _('Password'), class: 'label-bold'
- = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' }
+ = password_field_tag 'user[password]', nil, { class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } }
.submit-container.move-submit-down
- = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' }
+ = submit_tag _('Enter admin mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
index c7382266480..15005bb9224 100644
--- a/app/views/admin/sessions/_signin_box.html.haml
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -7,7 +7,7 @@
- ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
- = render 'devise/sessions/new_ldap', server: server, hide_remember_me: true, submit_message: _('Enter Admin Mode')
+ = render 'devise/sessions/new_ldap', server: server, render_remember_me: false, submit_message: _('Enter admin mode')
= render_if_exists 'devise/sessions/new_smartcard'
diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml
index 40ba79d1a65..f7b4035488d 100644
--- a/app/views/admin/sessions/_two_factor_otp.html.haml
+++ b/app/views/admin/sessions/_two_factor_otp.html.haml
@@ -1,9 +1,9 @@
-= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_webauthn_u2f_enabled?}" }) do
+= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_webauthn_enabled?}" }) do
.form-group
- = label_tag :user_otp_attempt, _('Two-Factor Authentication code')
- = text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.')
+ = label_tag :user_otp_attempt, _('Enter verification code')
+ = text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.')
%p.form-text.text-muted.hint
- = _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
+ = _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.")
.submit-container.move-submit-down
= submit_tag 'Verify code', class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 7d07b49c98e..4fc30cbaecf 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -1,10 +1,10 @@
-- @hide_breadcrumbs = true
-- page_title _('Enter Admin Mode')
+- page_title _('Enter admin mode')
+- add_page_specific_style 'page_bundles/login'
.row.justify-content-center
.col-md-5.new-session-forms-container
.login-page
- #signin-container
+ #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
- if any_form_based_providers_enabled?
= render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false
- else
@@ -19,5 +19,4 @@
= _('No authentication methods configured.')
- if omniauth_enabled? && button_based_providers_enabled?
- .clearfix
- = render 'devise/shared/omniauth_box', hide_remember_me: true
+ = render 'devise/shared/omniauth_box', render_remember_me: false
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index 3f915846dd8..3bbf768d7be 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -1,15 +1,15 @@
-- @hide_breadcrumbs = true
- 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
.login-page
- #signin-container
- = render 'devise/shared/tab_single', tab_title: _('Enter Admin Mode')
+ #signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
+ = render 'devise/shared/tab_single', tab_title: _('Enter admin mode')
.tab-content
.login-box.tab-pane.gl-p-5.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
- - if current_user.two_factor_otp_enabled?
+ - if current_user.two_factor_enabled?
= render 'admin/sessions/two_factor_otp'
- - if current_user.two_factor_webauthn_u2f_enabled?
+ - if current_user.two_factor_webauthn_enabled?
= render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path
diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml
index c974f455112..001662c4015 100644
--- a/app/views/admin/spam_logs/index.html.haml
+++ b/app/views/admin/spam_logs/index.html.haml
@@ -17,6 +17,6 @@
%th= _('Primary Action')
%th
= render @spam_logs
- = paginate @spam_logs, theme: 'gitlab'
+ = paginate_collection @spam_logs
- else
%h4= _('There are no Spam Logs')
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 049f3d61294..d3d2ebb90da 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.with_body do
+ %h4
+ = sprite_icon('pod', size: 18, css_class: 'gl-text-gray-700')
+ = _('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.with_body do
+ %h4
+ = sprite_icon('status-health', size: 18, css_class: 'gl-text-gray-700')
+ = _('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.with_body do
+ %h4
+ = sprite_icon('clock', size: 18, css_class: 'gl-text-gray-700')
+ = _('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.with_body do
+ %h4
+ = sprite_icon('disk', size: 18, css_class: 'gl-text-gray-700')
+ = _('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/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
index 869194a21f6..c63828cf41f 100644
--- a/app/views/admin/topics/_topic.html.haml
+++ b/app/views/admin/topics/_topic.html.haml
@@ -2,10 +2,9 @@
- title = topic.title || topic.name
%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'topic_row_content' } }
- .avatar-container.rect-avatar.s40.gl-flex-shrink-0
- = topic_icon(topic, class: "avatar s40")
+ = render Pajamas::AvatarComponent.new(topic, size: 32, alt: '')
- .gl-min-w-0.gl-flex-grow-1
+ .gl-min-w-0.gl-flex-grow-1.gl-ml-3
.title
= link_to title, topic_explore_projects_path(topic_name: topic.name)
%div
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index eb151b40a65..8822d52c3c0 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -28,7 +28,7 @@
.col-lg-8
- if @user.new_record?
= render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
= s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.')
- else
.form-group.gl-form-group{ role: 'group' }
@@ -71,6 +71,8 @@
= f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label'
= f.text_field :website_url, class: 'form-control gl-form-input'
+ = render_if_exists 'admin/users/custom_attributes', f: f
+
= render 'admin/users/admin_notes', f: f
%div
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index 6b5ec62bc77..8f7741b8a32 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -1,4 +1,4 @@
-.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-align-items-center.gl-py-3.gl-mb-5.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
+.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-align-items-center.gl-pt-3
.gl-my-3
%h1.page-title.gl-font-size-h-display.gl-m-0
= @user.name
@@ -27,8 +27,6 @@
= render_if_exists 'admin/users/gma_user_badge'
.gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2
- .gl-p-2
- #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
- if @user != current_user
- if impersonation_enabled?
.gl-p-2
@@ -42,6 +40,8 @@
.gl-p-2
= render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_identity_path(@user)) do
= _('New identity')
+ .gl-p-2
+ #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
= gl_tabs_nav do
= gl_tab_link_to _("Account"), admin_user_path(@user)
= gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user)
diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml
index e90dab68b39..b4f61a1b665 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.with_header do
= _('Profile')
- %ul.content-list
- %li
- %span.light= _('Member since')
- %strong= user.created_at.to_s(:medium)
- - unless user.public_email.blank?
+ - c.with_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/admin/users/_projects.html.haml b/app/views/admin/users/_projects.html.haml
index 2f77e83ac49..ce2d684b046 100644
--- a/app/views/admin/users/_projects.html.haml
+++ b/app/views/admin/users/_projects.html.haml
@@ -1,17 +1,17 @@
- if local_assigns.has_key?(:contributed_projects) && contributed_projects.present?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Projects contributed to')
- - c.body do
+ - c.with_body do
= render 'shared/projects/list',
projects: contributed_projects.sort_by(&:star_count).reverse,
projects_limit: 5, stars: true, avatar: false, compact_mode: true
- if local_assigns.has_key?(:projects) && projects.present?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Personal projects')
- - c.body do
+ - c.with_body do
= render 'shared/projects/list',
projects: projects.sort_by(&:star_count).reverse,
projects_limit: 10, stars: true, avatar: false, compact_mode: true
diff --git a/app/views/admin/users/_user_detail_note.html.haml b/app/views/admin/users/_user_detail_note.html.haml
index c8625833a70..65ef1c0710d 100644
--- a/app/views/admin/users/_user_detail_note.html.haml
+++ b/app/views/admin/users/_user_detail_note.html.haml
@@ -1,7 +1,7 @@
- if @user.note.present?
- text = @user.note
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-pb-0'}) do |c|
- - c.header do
+ - c.with_header do
= _('Admin Note')
- - c.body do
+ - c.with_body do
%p= text
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index 96dd16a96da..c9264535a13 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -4,7 +4,7 @@
= render Pajamas::AlertComponent.new(variant: :tip,
alert_options: { class: 'gl-my-5' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= render 'shared/registration_features_discovery_message', feature_title: s_('RegistrationFeatures|send emails to users')
.top-area
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index ff87cf8f866..1f3e8f4bba2 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -5,9 +5,9 @@
- if @user.groups.any?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0 gl-px-0'}) do |c|
- - c.header do
+ - c.with_header do
= _('Groups')
- - c.body do
+ - c.with_body do
%ul.hover-list
- @user.group_members.includes(:source).each do |group_member| # rubocop: disable CodeReuse/ActiveRecord
- group = group_member.group
@@ -31,9 +31,9 @@
.col-md-6
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0 gl-px-0'}) do |c|
- - c.header do
+ - c.with_header do
= _('Joined projects (%{projects_count})') % { projects_count: @joined_projects.count }
- - c.body do
+ - c.with_body do
%ul.hover-list
- @joined_projects.sort_by(&:full_name).each do |project|
- member = project.team.find_member(@user.id)
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index f7d4121e6e0..ea6525e1b96 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -6,9 +6,9 @@
.row
.col-md-6
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-2'}) do |c|
- - c.header do
+ - c.with_header do
= @user.name
- - c.body do
+ - c.with_body do
%ul.content-list
%li
= render Pajamas::AvatarComponent.new(@user, size: 64, class: 'gl-mr-3')
@@ -22,9 +22,9 @@
= render 'admin/users/profile', user: @user
= render 'admin/users/user_detail_note'
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-2'}) do |c|
- - c.header do
+ - c.with_header do
= _('Account:')
- - c.body do
+ - c.with_body do
%ul.content-list
%li
%span.light= _('Name:')
diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml
index 7dcec50573f..9e09bcd6e54 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,14 +12,16 @@
%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" }
%div
%p= _("We heard back from your device. You have been authenticated.")
= form_tag(target_path, method: :post, id: 'js-login-token-2fa-form') do |f|
- - if render_remember_me
+ - if remember_me_enabled? && render_remember_me
- resource_params = params[resource_name].presence || params
= hidden_field_tag 'user[remember_me]', resource_params.fetch(:remember_me, 0)
= hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml
index d6fe20e48bf..f8a03f085ff 100644
--- a/app/views/authentication/_register.html.haml
+++ b/app/views/authentication/_register.html.haml
@@ -1,47 +1,50 @@
-#js-register-token-2fa
+- 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
--# haml-lint:disable InlineJavaScript
-%script#js-register-2fa-message{ type: "text/template" }
- %p <%= message %>
+ -# haml-lint:disable InlineJavaScript
+ %script#js-register-2fa-message{ type: "text/template" }
+ %p <%= message %>
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-setup{ type: "text/template" }
- - if current_user.two_factor_otp_enabled?
- .row.gl-mb-3
- .col-md-5
- = render Pajamas::ButtonComponent.new(variant: :confirm,
- button_options: { id: 'js-setup-token-2fa-device' }) do
- = _("Set up new device")
- .col-md-7
- %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
- - else
- .row.gl-mb-3
- .col-md-4
- = render Pajamas::ButtonComponent.new(variant: :confirm,
- disabled: true,
- button_options: { id: 'js-setup-token-2fa-device' }) do
- = _("Set up new device")
- .col-md-8
- %p= _("You need to register a two-factor authentication app before you can set up a device.")
+ -# haml-lint:disable InlineJavaScript
+ %script#js-register-token-2fa-setup{ type: "text/template" }
+ - if current_user.two_factor_otp_enabled?
+ .row.gl-mb-3
+ .col-md-5
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ button_options: { id: 'js-setup-token-2fa-device' }) do
+ = _("Set up new device")
+ .col-md-7
+ %p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
+ - else
+ .row.gl-mb-3
+ .col-md-4
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ disabled: true,
+ button_options: { id: 'js-setup-token-2fa-device' }) do
+ = _("Set up new device")
+ .col-md-8
+ %p= _("You need to register a two-factor authentication app before you can set up a device.")
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-error{ type: "text/template" }
- %div
- %p
- %span <%= error_message %> (<%= error_name %>)
- = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do
- = _("Try again?")
+ -# haml-lint:disable InlineJavaScript
+ %script#js-register-token-2fa-error{ type: "text/template" }
+ %div
+ %p
+ %span <%= error_message %> (<%= error_name %>)
+ = render Pajamas::ButtonComponent.new(button_options: { id: 'js-token-2fa-try-again' }) do
+ = _("Try again?")
--# haml-lint:disable InlineJavaScript
-%script#js-register-token-2fa-registered{ type: "text/template" }
- .row.gl-mb-3
- .col-md-12
- %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
- = form_tag(target_path, method: :post) do
- .row.gl-mb-3
- .col-md-3
- = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
- .col-md-3
- = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
- = _("Register device")
+ -# haml-lint:disable InlineJavaScript
+ %script#js-register-token-2fa-registered{ type: "text/template" }
+ .row.gl-mb-3
+ .col-md-12
+ %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
+ = form_tag(target_path, method: :post) do
+ .row.gl-mb-3
+ .col-md-3
+ = text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
+ .col-md-3
+ = hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do
+ = _("Register device")
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 8aaa09b7862..da2c8a71dcd 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -1,12 +1,4 @@
- save_endpoint = local_assigns.fetch(:save_endpoint, nil)
-
-- if ci_variable_protected_by_default?
- = render Pajamas::AlertComponent.new(variant: :warning, show_icon: false, dismissible: false,
- alert_options: { class: 'gl-mb-3'}) do |c|
- = c.body do
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
- = _('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-
- is_group = !@group.nil?
- is_project = !@project.nil?
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/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index af4c934fd72..40632e27fa7 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -2,9 +2,10 @@
= render Pajamas::AlertComponent.new(title: s_('ClusterIntegration|Did you know?'),
alert_options: { class: 'gcp-signup-offer',
- data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path }}) do |c|
+ data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path }},
+ close_button_options: { data: { track_action: 'click_dismiss', track_label: 'gcp_signup_offer_banner' }}) do |c|
= c.body do
= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
= c.actions do
- = render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', button_options: { rel: 'noopener noreferrer' }) do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', button_options: { rel: 'noopener noreferrer', data: { track_action: 'click_button', track_label: 'gcp_signup_offer_banner' } }) do
= s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml
index 4a3062def8c..4d36c5094a3 100644
--- a/app/views/clusters/clusters/_integrations.html.haml
+++ b/app/views/clusters/clusters/_integrations.html.haml
@@ -6,10 +6,10 @@
- if can?(current_user, :admin_cluster, @cluster)
.sub-section.form-group
= gitlab_ui_form_for @prometheus_integration, as: :integration, namespace: :prometheus, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |prometheus_form|
- = prometheus_form.hidden_field :application_type
+ = prometheus_form.hidden_field :application_type, value: @prometheus_integration.application_type
.form-group.gl-form-group
- help_text = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Prometheus for metrics.')
- - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations", anchor: "prometheus-cluster-integration"), target: '_blank', rel: 'noopener noreferrer')
+ - help_link = link_to(_('More information.'), help_page_path("user/clusters/integrations"), target: '_blank', rel: 'noopener noreferrer')
= prometheus_form.gitlab_ui_checkbox_component :enabled,
s_('ClusterIntegration|Enable Prometheus integration'),
help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link }
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..19ca9407513 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')
@@ -33,8 +32,8 @@
= gl_tabs_nav do
= render 'clusters/clusters/details_tab'
= render_if_exists 'clusters/clusters/environments_tab'
- = render 'clusters/clusters/health_tab'
- = render 'clusters/clusters/integrations_tab'
+ = render 'clusters/clusters/health_tab' if !Feature.enabled?(:remove_monitor_metrics)
+ = render 'clusters/clusters/integrations_tab' if !Feature.enabled?(:remove_monitor_metrics)
= render 'clusters/clusters/advanced_settings_tab'
.tab-content.py-3
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 09e2e35c617..62ca4a3bab6 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,14 +1,13 @@
.page-title-holder.d-flex.align-items-center
%h1.page-title.gl-font-size-h-display= _('Groups')
- - if current_user.can_create_group?
- .page-title-controls
+ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5
+ = link_to _("Explore groups"), explore_groups_path
+ - if current_user.can_create_group?
= render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { qa_selector: "new_group_button", testid: "new-group-button" } }) do
= _("New group")
-.top-area
- = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do
- = gl_tab_link_to _("Your groups"), dashboard_groups_path
- = gl_tab_link_to _("Explore public groups"), explore_groups_path, data: { qa_selector: "public_groups_tab" }
+
+.top-area.gl-py-3.gl-justify-content-end.gl-border-bottom-0
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
diff --git a/app/views/shared/dashboard/_no_filter_selected.html.haml b/app/views/dashboard/_no_filter_selected.html.haml
index 48c844d93e8..48c844d93e8 100644
--- a/app/views/shared/dashboard/_no_filter_selected.html.haml
+++ b/app/views/dashboard/_no_filter_selected.html.haml
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index c58d4cff034..e600d84f492 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -4,8 +4,9 @@
.page-title-holder.gl-display-flex.gl-align-items-center
%h1.page-title.gl-font-size-h-display= _('Projects')
- - if current_user.can_create_project?
- .page-title-controls
+ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5
+ = link_to _("Explore projects"), explore_projects_path
+ - if current_user.can_create_project?
= render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' } }) do
= _("New project")
diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml
index 7cbd2fb14ec..4367d201190 100644
--- a/app/views/dashboard/_projects_nav.html.haml
+++ b/app/views/dashboard/_projects_nav.html.haml
@@ -1,13 +1,10 @@
- is_your_projects_path = current_page?(dashboard_projects_path) || current_page?(root_path)
-- is_explore_projects_path = current_page?(explore_root_path) || current_page?(trending_explore_projects_path) || current_page?(starred_explore_projects_path) || current_page?(explore_projects_path)
= 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_link_to s_("ProjectList|Explore"), explore_root_path, { item_active: is_explore_projects_path, data: { placement: 'right' } }
- = gl_tab_link_to s_("ProjectList|Topics"), topics_explore_projects_path, { data: { placement: 'right' } }
+ = gl_tab_counter_badge(limited_counter_with_delimiter(@all_starred_projects))
= render_if_exists "dashboard/removed_projects_tab"
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 5a798c249d1..e0e8aaa0fd9 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -1,13 +1,8 @@
.page-title-holder.d-flex.align-items-center
%h1.page-title.gl-font-size-h-display= _('Snippets')
- - if current_user && current_user.snippets.any? || @snippets.any?
- .page-title-controls
- - if can?(current_user, :create_snippet)
- = render Pajamas::ButtonComponent.new(href: new_snippet_path, variant: :confirm, button_options: { title: _("New snippet") }) do
- = _("New snippet")
-
-.top-area
- = gl_tabs_nav({ class: 'gl-border-0' }) do
- = gl_tab_link_to _('Your snippets'), dashboard_snippets_path, { title: _('Your snippets') }
- = gl_tab_link_to _('Explore snippets'), explore_snippets_path, { title: _('Explore snippets') }
+ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5
+ = link_to _("Explore snippets"), explore_snippets_path
+ - if can?(current_user, :create_snippet)
+ = render Pajamas::ButtonComponent.new(href: new_snippet_path, variant: :confirm, button_options: { title: _("New snippet") }) do
+ = _("New snippet")
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..78c3270114e 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'
@@ -8,24 +7,11 @@
= render_dashboard_ultimate_trial(current_user)
-.page-title-holder.d-flex.align-items-center
+.page-title-holder.gl-display-flex.gl-align-items-center
%h1.page-title.gl-font-size-h-display= _('Issues')
- if current_user
.page-title-controls
= render 'shared/new_project_item_vue_select'
-- if ::Feature.enabled?(:vue_issues_dashboard)
- .js-issues-dashboard{ data: dashboard_issues_list_data(current_user) }
-- else
- .top-area
- = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set
- .nav-controls
- = render 'shared/issuable/feed_buttons'
-
- = render 'shared/issuable/search_bar', type: :issues
-
- - if current_user && @no_filters_set
- = render 'shared/dashboard/no_filter_selected'
- - else
- = render 'shared/issues'
+.js-issues-dashboard{ data: dashboard_issues_list_data(current_user) }
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 712f987a783..de34c709ff3 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'
@@ -18,7 +17,7 @@
= render 'shared/issuable/search_bar', type: :merge_requests, disable_target_branch: true
- if current_user && @no_filters_set
- = render 'shared/dashboard/no_filter_selected'
+ = render 'no_filter_selected'
- elsif @search_timeout_occurred
= render 'shared/dashboard/search_timeout_occurred'
- else
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/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
index eba5e7c6e9b..855177fd836 100644
--- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -7,7 +7,7 @@
= link_to new_project_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Create a project')
%p
@@ -17,7 +17,7 @@
= link_to new_group_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_group", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Create a group')
%p
@@ -26,7 +26,7 @@
= link_to new_admin_user_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_user", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Add people')
%p
@@ -35,7 +35,7 @@
= link_to admin_root_path, class: link_classes do
.blank-state-icon
= custom_icon("configure_server", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Configure GitLab')
%p
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index a9a34af3f96..c5fdc31a775 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -5,7 +5,7 @@
= link_to new_project_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Create a project')
%p
@@ -19,7 +19,7 @@
= link_to new_group_path, class: link_classes do
.blank-state-icon
= custom_icon("add_new_group", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Create a group')
%p
@@ -28,7 +28,7 @@
= link_to trending_explore_projects_path, class: link_classes do
.blank-state-icon
= custom_icon("globe", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Explore public projects')
%p
@@ -37,7 +37,7 @@
= link_to Gitlab::Saas::doc_url, class: link_classes do
.blank-state-icon
= custom_icon("lightbulb", size: 50)
- .blank-state-body.gl-sm-pl-0.gl-pl-6
+ .blank-state-body.gl-sm-pl-6
%h3.gl-font-size-h2.gl-mt-0
= _('Learn more about GitLab')
%p
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 68457ab33f7..667ed617849 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,11 +1,9 @@
-- @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'
- if current_user.snippets.exists?
- = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts }
+ .top-area= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts }
= render partial: 'shared/snippets/list', locals: { link_project: true }
- else
= render 'shared/empty_states/snippets', button_path: button_path
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 9e59f9d700f..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'
@@ -62,7 +61,7 @@
= sort_options_hash[@sort]
- else
= sort_title_recently_created
- = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon')
%ul.dropdown-menu.dropdown-menu-sort.dropdown-menu-right
%li
= link_to todos_filter_path(sort: sort_value_label_priority) do
@@ -82,15 +81,15 @@
= render @allowed_todos
= paginate @todos, theme: "gitlab"
.js-nothing-here-container.empty-state.hidden
- .svg-content
- = image_tag 'illustrations/todos_all_done.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-todos-all-done-md.svg'
.text-content.gl-text-center
%h4
= s_("Todos|You're all done!")
- elsif current_user.todos.any?
.col.todos-all-done.empty-state
- .svg-content.svg-250
- = image_tag 'illustrations/todos_all_done.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-todos-all-done-md.svg'
.text-content.gl-text-center
- if todos_filter_empty?
%h4
@@ -102,8 +101,8 @@
= s_("Todos|Nothing is on your to-do list. Nice work!")
- else
.col.empty-state
- .svg-content
- = image_tag 'illustrations/todos_empty.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-todos-md.svg'
.text-content.gl-text-center
%h4
= s_("Todos|Your To-Do List shows what to work on next")
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index 01f9595f35c..c22eeba2f01 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,6 +1,7 @@
- user_email = "(#{params[:email]})" if Devise.email_regexp.match?(params[:email])
- request_link_start = '<a href="%{new_user_confirmation_path}">'.html_safe % { new_user_confirmation_path: new_user_confirmation_path }
-- request_link_end = '</a>'.html_safe
+- registration_link_start = '<a href="%{new_user_registration_path}">'.html_safe % { new_user_registration_path: new_user_registration_path }
+- link_end = '</a>'.html_safe
- content_for :page_specific_javascripts do
= render "layouts/google_tag_manager_head"
= render "layouts/one_trust"
@@ -12,9 +13,11 @@
= _("Almost there...")
%p{ class: 'gl-mb-6 gl-font-lg!' }
= _('Please check your email %{email} to confirm your account') % { email: user_email }
+ %br
+ = _('If the email address is incorrect, you can %{registration_link_start}register again with a different email%{registration_link_end}.').html_safe % { registration_link_start: registration_link_start, registration_link_end: link_end }
%hr
- if Gitlab::CurrentSettings.after_sign_up_text.present?
.well-confirmation.gl-text-center
= markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
%p.gl-text-center
- = _("No confirmation email received? Check your spam folder or %{request_link_start}request new confirmation email%{request_link_end}.").html_safe % { request_link_start: request_link_start, request_link_end: request_link_end }
+ = _("No confirmation email received? Check your spam folder or %{request_link_start}request new confirmation email%{request_link_end}.").html_safe % { request_link_start: request_link_start, request_link_end: link_end }
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index d3bd1d58d21..5af247703f6 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -1,19 +1,22 @@
= render 'devise/shared/tab_single', tab_title: 'Resend confirmation instructions'
.login-box.gl-p-5
.login-body
- = form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
.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?
= recaptcha_tags nonce: content_security_policy_nonce
.gl-mt-5
- = f.submit _("Resend"), class: 'gl-button btn btn-confirm'
+ = render Pajamas::ButtonComponent.new(block: true, type: :submit, variant: :confirm) do
+ = _("Resend")
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/mailer/user_admin_approval.text.erb b/app/views/devise/mailer/user_admin_approval.text.erb
index 5242981e514..bce56d59c68 100644
--- a/app/views/devise/mailer/user_admin_approval.text.erb
+++ b/app/views/devise/mailer/user_admin_approval.text.erb
@@ -2,6 +2,6 @@
<%= _('Your GitLab account request has been approved!') %>
-<%= _('Your username is %{username}.' % { username: @resource.username }) %>
+<%= _('Your username is %{username}.') % { username: @resource.username } %>
-<%= _('Your sign-in page is %{url}.' % { url: Gitlab.config.gitlab.url }) %>
+<%= _('Your sign-in page is %{url}.') % { url: Gitlab.config.gitlab.url } %>
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 8a960602536..e75449bf320 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -8,8 +8,8 @@
= render "layouts/google_tag_manager_body"
.signup-page
- = render 'devise/shared/signup_box',
- url: registration_path(resource_name, glm_tracking_params.to_hash),
+ = render signup_box_template,
+ url: registration_path(resource_name, registration_path_params),
button_text: _('Register'),
borderless: Feature.enabled?(:restyle_login_page, @project),
show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 3aeb89979bb..698e8c89a08 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -4,16 +4,19 @@
= f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
.form-group.gl-px-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', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' }
- - if devise_mapping.rememberable?
- .gl-px-5
- .gl-display-inline-block
+ = f.password_field :password, class: 'form-control gl-form-input js-password', data: { id: "#{resource_name}_password",
+ qa_selector: 'password_field',
+ testid: 'password-field',
+ name: "#{resource_name}[password]" }
+ .gl-px-5
+ .gl-display-inline-block
+ - if remember_me_enabled?
= f.gitlab_ui_checkbox_component :remember_me, _('Remember me')
- .gl-float-right
- - if unconfirmed_email?
- = link_to _('Resend confirmation email'), new_user_confirmation_path
- - else
- = link_to _('Forgot your password?'), new_password_path(:user)
+ .gl-float-right
+ - if unconfirmed_email?
+ = link_to _('Resend confirmation email'), new_user_confirmation_path
+ - else
+ = link_to _('Forgot your password?'), new_password_path(:user)
%div
- if Feature.enabled?(:arkose_labs_login_challenge)
= render_if_exists 'devise/sessions/arkose_labs'
@@ -21,8 +24,8 @@
.gl-px-5
= recaptcha_tags nonce: content_security_policy_nonce
- .submit-container.move-submit-down.gl-px-5
+ .submit-container.move-submit-down.gl-px-5.gl-pb-5
= f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
- - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
- .gl-px-5
+ - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
+ .gl-px-5
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index bdf357c5f74..293e287371a 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -4,8 +4,8 @@
= text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true }
.form-group.gl-px-5
= label_tag :password
- = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control bottom", title: _("This field is required."), required: true }
- - if devise_mapping.rememberable?
+ = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password' } }
+ - if remember_me_enabled?
.remember-me.gl-px-5
%label{ for: "remember_me" }
= check_box_tag :remember_me, '1', false, id: 'remember_me'
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 7affbafbdeb..fb5a57b509c 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,5 +1,5 @@
- server = local_assigns.fetch(:server)
-- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
+- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true)
- submit_message = local_assigns.fetch(:submit_message, _('Sign in'))
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
@@ -8,8 +8,8 @@
= text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true }
.form-group.gl-px-5
= label_tag :password
- = password_field_tag :password, nil, { autocomplete: 'current-password', class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true }
- - if !hide_remember_me && devise_mapping.rememberable?
+ = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password', qa_selector: 'password_field' } }
+ - if render_remember_me
.gl-px-5
= render Pajamas::CheckboxTagComponent.new(name: 'remember_me') do |c|
= c.label do
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index a4edf165a89..fd96d5dc1c4 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -26,5 +26,4 @@
= _("Don't have an account yet?")
= link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }
- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
- .clearfix
- = render 'devise/shared/omniauth_box'
+ = render 'devise/shared/omniauth_box'
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index f63f1aa9197..06152e3dac5 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -2,15 +2,16 @@
= render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') if Feature.disabled?(:restyle_login_page, @project)
.login-box.gl-p-5
.login-body
- - if @user.two_factor_otp_enabled?
- = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_u2f_enabled?}" }) do |f|
+ - if @user.two_factor_enabled?
+ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f|
- resource_params = params[resource_name].presence || params
- = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
+ - if remember_me_enabled?
+ = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
- = f.label _('Two-Factor Authentication code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : ''
- = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
- %p.form-text.text-muted.hint= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
+ = f.label _('Enter verification code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : ''
+ = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
+ %p.form-text.text-muted.hint= _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.")
.prepend-top-20
= f.submit _("Verify code"), pajamas_button: true, data: { qa_selector: 'verify_code_button' }
- - if @user.two_factor_webauthn_u2f_enabled?
+ - if @user.two_factor_webauthn_enabled?
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_error_messages.html.haml b/app/views/devise/shared/_error_messages.html.haml
new file mode 100644
index 00000000000..b7589a4460e
--- /dev/null
+++ b/app/views/devise/shared/_error_messages.html.haml
@@ -0,0 +1,9 @@
+- if resource.errors.any?
+ = render Pajamas::AlertComponent.new(title: I18n.t("errors.messages.not_saved", count: resource.errors.count, resource: resource.class.model_name.human.downcase),
+ variant: :danger,
+ dismissible: false,
+ alert_options: { id: 'error_explanation', class: 'gl-mb-3'}) do |c|
+ = c.body do
+ %ul.gl-pl-4
+ - resource.errors.full_messages.each do |message|
+ %li= message
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 439a2fc4d96..14a9bde2d9e 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -1,18 +1,22 @@
-- hide_remember_me = local_assigns.fetch(:hide_remember_me, false)
+- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true)
- restyle_login_page_enabled = Feature.enabled?(:restyle_login_page, @project)
-%div{ class: restyle_login_page_enabled ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' }
- %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : 'gl-font-weight-bold' }
- = _('Sign in with')
- - providers = enabled_button_based_providers
- .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
- - 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|
+
+- if restyle_login_page_enabled && (any_form_based_providers_enabled? || password_authentication_enabled_for_web?)
+ .omniauth-divider.gl-display-flex.gl-align-items-center
+ = _("or")
+
+.gl-mt-5.gl-px-5{ class: restyle_login_page_enabled ? 'omniauth-container gl-text-center gl-ml-auto gl-mr-auto' : 'omniauth-container gl-py-5' }
+ - if !restyle_login_page_enabled
+ %label.gl-font-weight-bold
+ = _('Sign in with')
+ - enabled_button_based_providers.each do |provider|
+ - has_icon = provider_has_icon?(provider)
+ = 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-mb-2 js-oauth-login gl-w-full", 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)
+ - if render_remember_me
+ = render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c|
= c.label do
= _('Remember me')
diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml
index 0a48c342502..a1d10898c5b 100644
--- a/app/views/devise/shared/_sign_in_link.html.haml
+++ b/app/views/devise/shared/_sign_in_link.html.haml
@@ -1,6 +1,6 @@
%p.text-center
%span.light
- = _('Already have login and password?')
+ = _('Already have an account?')
- path_params = { redirect_to_referer: 'yes' }
- path_params[:invite_email] = @invite_email if @invite_email.present?
= link_to _('Sign in'), new_session_path(:user, path_params)
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index f4f3965bdc1..31c541eebde 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,21 +53,20 @@
%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' },
+ %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password",
+ title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length },
+ minimum_password_length: @minimum_password_length,
+ 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 }
+ name: "#{form_resource_name}[password]" } }
+ %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
- - if arkose_labs_challenge_enabled?
+ - if arkose_labs_enabled?
= render_if_exists 'devise/registrations/arkose_labs'
- elsif show_recaptcha_sign_up?
= recaptcha_tags nonce: content_security_policy_nonce
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index a96c8d6358b..6294a93808b 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -3,9 +3,9 @@
.gl-text-center.gl-pt-5
%label.gl-font-weight-normal
= _("Register with:")
- .gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto
+ .gl-text-center.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-w-full gl-mb-4 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-4 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 0428b9c340c..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 }
@@ -9,4 +8,5 @@
= render 'shared/doorkeeper/applications/show',
edit_path: edit_oauth_application_path(@application),
delete_path: oauth_application_path(@application),
- index_path: oauth_applications_path
+ index_path: oauth_applications_path,
+ renew_path: renew_oauth_application_path(@application)
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/groups/_nav.html.haml b/app/views/explore/groups/_nav.html.haml
index 3c9c4e9f76b..176bfd307b2 100644
--- a/app/views/explore/groups/_nav.html.haml
+++ b/app/views/explore/groups/_nav.html.haml
@@ -1,6 +1,4 @@
-.top-area
- = gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-none'}) do
- = gl_tab_link_to _("Explore Groups"), explore_groups_path
+.top-area.gl-p-3.gl-justify-content-end
.nav-controls
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 60132818193..213346b4cc2 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -1,14 +1,17 @@
-- @hide_top_links = true
-- page_title _("Groups")
+- breadcrumb_title _("Groups")
+- page_title _("Explore groups")
- header_title _("Groups"), dashboard_groups_path
= render_dashboard_ultimate_trial(current_user)
-- if current_user
- = render 'dashboard/groups_head'
-- else
- = render 'explore/head'
- = render 'nav'
+.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_group?
+ = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm) do
+ = _("New group")
+
+= render 'nav'
- if cookies[:explore_groups_landing_dismissed] != 'true'
.explore-groups.landing.content-block.js-explore-groups-landing.hide
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/_nav.html.haml b/app/views/explore/projects/_nav.html.haml
index 9119026320a..ab565279238 100644
--- a/app/views/explore/projects/_nav.html.haml
+++ b/app/views/explore/projects/_nav.html.haml
@@ -1,10 +1,8 @@
.top-area
= gl_tabs_nav({ class: 'gl-display-flex gl-flex-grow-1 gl-border-none'}) do
= gl_tab_link_to _('All'), explore_projects_path, { item_active: current_page?(explore_projects_path) || current_page?(explore_root_path) }
- = gl_tab_link_to _('Most stars'), starred_explore_projects_path
+ = gl_tab_link_to _('Most starred'), starred_explore_projects_path
= gl_tab_link_to _('Trending'), trending_explore_projects_path
.nav-controls
- - unless current_user
- = render 'shared/projects/search_form'
- = render 'filter'
+ = render 'shared/projects/search_form'
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index 9585eb76912..50d79eeefdb 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -1,14 +1,5 @@
-- @hide_top_links = true
-- page_title _("Projects")
-- header_title _("Projects"), dashboard_projects_path
- page_canonical_link explore_projects_url
-= 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'
= 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..dc92787a41f 100644
--- a/app/views/explore/projects/page_out_of_bounds.html.haml
+++ b/app/views/explore/projects/page_out_of_bounds.html.haml
@@ -1,19 +1,9 @@
-- @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
.svg-content
- = image_tag 'illustrations/profile-page/personal-project.svg', size: '75'
+ = image_tag 'illustrations/empty-state/empty-projects-md.svg', size: '75'
.text-content
%h5= _("Maximum page reached")
%p= _("Sorry, you have exceeded the maximum browsable page number. Please use the API to explore further.")
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index ec7eefea264..8840a2dc0e3 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -1,13 +1,3 @@
-- @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: :starred
-- else
- = render 'explore/head'
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml
index 7b2c5683482..b26abefcb0e 100644
--- a/app/views/explore/projects/topic.html.haml
+++ b/app/views/explore/projects/topic.html.haml
@@ -1,23 +1,23 @@
-- @hide_top_links = false
-- @no_container = true
+- add_to_breadcrumbs _("Topics"), topics_explore_projects_path
+- breadcrumb_title @topic.title_or_name
- page_title @topic.title_or_name, _("Topics")
- max_topic_title_length = 50
= render_dashboard_ultimate_trial(current_user)
-.gl-text-center.gl-bg-gray-10.gl-pb-2.gl-pt-6
- .gl-pb-5.gl-align-items-center.gl-justify-content-center.gl-display-flex
- .avatar-container.rect-avatar.s60.gl-flex-shrink-0
- = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s60')
- - if @topic.title_or_name.length > max_topic_title_length
- %h1.gl-mt-3.gl-str-truncated.has-tooltip{ title: @topic.title_or_name }
- = truncate(@topic.title_or_name, length: max_topic_title_length)
- - else
- %h1.gl-mt-3
- = @topic.title_or_name
- - if @topic.description.present?
- .topic-description.gl-ml-4.gl-mr-4
- = markdown(@topic.description)
+.gl-text-center.gl-bg-gray-10.gl-pb-3.gl-pt-6
+ %div{ class: container_class }
+ .gl-pb-5.gl-align-items-center.gl-justify-content-center.gl-display-flex
+ = render Pajamas::AvatarComponent.new(@topic, size: 64, alt: '')
+ - if @topic.title_or_name.length > max_topic_title_length
+ %h1.gl-mt-3.gl-ml-5.gl-str-truncated.has-tooltip{ title: @topic.title_or_name }
+ = truncate(@topic.title_or_name, length: max_topic_title_length)
+ - else
+ %h1.gl-mt-3.gl-ml-5
+ = @topic.title_or_name
+ - if @topic.description.present?
+ .topic-description
+ = markdown(@topic.description)
%div{ class: container_class }
.gl-py-5.gl-border-gray-100.gl-border-b-solid.gl-border-b-1
diff --git a/app/views/explore/projects/topics.html.haml b/app/views/explore/projects/topics.html.haml
index 228304d25b6..08cd122c6aa 100644
--- a/app/views/explore/projects/topics.html.haml
+++ b/app/views/explore/projects/topics.html.haml
@@ -1,12 +1,9 @@
-- @hide_top_links = true
-- page_title _("Topics")
+- breadcrumb_title _("Topics")
+- page_title _("Explore topics")
- header_title _("Topics"), topics_explore_projects_path
= render_dashboard_ultimate_trial(current_user)
-- if current_user
- = render 'explore/topics/head'
-- else
- = render 'explore/head'
+= render 'explore/topics/head'
= render partial: 'shared/topics/list'
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index 8a92ec31b22..8840a2dc0e3 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -1,13 +1,3 @@
-- @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_trending
-- else
- = render 'explore/head'
-
+= render 'explore/projects/head'
= render 'explore/projects/nav'
= render 'projects', projects: @projects
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index bf861e30b3a..bd8b9c29389 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -1,10 +1,12 @@
-- @hide_top_links = true
-- page_title _("Snippets")
+- breadcrumb_title _("Snippets")
+- page_title _("Explore snippets")
- header_title _("Snippets"), snippets_path
-- if current_user
- = render 'dashboard/snippets_head'
-- else
- = render 'explore/head'
+.page-title-holder.gl-display-flex.gl-align-items-center
+ %h1.page-title.gl-font-size-h-display= page_title
+ .page-title-controls
+ - if can?(current_user, :create_snippet)
+ = render Pajamas::ButtonComponent.new(href: new_snippet_path, variant: :confirm) do
+ = _("New snippet")
= render partial: 'shared/snippets/list', locals: { link_project: true }
diff --git a/app/views/explore/topics/_head.html.haml b/app/views/explore/topics/_head.html.haml
index f7d80d63c45..db8de333517 100644
--- a/app/views/explore/topics/_head.html.haml
+++ b/app/views/explore/topics/_head.html.haml
@@ -1,10 +1,6 @@
-.page-title-holder.d-flex.align-items-center
- %h1.page-title.gl-font-size-h-display= _('Projects')
+.page-title-holder.gl-display-flex.gl-align-items-center
+ %h1.page-title.gl-font-size-h-display= page_title
-.top-area
- .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
- = render 'dashboard/projects_nav'
+.top-area.gl-p-4
.nav-controls
= render 'shared/topics/search_form'
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_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 8547795b4b7..7f113a1cfd4 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -2,10 +2,10 @@
%legend.col-form-label.col-form-label
= _('Large File Storage')
= f.gitlab_ui_checkbox_component :lfs_enabled, checkbox_options: { checked: @group.lfs_enabled? } do |c|
- = c.label do
+ - c.with_label do
= _('Projects in this group can use Git LFS')
= link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index'), class: 'gl-ml-2'
- = c.help_text do
+ - c.with_help_text do
= _('This setting can be overridden in each project.')
.form-group.gl-form-group{ role: 'group' }
= f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'gl-display-block col-form-label'
@@ -32,8 +32,7 @@
.form-group.gl-form-group
%legend.col-form-label.col-form-label
= s_('Runners|Runner Registration')
- - parent_disabled = Gitlab::CurrentSettings.valid_runner_registrars.exclude?('group') || !@group.all_ancestors_have_runner_registration_enabled?
= f.gitlab_ui_checkbox_component :runner_registration_enabled,
s_('Runners|New group runners can be registered'),
- checkbox_options: { checked: @group.runner_registration_enabled && !parent_disabled, disabled: parent_disabled },
+ checkbox_options: { checked: @group.runner_registration_enabled?, disabled: !@group.all_ancestors_have_runner_registration_enabled? },
help_text: s_('Runners|Existing runners are not affected. To permit runner registration for all groups, enable this setting in the Admin Area in Settings &gt; CI/CD.').html_safe
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/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
index 24ba060a89a..9fbb7f3c9ed 100644
--- a/app/views/groups/_import_group_from_another_instance_panel.html.haml
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -9,7 +9,7 @@
- if bulk_imports_disabled
= render Pajamas::AlertComponent.new(dismissible: false, variant: :tip) do |c|
- = c.body do
+ - c.with_body do
= s_('GroupsNew|Importing groups by direct transfer is currently disabled.')
- if current_user.admin?
@@ -24,10 +24,10 @@
- else
= render Pajamas::AlertComponent.new(dismissible: false,
variant: :warning) do |c|
- = c.body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
+ - c.with_body do
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'migrated-group-items') }
- docs_link_end = '</a>'.html_safe
- = s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
+ = s_('GroupsNew|Not all group items are migrated. %{docs_link_start}What items are migrated%{docs_link_end}?').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p.gl-mt-3
= s_('GroupsNew|Provide credentials for the source instance to import from. You can provide this instance as a source to move groups in this instance.')
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index 35e8b7dc977..91f7b574dbf 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -9,15 +9,15 @@
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'gl-mb-5' },
dismissible: false) do |c|
- = c.body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
+ - c.with_body do
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'migrate-groups-by-direct-transfer-recommended') }
- link_end = '</a>'.html_safe
- = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}group migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
+ = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end }
= render 'shared/groups/group_name_and_path_fields', f: f
.form-group
= f.label :file, s_('GroupsNew|Upload file')
.gl-font-weight-normal
- - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/settings/import_export') }
+ - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/import/index') }
= s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe }
.gl-mt-3
= render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-confirm-secondary gl-mr-2'
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
index f0fd9026b30..cd3327ba9ec 100644
--- a/app/views/groups/_invite_members_modal.html.haml
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -2,5 +2,5 @@
.js-invite-members-modal{ data: { is_project: 'false',
access_levels: group.access_level_roles.to_json,
- reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s,
+ reload_page_on_submit: current_path?('group_members#index').to_s,
help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
deleted file mode 100644
index 978ef01984c..00000000000
--- a/app/views/groups/_invite_members_side_nav_link.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-.js-invite-members-trigger{ data: { trigger_source: 'group-side-nav',
- icon: 'users',
- display_text: title,
- trigger_element: 'side-nav',
- qa_selector: 'invite_members_sidebar_button' } }
-
-= render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu }
-= render 'groups/invite_members_modal', group: group
diff --git a/app/views/groups/_invite_members_top_nav_link.html.haml b/app/views/groups/_invite_members_top_nav_link.html.haml
new file mode 100644
index 00000000000..35a8d4d9944
--- /dev/null
+++ b/app/views/groups/_invite_members_top_nav_link.html.haml
@@ -0,0 +1,5 @@
+- data = local_assigns.fetch(:data)
+- data[:display_text] = local_assigns.fetch(:display_text)
+- data[:icon] = local_assigns.fetch(:icon)
+
+.js-invite-members-trigger{ data: data }
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 95990e8937c..ddf6e52796f 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -31,6 +31,5 @@
.row
.col-sm-12
= f.submit submit_label, pajamas_button: true, data: { qa_selector: 'create_group_button' }
- = render Pajamas::ButtonComponent.new(href: dashboard_groups_path) do
+ = render Pajamas::ButtonComponent.new(href: @parent_group || dashboard_groups_path) do
= _('Cancel')
-
diff --git a/app/views/groups/achievements/index.html.haml b/app/views/groups/achievements/index.html.haml
new file mode 100644
index 00000000000..d93448b430a
--- /dev/null
+++ b/app/views/groups/achievements/index.html.haml
@@ -0,0 +1,14 @@
+- breadcrumb_title _('Achievements')
+- page_title _('Achievements')
+- @content_wrapper_class = "gl-relative"
+
+= content_for :after_content do
+ #js-achievements-form-portal
+
+#js-achievements-app{ data: { base_path: group_achievements_path(@group), view_model: Gitlab::Json.generate({
+ can_admin_achievement: can?(current_user, :admin_achievement, @group),
+ can_award_achievement: can?(current_user, :award_achievement, @group),
+ group_full_path: @group.full_path,
+ group_id: @group.id,
+ text_query: params[:search]
+ }) } }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index 178d8980ab8..8416cb81c95 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -1,7 +1,7 @@
- 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'),
group_id: @group.id,
+ settings_path: group_settings_packages_and_registries_path(@group),
can_clear_cache: can?(current_user, :admin_group, @group).to_s } }
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 0c416d57b75..dedff502a87 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title _("General settings")
- page_title _("General settings")
-- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
+- @force_desktop_expanded_sidebar = true
= render 'shared/namespaces/cascading_settings/lock_popovers'
@@ -17,7 +17,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')
@@ -30,8 +30,7 @@
= render_if_exists 'groups/merge_requests', expanded: expanded, group: @group
= render_if_exists 'groups/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user
-= render_if_exists 'groups/insights', expanded: expanded
-= render_if_exists 'groups/analytics_dashboards', expanded: expanded
+= render_if_exists 'groups/analytics', expanded: expanded
%section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 298ed2c0806..04bf3f98a1e 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -16,7 +16,6 @@
trigger_source: 'group-members-page',
display_text: _('Invite members') } }
= render 'groups/invite_groups_modal', group: @group, reload_page_on_submit: true
- = render 'groups/invite_members_modal', group: @group, reload_page_on_submit: true
= render_if_exists 'groups/group_members/ldap_sync'
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/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index a03c406acc6..80da61847ef 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -12,9 +12,11 @@
- if @labels.any?
.text-muted.gl-mb-5
= labels_function_introduction
- .other-labels
- %h4= _('Labels')
- %ul.manage-labels-list.js-other-labels
+ .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
+ = _('Labels')
+ %ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
= render partial: 'shared/label', collection: @labels, as: :label, locals: { use_label_priority: false, subject: @group }
= paginate @labels, theme: 'gitlab'
- elsif search.present?
@@ -27,5 +29,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.js-priority-badge.inline.gl-ml-3
- .label-badge.gl-bg-blue-50= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-mr-3
+ .label-badge.gl-bg-blue-50= _('Prioritized')
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index a99d76f99a7..89f460606cb 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -1,30 +1,31 @@
= gitlab_ui_form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :title, _("Title")
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
+
+ - if @conflict
+ = render 'shared/model_version_conflict', model_name: _('milestone'), link_path: group_milestone_path(@group, @milestone)
+
+ .form-group
+ = f.label :title, _("Title")
+ = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
= render "shared/milestones/form_dates", f: f
- .form-group.row.milestone-description
- .col-form-label.col-sm-2
- = f.label :description, _("Description")
- .col-sm-10
- = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
+ .form-group
+ = f.label :description, _("Description")
+ = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
+ = render 'shared/zen', f: f, attr: :description,
+ classes: 'note-textarea',
+ qa_selector: 'milestone_description_field',
+ supports_autocomplete: true,
+ placeholder: _('Write milestone description...')
.clearfix
.error-alert
- .form-actions
- - if @milestone.new_record?
- = f.submit _('Create milestone'), data: { qa_selector: "create_milestone_button" }, pajamas_button: true
- = render Pajamas::ButtonComponent.new(href: group_milestones_path(@group)) do
- = _("Cancel")
- - else
- = f.submit _('Update milestone'), pajamas_button: true
- = render Pajamas::ButtonComponent.new(href: group_milestone_path(@group, @milestone)) do
- = _("Cancel")
+ = f.hidden_field :lock_version
+
+ - if @milestone.new_record?
+ = f.submit _('Create milestone'), data: { qa_selector: "create_milestone_button" }, class: 'gl-mr-2', pajamas_button: true
+ = render Pajamas::ButtonComponent.new(href: group_milestones_path(@group)) do
+ = _("Cancel")
+ - else
+ = f.submit _('Save changes'), class: 'gl-mr-2', pajamas_button: true
+ = render Pajamas::ButtonComponent.new(href: group_milestone_path(@group, @milestone)) do
+ = _("Cancel")
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 8bceb1ddd5c..e1837bdd6fa 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -5,6 +5,5 @@
%h1.page-title.gl-font-size-h-display
= _("New Milestone")
-%hr
-
-= render "form"
+.gl-mt-3
+ = render "form"
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index b75fda2f344..88cb8d989fa 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -1,13 +1,14 @@
-- @hide_breadcrumbs = true
+- @hide_top_bar = true
- @hide_top_links = true
- page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path
- add_page_specific_style 'page_bundles/new_namespace'
-.group-edit-container.gl-mt-5
+.group-edit-container
- .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s }.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
@@ -15,11 +16,6 @@
= render 'new_group_fields', f: f, group_name_id: 'create-group-name'
#import-group-pane.tab-pane
- - if import_sources_enabled?
- = render 'import_group_from_another_instance_panel'
- .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1
- = render 'import_group_from_file_panel'
- - else
- .nothing-here-block
- %h4= s_('GroupsNew|No import options available')
- %p= s_('GroupsNew|Contact an administrator to enable options for importing your group.')
+ = render 'import_group_from_another_instance_panel'
+ .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1
+ = render 'import_group_from_file_panel'
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..ed078230349 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -1,16 +1,16 @@
- breadcrumb_title _("Projects")
- page_title _("Projects")
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
= 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
+ - c.with_header do
.gl-flex-grow-1
= html_escape(_("%{strong_open}%{group_name}%{strong_close} projects:")) % { strong_open: '<strong>'.html_safe, group_name: @group.name, strong_close: '</strong>'.html_safe }
- if can? current_user, :admin_group, @group
.controls
= render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), size: :small, variant: :confirm) do
= _("New project")
- - c.body do
+ - c.with_body do
%ul.content-list
- @projects.each_with_index do |project, idx|
%li.project-row.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'project_row_container', qa_index: idx } }
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/edit.html.haml b/app/views/groups/runners/edit.html.haml
index 4bd550eaa47..93f2f395df6 100644
--- a/app/views/groups/runners/edit.html.haml
+++ b/app/views/groups/runners/edit.html.haml
@@ -1,4 +1,4 @@
-- runner_name = "##{@runner.id} (#{@runner.short_sha})"
+- runner_name = runner_short_name(@runner)
- breadcrumb_title _('Edit')
- page_title _('Edit'), runner_name
- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
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..db48e66185c
--- /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: { 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..15d96bb80b6
--- /dev/null
+++ b/app/views/groups/runners/register.html.haml
@@ -0,0 +1,7 @@
+- runner_name = runner_short_name(@runner)
+- breadcrumb_title s_('Runners|Register')
+- page_title s_('Runners|Register'), runner_name
+- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
+- add_to_breadcrumbs runner_name, register_group_runner_path(@group, @runner)
+
+#js-group-register-runner{ data: { runner_id: @runner.id, runners_path: group_runners_path(@group) } }
diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml
index 43673d54478..53ca704ecb7 100644
--- a/app/views/groups/runners/show.html.haml
+++ b/app/views/groups/runners/show.html.haml
@@ -1,9 +1,9 @@
- add_page_specific_style 'page_bundles/ci_status'
- add_page_specific_style 'page_bundles/runner_details'
-- title = "##{@runner.id} (#{@runner.short_sha})"
-- breadcrumb_title title
-- page_title title
+- runner_name = runner_short_name(@runner)
+- breadcrumb_title runner_name
+- page_title runner_name
- add_to_breadcrumbs _('Runners'), group_runners_path(@group)
#js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} }
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index 5d79d0f8e79..1e80c1846a4 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -4,16 +4,15 @@
%h4= s_('GroupSettings|Export group')
%p= _('Export this group with all related data.')
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
- = c.body do
- - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') }
+ - c.with_body do
+ - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') }
- docs_link_end = '</a>'.html_safe
- = s_('GroupsNew|This feature is deprecated and replaced by %{docs_link_start}group migration%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
+ = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end }
%p
- export_information = _('After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance.') % { strong_text_start: '<strong>'.html_safe, strong_text_end: '</strong>'.html_safe}
= export_information.html_safe
- = link_to _('Learn more.'), help_page_path('user/group/settings/import_export.md'), target: '_blank', rel: 'noopener noreferrer'
= render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
%p.gl-mb-0
%p= _('The following items will be exported:')
%ul
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 658109fde64..8c73fc95544 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -19,12 +19,16 @@
= 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
.form-group.gl-mt-3.gl-mb-6
- .avatar-container.rect-avatar.s90
- = group_icon(@group, alt: '', class: 'avatar group-avatar s90')
+ = render Pajamas::AvatarComponent.new(@group, size: 96, alt: '', class: 'gl-float-left gl-mr-5')
= f.label :avatar, s_('Groups|Group avatar'), class: 'label-bold d-block'
= render 'shared/choose_avatar_button', f: f
- if @group.avatar?
diff --git a/app/views/groups/settings/_git_access_protocols.html.haml b/app/views/groups/settings/_git_access_protocols.html.haml
index 01e8536c7ad..db177da1d84 100644
--- a/app/views/groups/settings/_git_access_protocols.html.haml
+++ b/app/views/groups/settings/_git_access_protocols.html.haml
@@ -1,4 +1,4 @@
-- if group.root? && Feature.enabled?(:group_level_git_protocol_control, group)
+- if group.root?
.form-group
= f.label _('Enabled Git access protocols'), class: 'label-bold'
= f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group, group.enabled_git_access_protocol), {}, class: 'form-control', data: { qa_selector: 'enabled_git_access_protocol_dropdown' }, disabled: !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank?
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index a18789b52a3..6fa76297679 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -30,13 +30,16 @@
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_if_exists 'groups/settings/ai_related_settings', 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/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml
index cb05076b39d..acf11fd8858 100644
--- a/app/views/groups/settings/_remove_button.html.haml
+++ b/app/views/groups/settings/_remove_button.html.haml
@@ -2,7 +2,7 @@
- if group.prevent_delete?
= render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c|
- = c.body do
+ - c.with_body do
= html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
.js-confirm-danger{ data: group_settings_confirm_modal_data(group, remove_form_id) }
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
index a4a83330fa9..9ebe3a740b3 100644
--- a/app/views/groups/settings/_transfer.html.haml
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -10,11 +10,11 @@
- learn_more_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learn_more_link }
- warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended side effects. %{learn_more_link_start}Learn more.%{learn_more_link_end}") % { learn_more_link_start: learn_more_link_start, learn_more_link_end: '</a>'.html_safe }
%li= warning_text.html_safe
- %li= s_('GroupSettings|You can only transfer the group to a group you manage.')
+ %li= s_('GroupSettings|You must have the Owner role in the target group')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|If the parent group's visibility is lower than the group's current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- if group.paid?
= render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
= html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
.js-transfer-group-form{ data: initial_data }
diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml
index 309633471a5..96a492e599e 100644
--- a/app/views/groups/settings/access_tokens/index.html.haml
+++ b/app/views/groups/settings/access_tokens/index.html.haml
@@ -2,7 +2,7 @@
- page_title _('Group Access Tokens')
- type = _('group access token')
- type_plural = _('group access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4
@@ -13,15 +13,15 @@
- if current_user.can?(:create_resource_access_tokens, @group)
= _('Generate group access tokens scoped to this group for your applications that need access to the GitLab API.')
%p
- = _('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('You can also use group access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe }
- else
- = _('Group access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('Group access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}')) % { link_start: link_start, link_end: '</a>'.html_safe }
%p
- root_group = @group.root_ancestor
- if current_user.can?(:admin_group, root_group)
- group_settings_link = edit_group_path(root_group)
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
- = _('You can enable group access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ = html_escape(_('You can enable group access token creation in %{link_start}group settings%{link_end}.')) % { link_start: link_start, link_end: '</a>'.html_safe }
.col-lg-8
#js-new-access-token-app{ data: { access_token_type: type } }
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/index.html.haml b/app/views/groups/settings/applications/index.html.haml
index 95bf2151bda..da3257ca27d 100644
--- a/app/views/groups/settings/applications/index.html.haml
+++ b/app/views/groups/settings/applications/index.html.haml
@@ -1,5 +1,6 @@
- page_title _("Group applications")
- add_page_specific_style 'page_bundles/settings'
+- @force_desktop_expanded_sidebar = true
= render 'shared/doorkeeper/applications/index',
oauth_applications_enabled: user_oauth_applications?,
diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml
index 4a83d96aae4..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 }
@@ -9,4 +8,5 @@
= render 'shared/doorkeeper/applications/show',
edit_path: edit_group_settings_application_path(@group, @application),
delete_path: group_settings_application_path(@group, @application),
- index_path: group_settings_applications_path
+ index_path: group_settings_applications_path,
+ renew_path: renew_group_settings_application_path(@group, @application)
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
index 06cb9893196..b0a5d0bd4fa 100644
--- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -2,8 +2,8 @@
= form_errors(group)
%fieldset
.form-group
- .card.gl-mb-3
- .card-body
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c|
+ - c.with_body do
- learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
- help_text = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.')
- badge = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info
@@ -13,4 +13,5 @@
help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link },
checkbox_options: { checked: group.auto_devops_enabled? }
- = f.submit _('Save changes'), class: 'gl-mt-5', pajamas_button: true
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mt-5' }) do
+ = _('Save changes')
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 67b87f842f9..7b6e50ffd36 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _("CI/CD Settings")
- page_title _("CI/CD")
+- @force_desktop_expanded_sidebar = true
- expanded = expanded_by_default?
- general_expanded = @group.errors.empty? ? expanded : true
@@ -47,7 +48,7 @@
- quickstart_url = help_page_path('topics/autodevops/cloud_deployments/auto_devops_with_gke')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
- quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url }
- = s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
+ = html_escape(s_('AutoDevOps|%{auto_devops_start}Automate building, testing, and deploying%{auto_devops_end} your applications based on your continuous integration and delivery configuration. %{quickstart_start}How do I get started?%{quickstart_end}')) % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
.settings-content
= render 'groups/settings/ci_cd/auto_devops_form', group: @group
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index ec99ceb5f8d..3c1a38d9997 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title s_('Integrations|Group-level integration management')
- page_title s_('Integrations|Group-level integration management')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%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..d5e02f141b3 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,6 @@
- breadcrumb_title _('Packages and registries settings')
- page_title _('Packages and registries settings')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%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..fc81d22391a 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title _('Repository Settings')
- page_title _('Repository')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
- 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 72b7bec1b92..e42f524467d 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'
@@ -8,13 +7,12 @@
- if show_invite_banner?(@group)
= content_for :group_invite_members_banner do
.container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" }
- .js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/merge_requests.svg'),
+ .js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/empty-state/empty-merge-requests-md.svg'),
track_label: 'invite_members_banner',
invite_members_path: group_group_members_path(@group),
callouts_path: group_callouts_path,
callouts_feature_id: Users::GroupCalloutsHelper::INVITE_MEMBERS_BANNER,
group_id: @group.id } }
- = render 'groups/invite_members_modal', group: @group
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
@@ -30,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/help/instance_configuration/_ci_cd_limits.html.haml b/app/views/help/instance_configuration/_ci_cd_limits.html.haml
index bd5b8a6f10d..8496d354ee1 100644
--- a/app/views/help/instance_configuration/_ci_cd_limits.html.haml
+++ b/app/views/help/instance_configuration/_ci_cd_limits.html.haml
@@ -19,34 +19,30 @@
%th= title.to_s.humanize
%tbody
%tr
- %td= s_('AdminSettings|Maximum number of jobs in a single pipeline')
+ %td= plan_limit_setting_description(:ci_pipeline_size)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_pipeline_size])
%tr
- %td= s_('AdminSettings|Total number of jobs in currently active pipelines')
+ %td= plan_limit_setting_description(:ci_active_jobs)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_active_jobs])
%tr
- %td= s_('AdminSettings|Maximum number of active pipelines per project')
- - ci_cd_limits.each_value do |limits|
- %td= instance_configuration_disabled_cell_html(limits[:ci_active_pipelines])
- %tr
- %td= s_('AdminSettings|Maximum number of pipeline subscriptions to and from a project')
+ %td= plan_limit_setting_description(:ci_project_subscriptions)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_project_subscriptions])
%tr
- %td= s_('AdminSettings|Maximum number of pipeline schedules')
+ %td= plan_limit_setting_description(:ci_pipeline_schedules)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_pipeline_schedules])
%tr
- %td= s_('AdminSettings|Maximum number of DAG dependencies that a job can have')
+ %td= plan_limit_setting_description(:ci_needs_size_limit)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_needs_size_limit])
%tr
- %td= s_('AdminSettings|Maximum number of runners registered per group')
+ %td= plan_limit_setting_description(:ci_registered_group_runners)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_registered_group_runners])
%tr
- %td= s_('AdminSettings|Maximum number of runners registered per project')
+ %td= plan_limit_setting_description(:ci_registered_project_runners)
- ci_cd_limits.each_value do |limits|
%td= instance_configuration_disabled_cell_html(limits[:ci_registered_project_runners])
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index 5a6e93c3573..eb6d5668807 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -7,4 +7,6 @@
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')
-= render partial: 'shared/ide_root', locals: { data: ide_data(project: @project, branch: @branch, path: @path, merge_request: @merge_request, fork_info: @fork_info), loading_text: _('Loading the GitLab IDE...') }
+- data = ide_data(project: @project, fork_info: @fork_info, params: params)
+
+= render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE...') }
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 4d2186a1352..5eae48fd237 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -5,8 +5,10 @@
- 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, [])
+- status_import_github_group_path = local_assigns.fetch(:status_import_github_group_path, '')
- header_title _("New project"), new_project_path
- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
@@ -18,7 +20,9 @@
jobs_path: url_for([:realtime_changes, :import, provider, { format: :json }]),
default_target_namespace: default_namespace_path,
import_path: url_for([:import, provider, { format: :json }]),
+ status_import_github_group_path: status_import_github_group_path,
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..d5d36a9b581
--- /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{ data: { failures_path: failures_import_github_path } }
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 4a9f8be35c3..b07374e5b5f 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -11,4 +11,6 @@
provider: 'github', paginatable: paginatable,
default_namespace: @namespace,
cancel_path: cancel_import_github_path,
+ details_path: details_import_github_path,
+ status_import_github_group_path: status_import_github_group_path(format: :json),
optional_stages: Gitlab::GithubImport::Settings.stages_array
diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml
deleted file mode 100644
index d4fdd107043..00000000000
--- a/app/views/import/phabricator/new.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-- page_title _('Phabricator Server Import')
-- header_title _("New project"), new_project_path
-- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project')
-
-%h1.page-title.gl-font-size-h-display.d-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('issues', css_class: 'gl-mr-2')
- = _('Import tasks from Phabricator into issues')
-
-= render 'import/shared/errors'
-
-= form_tag import_phabricator_path, class: 'new_project', method: :post do
- = render 'import/shared/new_project_form'
-
- %h4.gl-mt-0= _('Enter in your Phabricator Server URL and personal access token below')
-
- .form-group.row
- = label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2'
- .col-md-4
- = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control gl-form-input gl-mr-3', placeholder: 'https://your-phabricator-server', size: 40
- .form-group.row
- = label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2'
- .col-md-4
- = password_field_tag :api_token, params[:api_token], class: 'form-control gl-form-input gl-mr-3', placeholder: _('Personal Access Token'), size: 40
- .form-actions
- = submit_tag _('Import tasks'), class: 'gl-button btn btn-confirm'
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/jira_connect/users/show.html.haml b/app/views/jira_connect/users/show.html.haml
deleted file mode 100644
index 5db6cb44ff6..00000000000
--- a/app/views/jira_connect/users/show.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-.gl-text-center.gl-mx-auto.gl-pt-6
- %h3.gl-mb-4
- = _('You are signed in to GitLab as:')
-
- .gl-display-flex.gl-flex-direction-column.gl-align-items-center.gl-mb-4
- = link_to user_path(current_user), target: '_blank', rel: 'noopener noreferrer' do
- = user_avatar_without_link(user: current_user, size: 60, css_class: 'gl-mr-0! gl-mb-2', has_tooltip: false)
- = link_to current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer'
-
- %p.gl-mb-6
- = s_('JiraService|You can now close this window and%{br}return to the GitLab for Jira application.').html_safe % { br: '<br>'.html_safe }
-
- - if @jira_app_link
- %p
- = render Pajamas::ButtonComponent.new(href: @jira_app_link, variant: :confirm) do
- = s_('Integrations|Return to GitLab for Jira')
-
-
- %p= link_to _('Sign out'), destroy_user_session_path, method: :post
-
-- add_page_specific_style 'page_bundles/jira_connect_users'
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index dd441d0d155..d3a4c5c5ba8 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)
@@ -10,6 +11,7 @@
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
= render 'layouts/startup_js'
+ = yield :startup_js
- if page_canonical_link
%link{ rel: 'canonical', href: page_canonical_link }
@@ -45,6 +47,7 @@
= render 'layouts/startup_css_activation'
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
+ = render 'layouts/snowplow'
= Gon::Base.render_data(nonce: content_security_policy_nonce)
@@ -57,26 +60,29 @@
= yield :page_specific_javascripts
+ = webpack_bundle_tag 'super_sidebar' if show_super_sidebar?
+
= webpack_controller_bundle_tags
= 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 }
@@ -98,6 +104,5 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
= render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id')
- = render 'layouts/snowplow'
-# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/gitlab/-/issues/184)
= render_if_exists "layouts/frontend_monitor"
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index 9026bec84c3..b20b95cade8 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -12,7 +12,8 @@
= preload_link_tag(path_to_stylesheet('application'), crossorigin: css_crossorigin)
= preload_link_tag(path_to_stylesheet("highlight/themes/#{user_color_scheme}"), crossorigin: css_crossorigin)
- if Gitlab::Tracking.enabled? && Gitlab::Tracking.collector_hostname
- %link{ rel: 'preconnect', href: "https://#{Gitlab::Tracking.collector_hostname}", crossorigin: '' }
+ - unless Rails.env.development?
+ %link{ rel: 'preconnect', href: "https://#{Gitlab::Tracking.collector_hostname}", crossorigin: '' }
-# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug.
-# See https://github.com/web-platform-tests/wpt/pull/36930
%link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index d2ed70d6b48..aa80de7f789 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -2,15 +2,21 @@
- @left_sidebar = true
.layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class }
- if show_super_sidebar?
- - sidebar_data = super_sidebar_context(current_user, group: @group, project: @project).to_json
- %aside.js-super-sidebar.nav-sidebar{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_url } }
+ -# Render the parent group sidebar while creating a new subgroup/project, see GroupsController#new.
+ - 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, 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, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s } }
- if display_whats_new?
#whats-new-app{ data: { version_digest: whats_new_version_digest } }
+ = render_if_exists "layouts/tanuki_bot_chat"
+
- 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
@@ -30,10 +36,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'
@@ -44,4 +49,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'
+= 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/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 5db7f22e36b..399a826d611 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -1,21 +1,17 @@
- return unless Gitlab::Tracking.enabled?
- namespace = @group || @project&.namespace || @namespace
-
+= webpack_bundle_tag 'tracker'
= javascript_tag do
:plain
- ;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[];
- p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments)
- };p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1;
- n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{escaped_url(asset_url('snowplow/sp.js'))}","snowplow"));
-
window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}
gl = window.gl || {};
gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new(
- namespace: namespace,
- project: @project,
- user: current_user,
+ namespace_id: namespace&.id,
+ plan_name: namespace&.actual_plan_name,
+ project_id: @project&.id,
+ user_id: current_user&.id,
new_nav: show_super_sidebar?
).to_context.to_json.to_json}
gl.snowplowPseudonymizedPageUrl = #{masked_page_url(group: namespace, project: @project).to_json};
diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml
index a1b1304f994..8217ac13c52 100644
--- a/app/views/layouts/component_preview.html.haml
+++ b/app/views/layouts/component_preview.html.haml
@@ -1,12 +1,12 @@
%head
- - if params[:lookbook][:display][:theme] == 'light'
+ - if params[:lookbook][:display][:theme] == "light"
= stylesheet_link_tag "application"
= stylesheet_link_tag "application_utilities"
- else
= stylesheet_link_tag "application_dark"
= stylesheet_link_tag "application_utilities_dark"
%body
- .container.gl-mt-6
+ .gl-mt-6{ class: (params[:lookbook][:display][:layout] == "fluid" ? "container-fluid" : "container") }
- if params[:lookbook][:display][:bg_dark]
.bg-dark.rounded.shadow.p-4
= yield
diff --git a/app/views/layouts/dashboard.html.haml b/app/views/layouts/dashboard.html.haml
index 89f238eb6b3..39f4a755340 100644
--- a/app/views/layouts/dashboard.html.haml
+++ b/app/views/layouts/dashboard.html.haml
@@ -1,7 +1,6 @@
-- page_title _("Dashboard")
-- header_title _("Dashboard"), root_path unless header_title
+- header_title _("Your work"), root_path
- @left_sidebar = true
-- nav "your_work"
+- nav (@parent_group ? "group" : "your_work")
= render template: "layouts/application"
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 3532c6638ce..71771dd7cb6 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' }
@@ -6,30 +7,37 @@
= render "layouts/init_client_detection_flags"
- if Feature.enabled?(:restyle_login_page, @project)
.page-wrap.borderless
- .login-page-broadcast
- = render "layouts/broadcast"
.container.navless-container
.content
= render "layouts/flash"
- .mt-3
- .col-sm-12.gl-text-center
- = brand_image
- %h1.mb-3.gl-font-size-h2
- = brand_title
- - if current_appearance&.description?
- = brand_text
- = render_if_exists 'layouts/devise_help_text'
- .mb-3
- .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
- = yield
-
+ - if current_appearance&.description?
+ .row
+ .col-md.order-12.sm-bg-gray-10
+ .col-sm-12
+ %h1.mb-3.gl-font-size-h2
+ = brand_title
+ = brand_text
+ = render_if_exists 'layouts/devise_help_text'
+ .col-md.order-md-12
+ .col-sm-12.bar
+ .gl-text-center
+ = brand_image
+ = yield
+ - else
+ .mt-3
+ .col-sm-12.gl-text-center
+ = brand_image
+ %h1.mb-3.gl-font-size-h2
+ = brand_title
+ = render_if_exists 'layouts/devise_help_text'
+ .mb-3
+ .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
+ = yield
= render 'devise/shared/footer', footer_message: footer_message
- else
.page-wrap
= render "layouts/header/empty"
- .login-page-broadcast
- = render "layouts/broadcast"
.container.navless-container
.content
= render "layouts/flash"
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/explore.html.haml b/app/views/layouts/explore.html.haml
index c495bab4547..02c00a53316 100644
--- a/app/views/layouts/explore.html.haml
+++ b/app/views/layouts/explore.html.haml
@@ -1,11 +1,6 @@
-- page_title _("Explore")
+- header_title _("Explore"), explore_root_path
-- if current_user
- - @left_sidebar = true
- - nav "your_work"
-
-- unless current_user
- - @hide_breadcrumbs = true
- - header_title _("Explore GitLab"), explore_root_path
+- @left_sidebar = true
+- nav "explore"
= render template: "layouts/application"
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 95934f43a51..1f742279756 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -16,6 +16,11 @@
:plain
window.uploads_path = "#{group_uploads_path(@group)}";
+- content_for :before_content do
+ = render 'groups/invite_members_modal', group: @group
+
+= 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.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index af27026845e..1739dee1511 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -16,11 +16,7 @@
= s_('SetStatusModal|Edit status')
- else
= s_('SetStatusModal|Set status')
- - if current_user_menu?(:start_trial)
- %li
- %a.trial-link{ href: trials_link_url }
- = s_("CurrentUser|Start an Ultimate trial")
- = emoji_icon('rocket')
+ = dispensable_render_if_exists 'layouts/header/start_trial'
- if current_user_menu?(:settings)
%li
= link_to s_("CurrentUser|Edit profile"), profile_path, data: { qa_selector: 'edit_profile_link', track_action: "click_link", track_label: "user_edit_profile", track_property: "navigation_top" }
@@ -48,7 +44,7 @@
- if Feature.enabled?(:super_sidebar_nav, current_user)
%li.divider
- .js-new-nav-toggle{ data: { enabled: current_user.use_new_navigation.to_s, endpoint: profile_preferences_url} }
+ .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} }
- if current_user_menu?(:sign_out)
%li.divider
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/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml
index 372babea18e..50a2b45aa7e 100644
--- a/app/views/layouts/header/_new_dropdown.html.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -26,8 +26,13 @@
= section.fetch(:title)
- section.fetch(:menu_items).each do |menu_item|
%li<
- = link_to menu_item.fetch(:href), class: menu_item.fetch(:css_class), data: menu_item.fetch(:data) do
- = menu_item.fetch(:title)
- - if menu_item.fetch(:emoji)
- -# We need to insert a space between the title and emoji
- = " #{emoji_icon(menu_item.fetch(:emoji), 'aria-hidden': true, class: 'gl-font-base gl-vertical-align-baseline')}".html_safe
+ - if menu_item.fetch(:partial).present?
+ = render partial: menu_item.fetch(:partial),
+ locals: { display_text: menu_item.fetch(:title),
+ icon: menu_item.fetch(:icon),
+ data: menu_item.fetch(:data) }
+ - else
+ = link_to menu_item.fetch(:title),
+ menu_item.fetch(:href),
+ class: menu_item.fetch(:css_class),
+ data: menu_item.fetch(:data)
diff --git a/app/views/layouts/help.html.haml b/app/views/layouts/help.html.haml
index a913bea0c93..68426f71879 100644
--- a/app/views/layouts/help.html.haml
+++ b/app/views/layouts/help.html.haml
@@ -1,5 +1,7 @@
- @breadcrumb_title = _("Help")
- page_title _("Help")
- header_title _("Help"), help_path
+- if show_super_sidebar?
+ - @force_desktop_expanded_sidebar = true
= render template: "layouts/application"
diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml
index b5cb8f2af37..5531c0ab23a 100644
--- a/app/views/layouts/minimal.html.haml
+++ b/app/views/layouts/minimal.html.haml
@@ -6,12 +6,11 @@
%body{ data: body_data }
= header_message
= render 'peek/bar'
+ = render 'layouts/published_experiments'
= render "layouts/header/empty"
.layout-page
- .content-wrapper.content-wrapper-margin.gl-pt-6{ class: 'gl-md-pt-11!' }
- .alert-wrapper.gl-force-block-formatting-context
- = render "layouts/broadcast"
- .limit-container-width{ class: container_class }
+ .content-wrapper.gl-pt-6{ class: 'gl-md-pt-11!' }
+ %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 98d6af28cf5..00000000000
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ /dev/null
@@ -1,27 +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)
-
-%nav.breadcrumbs{ class: [container, @content_class], 'aria-label': _('Breadcrumbs') }
- .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) }
- - if defined?(@left_sidebar)
- = button_tag class: 'toggle-mobile-nav', data: { qa_selector: 'toggle_mobile_nav_button' }, type: 'button' do
- %span.sr-only= _("Open sidebar")
- = sprite_icon('sidebar', size: 18)
- .breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
- %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- - unless hide_top_links
- = 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/_explore.html.haml b/app/views/layouts/nav/sidebar/_explore.html.haml
new file mode 100644
index 00000000000..ccbcb434af1
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_explore.html.haml
@@ -0,0 +1 @@
+= render partial: 'shared/nav/sidebar', object: Sidebars::Explore::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil))
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index c2b50bc0e52..fd0e47b543f 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1 +1,2 @@
-= render partial: 'shared/nav/sidebar', object: Sidebars::Groups::Panel.new(group_sidebar_context(@group, current_user))
+- group = @parent_group || @group
+= render partial: 'shared/nav/sidebar', object: Sidebars::Groups::Panel.new(group_sidebar_context(group, current_user))
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 087eca3ba35..d53316442f8 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -1,169 +1 @@
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') }
- .nav-sidebar-inner-scroll
- .context-header
- = link_to profile_path, title: _('Profile Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
- = render Pajamas::AvatarComponent.new(current_user, size: 32, alt: current_user.name, class: 'gl-mr-3 js-sidebar-user-avatar', avatar_options: { data: { testid: 'sidebar-user-avatar' } })
- %span.sidebar-context-title= _('User Settings')
- %ul.sidebar-top-level-items
- = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
- = link_to profile_path do
- .nav-icon-container
- = sprite_icon('profile')
- %span.nav-item-name
- = _('Profile')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'profiles#show', html_options: { class: "fly-out-top-item" }) do
- = link_to profile_path do
- %strong.fly-out-top-item-name
- = _('Profile')
- = nav_link(controller: [:accounts, :two_factor_auths]) do
- = link_to profile_account_path, data: { qa_selector: 'profile_account_link' } do
- .nav-icon-container
- = sprite_icon('account')
- %span.nav-item-name
- = _('Account')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: [:accounts, :two_factor_auths], html_options: { class: "fly-out-top-item" }) do
- = link_to profile_account_path do
- %strong.fly-out-top-item-name
- = _('Account')
-
- = render_if_exists 'layouts/nav/sidebar/profile_billing_link'
- = nav_link(controller: 'oauth/applications') do
- = link_to applications_profile_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: 'oauth/applications', html_options: { class: "fly-out-top-item" }) do
- = link_to applications_profile_path do
- %strong.fly-out-top-item-name
- = _('Applications')
- = nav_link(controller: :chat_names) do
- = link_to profile_chat_names_path do
- .nav-icon-container
- = sprite_icon('comment')
- %span.nav-item-name
- = _('Chat')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :chat_names, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_chat_names_path do
- %strong.fly-out-top-item-name
- = _('Chat')
- - unless Gitlab::CurrentSettings.personal_access_tokens_disabled?
- = nav_link(controller: :personal_access_tokens) do
- = link_to profile_personal_access_tokens_path do
- .nav-icon-container
- = sprite_icon('token')
- %span.nav-item-name
- = _('Access Tokens')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_personal_access_tokens_path do
- %strong.fly-out-top-item-name
- = _('Access Tokens')
- = nav_link(controller: :emails) do
- = link_to profile_emails_path, data: { qa_selector: 'profile_emails_link' } do
- .nav-icon-container
- = sprite_icon('mail')
- %span.nav-item-name
- = _('Emails')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :emails, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_emails_path do
- %strong.fly-out-top-item-name
- = _('Emails')
- - if current_user.allow_password_authentication?
- = nav_link(controller: :passwords) do
- = link_to edit_profile_password_path , data: { qa_selector: 'profile_password_link' } do
- .nav-icon-container
- = sprite_icon('lock')
- %span.nav-item-name
- = _('Password')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :passwords, html_options: { class: "fly-out-top-item" }) do
- = link_to edit_profile_password_path do
- %strong.fly-out-top-item-name
- = _('Password')
- = nav_link(controller: :notifications) do
- = link_to profile_notifications_path do
- .nav-icon-container
- = sprite_icon('notifications')
- %span.nav-item-name
- = _('Notifications')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :notifications, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_notifications_path do
- %strong.fly-out-top-item-name
- = _('Notifications')
- = nav_link(controller: :keys) do
- = link_to profile_keys_path do
- .nav-icon-container
- = sprite_icon('key')
- %span.nav-item-name
- = _('SSH Keys')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :keys, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_keys_path do
- %strong.fly-out-top-item-name
- = _('SSH Keys')
- = nav_link(controller: :gpg_keys) do
- = link_to profile_gpg_keys_path do
- .nav-icon-container
- = sprite_icon('key')
- %span.nav-item-name
- = _('GPG Keys')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :gpg_keys, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_gpg_keys_path do
- %strong.fly-out-top-item-name
- = _('GPG Keys')
- = nav_link(controller: :preferences) do
- = link_to profile_preferences_path do
- .nav-icon-container
- = sprite_icon('preferences')
- %span.nav-item-name
- = _('Preferences')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :preferences, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_preferences_path do
- %strong.fly-out-top-item-name
- = _('Preferences')
- - if saved_replies_enabled?
- = nav_link(controller: :saved_replies) do
- = link_to profile_saved_replies_path do
- .nav-icon-container
- = sprite_icon('symlink')
- %span.nav-item-name
- = _('Saved Replies')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :saved_replies, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_saved_replies_path do
- %strong.fly-out-top-item-name
- = _('Saved Replies')
- = nav_link(controller: :active_sessions) do
- = link_to profile_active_sessions_path do
- .nav-icon-container
- = sprite_icon('monitor-lines')
- %span.nav-item-name
- = _('Active Sessions')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :active_sessions, html_options: { class: "fly-out-top-item" }) do
- = link_to profile_active_sessions_path do
- %strong.fly-out-top-item-name
- = _('Active Sessions')
- = nav_link(path: 'profiles#audit_log') do
- = link_to audit_log_profile_path do
- .nav-icon-container
- = sprite_icon('log')
- %span.nav-item-name
- = _('Authentication log')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'profiles#audit_log', html_options: { class: "fly-out-top-item" }) do
- = link_to audit_log_profile_path do
- %strong.fly-out-top-item-name
- = _('Authentication Log')
- = render_if_exists 'layouts/nav/sidebar/profile_usage_quotas_link'
-
- = render 'shared/sidebar_toggle_button'
+= render partial: 'shared/nav/sidebar', object: Sidebars::UserSettings::Panel.new(Sidebars::Context.new(current_user: current_user, container: current_user))
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/nav/sidebar/_user_profile.html.haml b/app/views/layouts/nav/sidebar/_user_profile.html.haml
new file mode 100644
index 00000000000..b24334f48c4
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_user_profile.html.haml
@@ -0,0 +1 @@
+= render partial: 'shared/nav/sidebar', object: Sidebars::UserProfile::Panel.new(Sidebars::Context.new(current_user: current_user, container: @user))
diff --git a/app/views/layouts/nav/sidebar/_your_work.html.haml b/app/views/layouts/nav/sidebar/_your_work.html.haml
index 0eba5045ab1..0da66c2e14e 100644
--- a/app/views/layouts/nav/sidebar/_your_work.html.haml
+++ b/app/views/layouts/nav/sidebar/_your_work.html.haml
@@ -1 +1 @@
-= render partial: 'shared/nav/sidebar', object: Sidebars::YourWork::Panel.new(Sidebars::Context.new(current_user: current_user, container: nil))
+= render partial: 'shared/nav/sidebar', object: Sidebars::YourWork::Panel.new(your_work_sidebar_context(current_user))
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 6ad6696b313..31d02324e68 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -18,7 +18,11 @@
:plain
window.uploads_path = "#{project_uploads_path(project)}";
+- content_for :before_content do
+ = render 'projects/invite_members_modal', project: @project
+
= 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/search.html.haml b/app/views/layouts/search.html.haml
index 44c4b14e90d..885fda10744 100644
--- a/app/views/layouts/search.html.haml
+++ b/app/views/layouts/search.html.haml
@@ -1,5 +1,7 @@
- page_title _("Search")
- header_title _("Search"), search_path
- add_page_specific_style 'page_bundles/search'
+- if show_super_sidebar?
+ - @force_desktop_expanded_sidebar = true
= 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 95a204a3319..ad566f262cf 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,9 +1,10 @@
- page_title _("Snippets")
-- header_title _("Snippets"), 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
- - @left_sidebar = true
- nav "your_work"
- content_for :page_specific_javascripts do
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/_issuable_csv_export.html.haml b/app/views/notify/_issuable_csv_export.html.haml
index 3b1fe90eaee..f7c6168ecb6 100644
--- a/app/views/notify/_issuable_csv_export.html.haml
+++ b/app/views/notify/_issuable_csv_export.html.haml
@@ -1,6 +1,8 @@
+- type = type.to_s.humanize.downcase
+
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
- project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;")
- = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s.titleize.downcase), project_link: project_link }
+ = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.titleize.downcase), project_link: project_link }
- if @truncated
%p
- = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of %{size_limit}. %{written_count} of %{count} %{issuables} have been included. Consider re-exporting with a narrower selection of %{issuables}.') % { written_count: @written_count, count: @count, issuables: type.to_s.pluralize, size_limit: @size_limit }
+ = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of %{size_limit}. %{written_count} of %{count} %{issuables} have been included. Consider re-exporting with a narrower selection of %{issuables}.') % { written_count: @written_count, count: @count, issuables: type.pluralize, size_limit: @size_limit }
diff --git a/app/views/notify/_issuable_csv_export.text.erb b/app/views/notify/_issuable_csv_export.text.erb
new file mode 100644
index 00000000000..a6e908803f5
--- /dev/null
+++ b/app/views/notify/_issuable_csv_export.text.erb
@@ -0,0 +1,7 @@
+<% type = type.to_s.humanize.downcase %>
+
+<%= _('Your CSV export of %{exported_objects} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { exported_objects: pluralize(@written_count, type), project_name: @project.full_name, project_url: project_url(@project) } %>
+
+<% if @truncated %>
+ <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of %{size_limit}. %{written_count} of %{total_count} %{object_type} have been included. Consider re-exporting with a narrower selection of %{object_type}.') % { written_count: @written_count, total_count: @count, size_limit: @size_limit, object_type: type.pluralize } %>
+<% end %>
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index 8853519fb8d..b23ba464c06 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -6,7 +6,7 @@
<%= sanitize_name(author.name) -%>
<% if discussion.nil? -%>
- <%= 'commented' -%>:
+ <%= 'commented' -%>
<% else -%>
<% if discussion.first_note == note -%>
<%= 'started a new discussion' -%>
@@ -16,9 +16,9 @@
<% if discussion.diff_discussion? -%>
<%= "on #{discussion.file_path}" -%>
<% end -%>
-<%= ':' -%>
-<%= " #{target_url}" -%>
<% end -%>
+<%= ':' -%>
+<%= " #{target_url}" -%>
<% if discussion&.diff_discussion? && discussion.on_text? -%>
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/export_work_items_csv_email.html.haml b/app/views/notify/export_work_items_csv_email.html.haml
new file mode 100644
index 00000000000..db842262049
--- /dev/null
+++ b/app/views/notify/export_work_items_csv_email.html.haml
@@ -0,0 +1 @@
+= render 'issuable_csv_export', type: :work_item
diff --git a/app/views/notify/export_work_items_csv_email.text.erb b/app/views/notify/export_work_items_csv_email.text.erb
new file mode 100644
index 00000000000..ec4aaa38886
--- /dev/null
+++ b/app/views/notify/export_work_items_csv_email.text.erb
@@ -0,0 +1 @@
+<%= render 'issuable_csv_export', type: :work_item %>
diff --git a/app/views/notify/import_work_items_csv_email.html.haml b/app/views/notify/import_work_items_csv_email.html.haml
new file mode 100644
index 00000000000..d4326d6bdf9
--- /dev/null
+++ b/app/views/notify/import_work_items_csv_email.html.haml
@@ -0,0 +1,49 @@
+- info_style = 'font-size:16px; text-align:center; line-height:24px;'
+- error_style = 'font-size:13px; text-align:center; line-height:16px; color:#dd2b0e;'
+
+%p{ style: info_style }
+ - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none;")
+ = s_('Notify|Here are the results for your CSV import for %{project_link}.').html_safe % { project_link: project_link }
+
+- success_lines = @results[:success]
+%p{ style: info_style }
+ - if success_lines > 0
+ - work_items = n_('%d work item', '%d work items', success_lines) % success_lines
+ = s_('Notify|%{work_items} successfully imported.') % { work_items: work_items }
+ - else
+ = s_('Notify|No work items have been imported.')
+
+ - if @results[:parse_error]
+ %p{ style: info_style }
+ = s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.')
+
+- type_errors = @results[:type_errors]
+- if type_errors
+ %p{ style: info_style }
+ = s_('Notify|Some values in the "type" column could not be matched with supported work item types:')
+
+ - blank_lines = type_errors[:blank]
+ - missing_lines = type_errors[:missing]
+ - disallowed_lines = type_errors[:disallowed]
+
+ - if blank_lines.present?
+ %p{ style: error_style }
+ = s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type is empty.') % { singular_or_plural_line: n_('Line', 'Lines', blank_lines.size), error_lines: blank_lines.join(', ') }
+
+ - if missing_lines.present?
+ %p{ style: error_style }
+ = s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type cannot be found or is not supported.') % { singular_or_plural_line: n_('Line', 'Lines', missing_lines.size), error_lines: missing_lines.join(', ') }
+
+ - if disallowed_lines.present?
+ %p{ style: error_style }
+ = s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type is not available. Please check your license and permissions.') % { singular_or_plural_line: n_('Line', 'Lines', disallowed_lines.size), error_lines: disallowed_lines.join(', ') }
+
+- error_lines = @results[:error_lines]
+- if error_lines.present?
+ %p{ style: error_style }
+ = s_('Notify|Errors found on %{singular_or_plural_line}: %{error_lines}. Please check that these lines have the following fields: %{required_headers}.') % { singular_or_plural_line: n_('line', 'lines', error_lines.size), required_headers: WorkItems::ImportCsvService.required_headers.join(', '),
+ error_lines: error_lines.join(', ') }
+
+- if error_lines.present? || type_errors
+ %p{ style: info_style }
+ = s_('Notify|Please fix the lines with errors and try the CSV import again.')
diff --git a/app/views/notify/import_work_items_csv_email.text.erb b/app/views/notify/import_work_items_csv_email.text.erb
new file mode 100644
index 00000000000..059dbc95cbc
--- /dev/null
+++ b/app/views/notify/import_work_items_csv_email.text.erb
@@ -0,0 +1,48 @@
+<%= s_('Notify|Here are the results for your CSV import for %{project_name} (%{project_link}).') % { project_name: @project.full_name, project_link: project_url(@project) } %>
+
+<% success_lines = @results[:success] %>
+<% if success_lines > 0 %>
+ <% work_items = n_('%d work item', '%d work items', success_lines) % success_lines %>
+ <%= s_('Notify|%{work_items} successfully imported.') % { work_items: work_items } %>
+<% else %>
+ <%= s_('Notify|No work items have been imported.') %>
+
+ <% if @results[:parse_error] %>
+ <%= s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.') %>
+ <% end %>
+<% end %>
+
+<% type_errors = @results[:type_errors] %>
+<%
+ if type_errors
+ blank_lines = type_errors[:blank]
+ missing_lines = type_errors[:missing]
+ disallowed_lines = type_errors[:disallowed]
+%>
+ <%= s_('Notify|Some values in the "type" column could not be matched with supported work item types:') %>
+
+ <% if blank_lines.present? %>
+ <%= s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type is empty.') % { singular_or_plural_line: n_('Line', 'Lines', blank_lines.size), error_lines: blank_lines.join(', ') } %>
+ <% end %>
+
+ <% if missing_lines.present? %>
+ <%= s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type cannot be found or is not supported.') % { singular_or_plural_line: n_('Line', 'Lines', missing_lines.size), error_lines: missing_lines.join(', ') } %>
+ <% end %>
+
+ <% if disallowed_lines.present? %>
+ <%= s_('Notify|%{singular_or_plural_line} %{error_lines}: Work item type is not available. Please check your license and permissions.') % { singular_or_plural_line: n_('Line', 'Lines', disallowed_lines.size), error_lines: disallowed_lines.join(', ') } %>
+ <% end %>
+<% end %>
+
+<%
+ error_lines = @results[:error_lines]
+ if error_lines.present?
+%>
+ <%= s_('Notify|Errors found on %{singular_or_plural_line}: %{error_lines}. Please check that these lines have the following fields: %{required_headers}.') % { singular_or_plural_line: n_('line', 'lines', error_lines.size), required_headers: WorkItems::ImportCsvService.required_headers.join(', '),
+ error_lines: error_lines.join(', ') } %>
+<% end %>
+
+<% if error_lines.present? || type_errors %>
+ <%= s_('Notify|Please fix the lines with errors and try the CSV import again.') %>
+<% end %>
+
diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb
index cf2910c4014..5b6c151e4ce 100644
--- a/app/views/notify/issues_csv_email.text.erb
+++ b/app/views/notify/issues_csv_email.text.erb
@@ -1,5 +1 @@
-<%= _('Your CSV export of %{written_count} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { written_count: pluralize(@written_count, 'issue'), project_name: @project.full_name, project_url: project_url(@project) } %>
-
-<% if @truncated %>
- <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of %{size_limit}. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count, size_limit: @size_limit } %>
-<% end %>
+<%= render 'issuable_csv_export', type: :issue %>
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index 61c9b130da8..7d10bc77126 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,4 +1,4 @@
-= sprintf(s_('Notify|Merge request %{merge_request} was %{mr_status}'), { merge_request: @merge_request.to_reference, mr_status: sanitize_name(@updated_by.name) })
+= sprintf(s_('Notify|Merge request %{merge_request} was %{mr_status} by %{updated_by}'), { merge_request: @merge_request.to_reference, mr_status: @mr_status, updated_by: sanitize_name(@updated_by.name) })
= sprintf(s_('Notify|Merge request URL: %{merge_request_url}'), { merge_request_url: project_merge_request_url(@merge_request.target_project, @merge_request) })
diff --git a/app/views/notify/merge_requests_csv_email.text.erb b/app/views/notify/merge_requests_csv_email.text.erb
index 78d11dde69f..c5dec164a4d 100644
--- a/app/views/notify/merge_requests_csv_email.text.erb
+++ b/app/views/notify/merge_requests_csv_email.text.erb
@@ -1,5 +1 @@
-<%= _('Your CSV export of %{written_count} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { written_count: pluralize(@written_count, 'merge request'), project_name: @project.full_name, project_url: project_url(@project) } %>
-
-<% if @truncated %>
- <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of %{size_limit}. %{written_count} of %{merge_requests_count} merge requests have been included. Consider re-exporting with a narrower selection of merge requests.') % { written_count: @written_count, merge_requests_count: @merge_requests_count, size_limit: @size_limit} %>
-<% end %>
+<%= render 'issuable_csv_export', type: :merge_request %>
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/new_review_email.text.erb b/app/views/notify/new_review_email.text.erb
index 7bf878aefd0..69cb33b05df 100644
--- a/app/views/notify/new_review_email.text.erb
+++ b/app/views/notify/new_review_email.text.erb
@@ -4,7 +4,6 @@
--
<% @notes.each_with_index do |note, index| %>
- <!-- Get preloaded note discussion-->
<% discussion = @discussions[note.discussion_id] if note.part_of_discussion?%>
<% target_url = project_merge_request_url(@project, @merge_request, anchor: "note_#{note.id}") %>
<%= render 'note_email', note: note, diff_limit: 3, target_url: target_url, discussion: discussion, author: @author %>
diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml
index 1bc2cc15616..c0b334fba94 100644
--- a/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml
+++ b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml
@@ -8,4 +8,4 @@
- docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting')
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_url }
- link_end = '</a>'.html_safe
- = _("Please follow the %{link_start}Let\'s Encrypt troubleshooting instructions%{link_end} to re-obtain your Let's Encrypt certificate.").html_safe % { link_start: link_start, link_end: link_end }
+ = _("Please follow the %{link_start}Let's Encrypt troubleshooting instructions%{link_end} to re-obtain your Let's Encrypt certificate.").html_safe % { link_start: link_start, link_end: link_end }
diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml
index 6f20d11c966..feb88d2df39 100644
--- a/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml
+++ b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml
@@ -4,4 +4,4 @@
#{_('Domain')}: #{project_pages_domain_url(@project, @domain)}
- docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting')
-= _("Please follow the Let\'s Encrypt troubleshooting instructions to re-obtain your Let's Encrypt certificate: %{docs_url}.").html_safe % { docs_url: docs_url }
+= _("Please follow the Let's Encrypt troubleshooting instructions to re-obtain your Let's Encrypt certificate: %{docs_url}.").html_safe % { docs_url: docs_url }
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..c072744c43c
--- /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.finished?
+ %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..65b0cba5616
--- /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.finished? %>
+ <%= 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/notify/two_factor_otp_attempt_failed_email.html.haml b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml
index 83f028af500..968d84f700d 100644
--- a/app/views/notify/two_factor_otp_attempt_failed_email.html.haml
+++ b/app/views/notify/two_factor_otp_attempt_failed_email.html.haml
@@ -9,7 +9,7 @@
%tr
%td{ style: "#{default_font}vertical-align:middle;color:#ffffff;text-align:center;" }
%span
- = _("We detected an attempt to sign in to your %{host} account using a wrong two-factor authentication code") % { host: Gitlab.config.gitlab.host }
+ = _("GitLab detected an attempt to sign in to your %{host} account using an incorrect verification code") % { host: Gitlab.config.gitlab.host }
%tr.spacer
%td{ style: spacer_style }
&nbsp;
@@ -43,7 +43,7 @@
%tr{ style: 'width:100%;' }
%td{ style: "#{default_style}text-align:center;" }
- password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') }
- = _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.')
+ = _('If you recently tried to sign in, but mistakenly entered an incorrect verification code, you can ignore this email.')
- if password_authentication_enabled_for_web?
%p
diff --git a/app/views/notify/two_factor_otp_attempt_failed_email.text.haml b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml
index 8718ab034ff..9760dd3d985 100644
--- a/app/views/notify/two_factor_otp_attempt_failed_email.text.haml
+++ b/app/views/notify/two_factor_otp_attempt_failed_email.text.haml
@@ -1,7 +1,7 @@
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
-= _('We detected an attempt to sign in to your %{host} account using a wrong two-factor authentication code, from the following IP address: %{ip}, at %{time}') % { host: Gitlab.config.gitlab.host, ip: @ip, time: @time }
+= _('GitLab detected an attempt to sign in to your %{host} account using an incorrect verification code from the following IP address: %{ip}, at %{time}') % { host: Gitlab.config.gitlab.host, ip: @ip, time: @time }
-= _('If you recently tried to sign in, but mistakenly entered a wrong two-factor authentication code, you may ignore this email.')
+= _('If you recently tried to sign in, but mistakenly entered an incorrect verification code, you can ignore this email.')
= _('If you did not recently try to sign in, you should immediately change your password: %{password_link}.') % { password_link: help_page_url('user/profile/user_passwords', anchor: 'change-your-password') }
= _('Make sure you choose a strong, unique password.')
diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml
index f8a0ae1352c..e252af78060 100644
--- a/app/views/notify/unknown_sign_in_email.html.haml
+++ b/app/views/notify/unknown_sign_in_email.html.haml
@@ -24,6 +24,11 @@
= Gitlab.config.gitlab.host
%tr
%td{ style: "#{default_style}border-top:1px solid #ededed;" }
+ = _('User')
+ %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
+ #{sanitize_name(@user.name)} (#{@user.username})
+ %tr
+ %td{ style: "#{default_style}border-top:1px solid #ededed;" }
= _('IP Address')
%td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
%span.muted{ style: "color:#333333;text-decoration:none;" }
diff --git a/app/views/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml
index f3e318f0d15..fbe35c502da 100644
--- a/app/views/notify/unknown_sign_in_email.text.haml
+++ b/app/views/notify/unknown_sign_in_email.text.haml
@@ -1,4 +1,4 @@
-= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+= _('Hi %{user_name} (%{user_username})!') % { user_name: sanitize_name(@user.name), user_username: @user.username }
= _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip }
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..0505a205333 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,5 +1,5 @@
- page_title _('Account')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
- 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..54736153223 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -1,5 +1,5 @@
- page_title _('Active Sessions')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.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..44cfbc1f74f 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,5 +1,5 @@
- page_title _('Authentication log')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index ce2fc2098c5..afc3894c23b 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -1,21 +1,5 @@
-- integration = chat_name.integration
-- project = integration&.project
%tr
%td
- %strong
- - if project.present? && can?(current_user, :read_project, project)
- = link_to project.full_name, project_path(project)
- - else
- .light= _('Not applicable.')
- %td
- %strong
- - if integration.present? && can?(current_user, :admin_project, project)
- = link_to integration.title, edit_project_settings_integration_path(project, integration)
- - elsif integration.present?
- = integration.title
- - else
- .light= _('Not applicable.')
- %td
= chat_name.team_domain
%td
= chat_name.chat_name
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 41bd81d0250..264ee040d7d 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,7 +1,8 @@
- page_title _('Chat')
-- @content_class = "limit-container-width" unless fluid_layout
+- @hide_search_settings = true
+- @force_desktop_expanded_sidebar = true
-.row.gl-mt-3.js-search-settings-section
+.row.gl-mt-5.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
= page_title
@@ -14,11 +15,9 @@
- if @chat_names.present?
.table-responsive
- %table.table.chat-names
+ %table.table
%thead
%tr
- %th= _('Project')
- %th= _('Service')
%th= _('Team domain')
%th= _('Nickname')
%th= _('Last used')
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index 303b8b10027..bc30ccc5821 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -1,14 +1,28 @@
-%h1.page-title.gl-font-size-h-display
- = _("Authorization required")
-%main{ :role => "main" }
- %p.h4
- = html_escape(_("Authorize %{user} to use your account?")) % { user: tag.strong(@chat_name_params[:chat_name]) }
+- @hide_search_settings = true
- %hr
- .actions
- = form_tag profile_chat_names_path, method: :post do
- = hidden_field_tag :token, @chat_name_token.token
- = submit_tag _("Authorize"), class: "gl-button btn btn-confirm wide float-left"
- = form_tag deny_profile_chat_names_path, method: :delete do
- = hidden_field_tag :token, @chat_name_token.token
- = submit_tag _("Deny"), class: "gl-button btn btn-danger gl-ml-3"
+%main{ role: 'main' }
+ .gl-max-w-80.gl-mx-auto.gl-mt-6
+ = render Pajamas::CardComponent.new do |c|
+ - c.header do
+ %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
+ = 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
+ %li= s_('SlackIntegration|Create and read issue data and comments.')
+ %li= s_('SlackIntegration|Perform deployments.')
+ %li= s_('SlackIntegration|Run ChatOps jobs.')
+ %p.gl-mb-0
+ = s_("SlackIntegration|You don't have to reauthorize this application if the permission scope changes in future releases.")
+ - c.footer do
+ .gl-display-flex
+ = form_tag profile_chat_names_path, method: :post do
+ = hidden_field_tag :token, @chat_name_token.token
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :danger) do
+ = _('Authorize')
+ = form_tag deny_profile_chat_names_path, method: :delete do
+ = hidden_field_tag :token, @chat_name_token.token
+ = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-ml-3' }) do
+ = _('Deny')
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..c16f3c3b12b 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -1,5 +1,5 @@
- page_title _('Emails')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
@@ -56,7 +56,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/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index ec48a611377..d52b16814c0 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -18,7 +18,7 @@
%code= subkey.fingerprint
.float-right
%span.key-created-at
- = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at) }
+ = html_escape(s_('Profiles|Created %{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at) }
= link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-icon btn-danger gl-ml-3" do
%span.sr-only= _('Remove')
= sprite_icon('remove')
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 539a0cd1f0e..b21a4da16b9 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,6 +1,6 @@
- page_title _('GPG Keys')
- add_page_specific_style 'page_bundles/profile'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index 825fb98782a..288007ec806 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -35,7 +35,7 @@
= ssh_key_usage_types.invert[key.usage_type]
.gl-display-flex.gl-float-right
- if key.can_delete?
- - if key.signing? && !is_admin && Feature.enabled?(:revoke_ssh_signatures)
+ - if key.signing? && !is_admin
= render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-confirm-modal-button', data: ssh_key_revoke_modal_data(key, revoke_profile_key_path(key)) }) do
= _('Revoke')
.gl-pl-3
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 69e92b9e508..e7c0cf813b5 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,6 +1,6 @@
- page_title _('SSH Keys')
- add_page_specific_style 'page_bundles/profile'
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.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..a632c450eda 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,6 +1,6 @@
- add_page_specific_style 'page_bundles/notifications'
- page_title _('Notifications')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%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..4fdf80c1eb1 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title _('Edit Password')
- page_title _('Password')
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.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..57c0badd033 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -2,7 +2,7 @@
- page_title s_('AccessTokens|Personal Access Tokens')
- type = _('personal access token')
- type_plural = _('personal access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.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 b10d05efc4f..7f8858411ca 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,11 +1,11 @@
- 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
- @themes = Gitlab::Themes::available_themes.to_json
- data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
+- @force_desktop_expanded_sidebar = true
- Gitlab::Themes.each do |theme|
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
@@ -17,6 +17,9 @@
= s_('Preferences|Color theme')
%p
= s_('Preferences|Customize the color of GitLab.')
+ - if show_super_sidebar?
+ %p
+ = s_('Preferences|Note: You have the new navigation enabled, so only Dark Mode theme significantly changes GitLab\'s appearance.')
.col-lg-8.application-theme
.row
- Gitlab::Themes.each do |theme|
@@ -34,7 +37,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
@@ -66,7 +69,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
@@ -76,7 +79,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
@@ -137,21 +140,6 @@
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select'
.col-sm-12
%hr
- - if Feature.enabled?(:vscode_web_ide, current_user)
- .row.js-preferences-form.js-search-settings-section
- .col-lg-4.profile-settings-sidebar#web-ide
- %h4.gl-mt-0
- = s_('Preferences|Web IDE')
- %p
- = s_('Preferences|The Web IDE Beta is the default Web IDE experience.')
- = link_to _('Learn more'), help_page_path('user/project/web_ide_beta/index.md'), target: '_blank', rel: 'noopener noreferrer'
- .col-lg-8
- .form-group
- = f.gitlab_ui_checkbox_component :use_legacy_web_ide,
- s_('Preferences|Opt out of the Web IDE Beta'),
- help_text: s_('Preferences|The Web IDE remains available alongside the Beta.')
- .col-sm-12
- %hr
.row.js-preferences-form.js-search-settings-section
.col-lg-4.profile-settings-sidebar#time-preferences
%h4.gl-mt-0
@@ -165,8 +153,21 @@
= f.gitlab_ui_checkbox_component :time_display_relative,
s_('Preferences|Use relative times'),
help_text: s_('Preferences|For example: 30 minutes ago.')
- - if Feature.enabled?(:user_time_settings)
+ - if Feature.enabled?(:disable_follow_users, @user)
+ .row.js-preferences-form.js-search-settings-section
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar#enabled_following
+ %h4.gl-mt-0
+ = s_('Preferences|Enable follow users feature')
+ %p
+ = s_('Preferences|Turns on or off the ability to follow or be followed by other users.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'follow-users'), target: '_blank', rel: 'noopener noreferrer'
+ .col-lg-8
.form-group
- = f.gitlab_ui_checkbox_component :time_format_in_24h, s_('Preferences|Display time in 24-hour format')
+ = f.gitlab_ui_checkbox_component :enabled_following,
+ s_('Preferences|Enable follow users')
+
#js-profile-preferences-app{ data: data_attributes }
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 5ffffb80d97..930f4f5c397 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -1,8 +1,8 @@
- 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
+- @force_desktop_expanded_sidebar = true
= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.row.js-search-settings-section
@@ -53,7 +53,9 @@
= status_form.hidden_field :emoji, data: { js_name: 'emoji' }
= status_form.hidden_field :message, data: { js_name: 'message' }
= status_form.hidden_field :availability, data: { js_name: 'availability' }
- = status_form.hidden_field :clear_status_after, value: @user.status&.clear_status_at&.to_s(:iso8601), data: { js_name: 'clearStatusAfter' }
+ = status_form.hidden_field :clear_status_after,
+ value: user_clear_status_at(@user),
+ data: { js_name: 'clearStatusAfter' }
.col-lg-12
%hr
.row.user-time-preferences.js-search-settings-section
@@ -106,9 +108,18 @@
.form-group.gl-form-group
- external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page')
- external_accounts_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: external_accounts_help_url }
- - external_accounts_docs_link = s_('Profiles|Your Discord user ID. Should be between %{min} and %{max} digits long. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}').html_safe % { min: '17', max: '20', external_accounts_link_start: external_accounts_link_start, external_accounts_link_end: '</a>'.html_safe }
+ - external_accounts_docs_link = s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}').html_safe % { external_accounts_link_start: external_accounts_link_start, external_accounts_link_end: '</a>'.html_safe }
+ - min_discord_length = 17
+ - max_discord_length = 20
= f.label :discord
- = f.text_field :discord, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|User ID")
+ = f.text_field :discord,
+ class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
+ placeholder: s_("Profiles|User ID"),
+ data: { min_length: min_discord_length,
+ min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
+ max_length: max_discord_length,
+ max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
+ allow_empty: true}
%small.form-text.text-gl-muted
= external_accounts_docs_link
@@ -147,8 +158,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 3add3af3c65..9cc7f6bdd49 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,31 +1,18 @@
- 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
.col-lg-4
%h4.gl-mt-0
- = _('Register Two-Factor Authenticator')
+ = _('Register a one-time password authenticator')
%p
= _('Use a one-time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).')
.col-lg-8
- if current_user.two_factor_otp_enabled?
%p
= _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.")
- %p
- = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
- - if @error
- = render Pajamas::AlertComponent.new(title: @error[:message],
- variant: :danger,
- alert_options: { class: 'gl-mb-3' },
- 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' } }
-
- else
%p
- register_2fa_token = _('We recommend using cloud-based authenticator applications that can restore access if you lose your hardware device.')
@@ -36,8 +23,8 @@
.gl-p-2.gl-mb-3{ style: 'background: #fff' }
= raw @qr_code
.col-md-8
- .gl-card
- .gl-card-body
+ = render Pajamas::CardComponent.new do |c|
+ - c.body do
%p.gl-mt-0.gl-mb-3.gl-font-weight-bold
= _("Can't scan the code?")
%p.gl-mt-0.gl-mb-3
@@ -58,15 +45,15 @@
= 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'
- .form-group
- = label_tag :pin_code, _('Pin code'), class: "label-bold"
- = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
- if current_password_required?
.form-group
= label_tag :current_password, _('Current password'), class: 'label-bold'
= password_field_tag :current_password, nil, autocomplete: 'current-password', required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' }
%p.form-text.text-muted
= _('Your current password is required to register a two-factor authenticator app.')
+ .form-group
+ = label_tag :pin_code, _('Enter verification code'), class: "label-bold"
+ = text_field_tag :pin_code, nil, autocomplete: 'off', inputmode: 'numeric', class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' }
.gl-mt-3
= submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' }
@@ -75,37 +62,27 @@
.row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
- - if webauthn_enabled
- = _('Register WebAuthn Device')
- - else
- = _('Register Universal Two-Factor (U2F) Device')
+ = _('Register a WebAuthn device')
%p
- = _('Set up a hardware device as a second factor to sign in.')
+ = _('Set up a hardware device to enable two-factor authentication (2FA).')
%p
- - if webauthn_enabled
- = _("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.")
+ - 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 U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser.")
+ = _("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.")
.col-lg-8
- - registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- - if registration.errors.present?
- = form_errors(registration)
- - if webauthn_enabled
- = render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
- - else
- = render "authentication/register", target_path: create_u2f_profile_two_factor_auth_path
+ - if @webauthn_registration.errors.present?
+ = form_errors(@webauthn_registration)
+ = render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
%hr
%h5
- - if webauthn_enabled
- = _('WebAuthn Devices (%{length})') % { length: @registrations.length }
- - else
- = _('U2F Devices (%{length})') % { length: @registrations.length }
+ = _('WebAuthn Devices (%{length})') % { length: @registrations.length }
- if @registrations.present?
.table-responsive
- %table.table.table-bordered.u2f-registrations
+ %table.table
%colgroup
%col{ width: "50%" }
%col{ width: "30%" }
@@ -134,7 +111,28 @@
- else
.settings-message.text-center
- - if webauthn_enabled
- = _("You don't have any WebAuthn devices registered yet.")
- - else
- = _("You don't have any U2F devices registered yet.")
+ = _("You don't have any WebAuthn devices registered yet.")
+
+ %hr
+
+ .row.gl-mt-3
+ .col-lg-4
+ %h4.gl-mt-0
+ = _('Disable two-factor authentication')
+ %p
+ = _('Use this section to disable your one-time password authenticator and WebAuthn devices. You can also generate new recovery codes.')
+ .col-lg-8
+ - if current_user.two_factor_enabled?
+ %p
+ = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
+ - if @error
+ = render Pajamas::AlertComponent.new(title: @error[:message],
+ variant: :danger,
+ alert_options: { class: 'gl-mb-3' },
+ 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: { 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/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 9962e03995b..19943aa68a3 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,4 +1,4 @@
-.form-actions.gl-display-flex
+.gl-display-flex.gl-mt-7
- submit_button_options = { type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } } }
= render Pajamas::ButtonComponent.new(**submit_button_options) do
= _('Commit changes')
diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml
index 489d303c5b9..29551505a7e 100644
--- a/app/views/projects/_deletion_failed.html.haml
+++ b/app/views/projects/_deletion_failed.html.haml
@@ -4,6 +4,6 @@
= render Pajamas::AlertComponent.new(variant: :warning,
dismissible: false,
alert_options: { class: 'project-deletion-failed-message' }) do |c|
- = c.body do
+ - c.with_body do
This project was scheduled for deletion, but failed with the following message:
= project.delete_error
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index e2d1a50ae5e..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
+#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 b9aeed188fa..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-my-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/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 412c91544a6..947a1007fd5 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -11,7 +11,7 @@
= render Pajamas::AlertComponent.new(variant: :tip,
alert_options: { class: 'gl-my-3' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
- docs_link_url = help_page_path('user/group/import/index') + '#migrate-groups-by-direct-transfer-recommended'
- docs_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
= html_escape(_("Importing GitLab projects? Migrating GitLab projects when migrating groups by direct transfer is in Beta. %{link_start}Learn more.%{link_end}")) % { link_start: docs_link, link_end: '</a>'.html_safe }
@@ -21,11 +21,6 @@
= render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do
= _('GitLab export')
- - if gitlab_import_enabled?
- %div
- = render Pajamas::ButtonComponent.new(href: status_import_gitlab_path(namespace_id: namespace_id), icon: 'tanuki', button_options: { class: "import_gitlab js-import-project-btn #{'js-how-to-import-link' unless gitlab_import_configured?}", data: { modal_title: _("Import projects from GitLab.com"), modal_message: import_from_gitlab_message, platform: 'gitlab_com', **tracking_attrs_data(track_label, 'click_button', 'gitlab_com') } }) do
- GitLab.com
-
- if github_import_enabled?
%div
= render Pajamas::ButtonComponent.new(href: new_import_github_path(namespace_id: namespace_id), icon: 'github', button_options: { class: 'js-import-github js-import-project-btn', data: { platform: 'github', **tracking_attrs_data(track_label, 'click_button', 'github') } }) do
@@ -63,11 +58,6 @@
= render Pajamas::ButtonComponent.new(href: new_import_manifest_path(namespace_id: namespace_id), icon: 'doc-text', button_options: { class: 'import_manifest js-import-project-btn', data: { platform: 'manifest_file', **tracking_attrs_data(track_label, 'click_button', 'manifest_file') } }) do
= _('Manifest file')
- - if phabricator_import_enabled?
- %div
- = render Pajamas::ButtonComponent.new(href: new_import_phabricator_path(namespace_id: namespace_id), icon: 'issues', button_options: { class: 'import_phabricator js-import-project-btn', data: { platform: 'phabricator', track_label: "#{track_label}", track_action: "click_button", track_property: "phabricator" } }) do
- = _('Phabricator tasks')
-
= render_if_exists "projects/gitee_import_button", namespace_id: namespace_id, track_label: track_label
diff --git a/app/views/projects/_invite_members_empty_project.html.haml b/app/views/projects/_invite_members_empty_project.html.haml
index 5bc53339bf0..18d06c7d0bb 100644
--- a/app/views/projects/_invite_members_empty_project.html.haml
+++ b/app/views/projects/_invite_members_empty_project.html.haml
@@ -6,8 +6,4 @@
.js-invite-members-trigger{ data: { variant: 'confirm',
classes: 'gl-mb-8 gl-xs-w-full',
display_text: s_('InviteMember|Invite members'),
- trigger_source: 'project-empty-page',
- event: 'click_button',
- label: 'invite_members_empty_project' } }
-
-= render 'projects/invite_members_modal', project: @project
+ trigger_source: 'project-empty-page' } }
diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml
index 53f74a0f270..a1b0bdd6c56 100644
--- a/app/views/projects/_invite_members_modal.html.haml
+++ b/app/views/projects/_invite_members_modal.html.haml
@@ -2,5 +2,5 @@
.js-invite-members-modal{ data: { is_project: 'true',
access_levels: ProjectMember.permissible_access_level_roles(current_user, project).to_json,
- reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s,
+ reload_page_on_submit: current_path?('project_members#index').to_s,
help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) }
diff --git a/app/views/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml
deleted file mode 100644
index b96a7608ce2..00000000000
--- a/app/views/projects/_invite_members_side_nav_link.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-.js-invite-members-trigger{ data: { trigger_source: 'project-side-nav',
- icon: 'users',
- display_text: title,
- trigger_element: 'side-nav',
- qa_selector: 'invite_members_sidebar_button' } }
-
-= render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu }
-= render 'projects/invite_members_modal', project: project
diff --git a/app/views/projects/_invite_members_top_nav_link.html.haml b/app/views/projects/_invite_members_top_nav_link.html.haml
new file mode 100644
index 00000000000..35a8d4d9944
--- /dev/null
+++ b/app/views/projects/_invite_members_top_nav_link.html.haml
@@ -0,0 +1,5 @@
+- data = local_assigns.fetch(:data)
+- data[:display_text] = local_assigns.fetch(:display_text)
+- data[:icon] = local_assigns.fetch(:icon)
+
+.js-invite-members-trigger{ data: data }
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index 5b493772f0a..89c91887d19 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -3,7 +3,7 @@
= render Pajamas::AlertComponent.new(variant: :success,
alert_options: { class: 'gl-mt-3' },
close_button_options: { class: 'js-close-banner' }) do |c|
- = c.body do
+ - c.with_body do
%span= s_("LastPushEvent|You pushed to")
%strong.gl-display-inline-flex.gl-max-w-50p{ data: { toggle: 'tooltip' }, title: event.ref_name }
= link_to event.ref_name, project_commits_path(event.project, event.ref_name), class: 'ref-name gl-text-truncate'
@@ -15,6 +15,6 @@
#{time_ago_with_tooltip(event.created_at)}
- if create_mr_button_from_event?(event)
- = c.actions do
+ - c.with_actions do
= render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { data: { qa_selector: 'create_merge_request_button' }}) do
= _('Create merge request')
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 53a1abdff33..70a2476c8e5 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -46,7 +46,7 @@
= render Pajamas::AlertComponent.new(alert_options: { class: "gl-mb-4 gl-display-none js-user-readme-repo" },
dismissible: false,
variant: :success) do |c|
- = c.body do
+ - c.with_body do
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
= html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
@@ -81,21 +81,21 @@
= render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_readme]',
checked: true,
checkbox_options: { data: { qa_selector: 'initialize_with_readme_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_readme' } }) do |c|
- = c.label do
+ - c.with_label do
= s_('ProjectsNew|Initialize repository with a README')
- = c.help_text do
+ - c.with_help_text do
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
.form-group
= render Pajamas::CheckboxTagComponent.new(name: 'project[initialize_with_sast]',
checkbox_options: { data: { qa_selector: 'initialize_with_sast_checkbox', track_label: track_label, track_action: 'activate_form_input', track_property: 'init_with_sast' } }) do |c|
- = c.label do
+ - c.with_label do
= s_('ProjectsNew|Enable Static Application Security Testing (SAST)')
- = c.help_text do
+ - c.with_help_text do
= s_('ProjectsNew|Analyze your source code for known security vulnerabilities.')
= link_to _('Learn more.'), help_page_path('user/application_security/sast/index'), target: '_blank', rel: 'noopener noreferrer', data: { track_action: 'followed' }
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/675
= render_if_exists 'shared/other_project_options', f: f, visibility_level: visibility_level, track_label: track_label
= f.submit _('Create project'), class: "js-create-project-button", data: { qa_selector: 'project_create_button', track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }, pajamas_button: true
-= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
+= link_to _('Cancel'), @parent_group || dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index 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/airflow/dags/index.html.haml b/app/views/projects/airflow/dags/index.html.haml
deleted file mode 100644
index d631d084db1..00000000000
--- a/app/views/projects/airflow/dags/index.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- breadcrumb_title s_('Airflow|Airflow DAGs')
-- page_title s_('Airflow|Airflow DAGs')
-
-.page-title-holder
- %h1.page-title.gl-font-size-h-display= s_('Airflow|Airflow DAGs')
-
-#js-show-airflow-dags{ data: {
- dags: @dags.to_json,
- pagination: @pagination.to_json
- }
-}
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 3359ea5f63b..ccda06c7e4c 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -12,7 +12,7 @@
.nav-block
%ul.breadcrumb.repo-breadcrumb
%li.breadcrumb-item
- = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build)
+ = link_to _('Artifacts'), browse_project_job_artifacts_path(@project, @build)
- path_breadcrumbs do |title, path|
%li.breadcrumb-item
= link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path)
diff --git a/app/views/projects/aws/configuration/index.html.haml b/app/views/projects/aws/configuration/index.html.haml
new file mode 100644
index 00000000000..27d8652c625
--- /dev/null
+++ b/app/views/projects/aws/configuration/index.html.haml
@@ -0,0 +1,7 @@
+- add_to_breadcrumbs _('AWS'), project_aws_path(@project)
+- breadcrumb_title s_('CloudSeed|Configuration')
+- page_title s_('CloudSeed|Configuration')
+
+- @content_class = "limit-container-width" unless fluid_layout
+
+#js-aws-configuration{ data: @js_data }
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 74b85a93c8e..e2cad2fb3d7 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,6 +1,17 @@
- page_title _("Blame"), @blob.path, @ref
- add_page_specific_style 'page_bundles/tree'
-- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page }
+- 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_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_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
@@ -26,11 +37,20 @@
.blame-table-wrapper
= render partial: 'page'
- - if @blame_pagination && @blame_pagination.total_pages > 1
+ - if @blame_mode.streaming?
+ #blame-stream-container.blame-stream-container
+
+ - 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
- = _('For faster browsing, not all history is shown.')
- = render Pajamas::ButtonComponent.new(href: namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, no_pagination: true), size: :small, button_options: { class: 'gl-mt-3' }) do |c|
- = _('View entire blame')
+ = render Pajamas::ButtonComponent.new(href: entire_blame_path(@id, @project), size: :small, button_options: { class: 'gl-mt-3' }) do |c|
+ = _('Show full blame')
+
+ - 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/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 7c2caf34fd1..79b13dc861a 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -2,7 +2,7 @@
.nav-block
.tree-ref-container
.tree-ref-holder
- = render 'shared/ref_switcher', destination: 'blob'
+ #js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project), ref: current_ref, ref_type: @ref_type.to_s } }
%ul.breadcrumb.repo-breadcrumb
%li.breadcrumb-item
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 87a6b54d697..621cd251bdf 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -2,8 +2,8 @@
- file_name = params[:id].split("/").last ||= ""
- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name)
-.file-holder-bottom-radius.file-holder.file.gl-mb-3
- .js-file-title.file-title.gl-display-flex.gl-align-items-center.clearfix{ data: { current_action: action } }
+.file-holder.file.gl-mb-3
+ .js-file-title.file-title.gl-display-flex.gl-align-items-center.gl-rounded-top-base{ data: { current_action: action } }
.editor-ref.block-truncated.has-tooltip{ title: ref }
= sprite_icon('branch', size: 12)
= ref
@@ -16,7 +16,7 @@
- if current_action?(:new) || current_action?(:create)
%span.float-left.gl-mr-3
\/
- = text_field_tag 'file_name', params[:file_name], placeholder: "File name", data: { qa_selector: 'file_name_field' },
+ = text_field_tag 'file_name', params[:file_name], placeholder: "Filename", data: { qa_selector: 'file_name_field' },
required: true, class: 'form-control gl-form-input new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '')
= render 'template_selectors'
- if should_suggest_gitlab_ci_yml?
@@ -26,15 +26,18 @@
dismiss_key: @project.id,
human_access: human_access } }
- .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end
- - if is_markdown
- - unless Feature.enabled?(:source_editor_toolbar, current_user)
- = render 'shared/blob/markdown_buttons', show_fullscreen_button: false, supports_file_upload: false
- %span.soft-wrap-toggle
- = render Pajamas::ButtonComponent.new(icon: 'soft-unwrap', button_options: { class: 'no-wrap' }) do
- = _("No wrap")
- = render Pajamas::ButtonComponent.new(icon: 'soft-wrap', button_options: { class: 'soft-wrap' }) do
- = _("Soft wrap")
+ - unless Feature.enabled?(:source_editor_toolbar, current_user)
+ .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end
+ - if is_markdown
+ .md-header.gl-display-flex.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2
+ .gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-justify-content-space-between
+ .md-header-toolbar.gl-display-flex.gl-py-2.gl-flex-wrap{ class: "gl-m-0!" }
+ = render 'shared/blob/markdown_buttons', supports_file_upload: false
+ %span.soft-wrap-toggle
+ = render Pajamas::ButtonComponent.new(icon: 'soft-unwrap', button_options: { class: 'no-wrap' }) do
+ = _("No wrap")
+ = render Pajamas::ButtonComponent.new(icon: 'soft-wrap', button_options: { class: 'soft-wrap' }) do
+ = _("Soft wrap")
.file-editor.code
- if Feature.enabled?(:source_editor_toolbar, current_user)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 528999f5c89..195dc03632a 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 }
-
+ - c.with_body do
+ - 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/blob/viewers/_csv.html.haml b/app/views/projects/blob/viewers/_csv.html.haml
index 3a58bc9902c..3538ba1dd0d 100644
--- a/app/views/projects/blob/viewers/_csv.html.haml
+++ b/app/views/projects/blob/viewers/_csv.html.haml
@@ -1 +1 @@
-.file-content#js-csv-viewer{ data: { data: viewer.blob.data } }
+.file-content#js-csv-viewer{ data: { data: blob_raw_path } }
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 393b19e6c5a..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,5 +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 27525b441ab..605715e2899 100644
--- a/app/views/projects/branch_rules/_show.html.haml
+++ b/app/views/projects/branch_rules/_show.html.haml
@@ -1,12 +1,16 @@
- expanded = expanded_by_default?
+- show_code_owners = @project.licensed_feature_available?(:code_owner_approval_required)
+- 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
= expanded ? _('Collapse') : _('Expand')
%p
= _('Define rules for who can push, merge, and the required approvals for each branch.')
+ = link_to(_('Leave feedback.'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/388149', target: '_blank', rel: 'noopener noreferrer')
.settings-content.gl-pr-0
- #js-branch-rules{ data: { project_path: @project.full_path, branch_rules_path: project_settings_repository_branch_rules_path(@project) } }
+ #js-branch-rules{ data: { project_path: @project.full_path, branch_rules_path: project_settings_repository_branch_rules_path(@project), show_code_owners: show_code_owners.to_s, show_status_checks: show_status_checks.to_s, show_approvers: show_approvers.to_s } }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 51c218f40b9..dbc1fe24d96 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,50 +1,51 @@
- merged = local_assigns.fetch(:merged, false)
- commit = @repository.commit(branch.dereferenced_target)
- merge_project = merge_request_source_project_for_project(@project)
-%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name}", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
- .branch-info
- .gl-display-flex.gl-align-items-center
- = sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0')
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3', data: { qa_selector: 'branch_link' } do
- = branch.name
- = clipboard_button(text: branch.name, title: _("Copy branch name"))
- - if branch.name == @repository.root_ref
- = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :info, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
- - elsif merged
- = gl_badge_tag s_('Branches|merged'), { variant: :info, size: :sm }, { class: 'gl-ml-2', title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref }, data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } }
- - if protected_branch?(@project, branch)
- = gl_badge_tag s_('Branches|protected'), { variant: :success, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
-
- = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
-
- .block-truncated
- - if commit
- = render 'projects/branches/commit', commit: commit, project: @project
- - else
- = s_('Branches|Can’t find HEAD commit for this branch')
-
- - if branch.name != @repository.root_ref
- .js-branch-divergence-graph
-
- .controls.d-none.d-md-block<
- - if commit_status
- = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
- - elsif show_commit_status
- .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
- %svg.s24
-
- - if merge_project && create_mr_button?(from: branch.name, source_project: @project)
- = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do
- = _('Merge request')
+%li{ class: "branch-item gl-py-3! js-branch-item js-branch-#{branch.name}", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
+ .branch-item-content.gl-display-flex.gl-align-items-center.gl-px-3.gl-py-2
+ .branch-info
+ .gl-display-flex.gl-align-items-center
+ = sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0')
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3', data: { qa_selector: 'branch_link' } do
+ = branch.name
+ = clipboard_button(text: branch.name, title: _("Copy branch name"))
+ - if branch.name == @repository.root_ref
+ = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :info, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+ - elsif merged
+ = gl_badge_tag s_('Branches|merged'), { variant: :info, size: :sm }, { class: 'gl-ml-2', title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref }, data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } }
+ - if protected_branch?(@project, branch)
+ = gl_badge_tag s_('Branches|protected'), { variant: :success, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+
+ = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
+
+ .block-truncated
+ - if commit
+ = render 'projects/branches/commit', commit: commit, project: @project
+ - else
+ = s_('Branches|Can’t find HEAD commit for this branch')
- if branch.name != @repository.root_ref
- = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
- class: "gl-button btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
- method: :post,
- title: s_('Branches|Compare') do
- = s_('Branches|Compare')
-
- = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top'
-
- - if can?(current_user, :push_code, @project)
- = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged
+ .js-branch-divergence-graph
+
+ .controls.d-none.d-md-block<
+ - if commit_status
+ = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
+ - elsif show_commit_status
+ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
+ %svg.s24
+
+ - if merge_project && create_mr_button?(from: branch.name, source_project: @project)
+ = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do
+ = _('Merge request')
+
+ - if branch.name != @repository.root_ref
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
+ class: "gl-button btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
+ method: :post,
+ title: s_('Branches|Compare') do
+ = s_('Branches|Compare')
+
+ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top'
+
+ - if can?(current_user, :push_code, @project)
+ = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged
diff --git a/app/views/projects/branches/_branch_rules_info.haml b/app/views/projects/branches/_branch_rules_info.haml
new file mode 100644
index 00000000000..451d0f9928c
--- /dev/null
+++ b/app/views/projects/branches/_branch_rules_info.haml
@@ -0,0 +1,12 @@
+- return unless show_branch_rules_info?
+= render Pajamas::AlertComponent.new(variant: :info,
+ title: s_("Branches|See all branch-related settings together with branch rules"),
+ alert_options: { class: 'js-branch-rules-info-callout gl-mb-6 gl-mt-4', data: { feature_id: Users::CalloutsHelper::BRANCH_RULES_INFO_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
+ - c.with_body do
+ = s_("Branches|You can now find an overview of settings for protected branches, merge request approvals, status checks, and security approvals conveniently in one spot.")
+
+ - c.with_actions do
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: project_settings_repository_path(@project, anchor: 'js-branch-rules'), button_options: { class: 'deferred-link gl-alert-action' }) do
+ = s_("Branches|View branch rules")
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close'}) do
+ = _('Dismiss')
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index e33e9509e3a..cfa0cf6d07b 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -6,4 +6,4 @@
%span.str-truncated
= link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray"
&middot;
- #{time_ago_with_tooltip(commit.committed_date)}
+ %span.gl-text-secondary= time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index a1f93d21647..a632e29d34f 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -7,13 +7,14 @@
- return unless branches.any?
-= render Pajamas::CardComponent.new(card_options: {class: 'gl-mb-5'}, body_options: {class: 'gl-py-0'}, footer_options: {class: 'gl-text-center'}) do |c|
- - c.header do
- = panel_title
- - c.body do
- %ul.content-list.all-branches{ data: { qa_selector: 'all_branches_container' } }
+= render Pajamas::CardComponent.new(card_options: {class: 'gl-mt-5 gl-bg-gray-10'}, header_options: {class: 'gl-px-5 gl-py-4 gl-bg-white'}, body_options: {class: 'gl-px-3 gl-py-0'}, footer_options: {class: 'gl-bg-white'}) do |c|
+ - c.with_header do
+ %h3.card-title.h5.gl-line-height-24.gl-m-0
+ = panel_title
+ - c.with_body do
+ %ul.content-list.branches-list.all-branches{ data: { qa_selector: 'all_branches_container' } }
- branches.first(overview_max_branches).each do |branch|
= render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any?
- if branches.size > overview_max_branches
- - c.footer do
+ - c.with_footer do
= link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index f43d19e2542..64adf97b1b5 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,13 +1,15 @@
- add_page_specific_style 'page_bundles/branches'
- page_title _('Branches')
- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
+- is_branch_rules_available = (can? current_user, :maintainer_access, @project) && Feature.enabled?(:branch_rules, @project)
+- can_push_code = (can? current_user, :push_code, @project)
-# Possible values for variables passed down from the projects/branches_controller.rb
-#
-# @mode - overview|active|stale|all (default:overview)
-# @sort - name_asc|updated_asc|updated_desc
-.top-area.gl-border-0
+.top-area
= gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-b-0' }) do
= gl_tab_link_to s_('Branches|Overview'), project_branches_path(@project), { item_active: @mode == 'overview', title: s_('Branches|Show overview of the branches') }
= gl_tab_link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), { title: s_('Branches|Show active branches') }
@@ -22,16 +24,23 @@
sorted_by: @sort }
}
- - if can? current_user, :push_code, @project
+ - if is_branch_rules_available
+ = link_to project_settings_repository_path(@project, anchor: 'js-branch-rules'), class: 'gl-button btn btn-default' do
+ = s_('Branches|View branch rules')
+
+ - if can_push_code
+ = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
+ = s_('Branches|New branch')
.js-delete-merged-branches{ data: {
default_branch: @project.repository.root_ref,
form_path: project_merged_branches_path(@project) }
}
- = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
- = s_('Branches|New branch')
= render_if_exists 'projects/commits/mirror_status'
+- if is_branch_rules_available
+ = render 'branch_rules_info'
+
.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } }
- if @gitaly_unavailable
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 91efd5ef048..9fd9943fd26 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -3,26 +3,21 @@
- if @error
= render Pajamas::AlertComponent.new(variant: :danger) do |c|
- = c.body do
+ - c.with_body do
= @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/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index a8a911adb7d..ab026d9c6ac 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -13,7 +13,7 @@
%label.label-bold
= _('Clone with SSH')
.input-group.btn-group
- = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' }
+ = text_field_tag :ssh_project_clone, ssh_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' }
.input-group-append
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
@@ -22,7 +22,7 @@
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group.btn-group
- = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' }
+ = text_field_tag :http_project_clone, http_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' }
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
@@ -32,12 +32,12 @@
%label.label-bold{ class: 'gl-px-4!' }
= _('Open in your IDE')
- if ssh_enabled?
- - escaped_ssh_url_to_repo = CGI.escape(project.ssh_url_to_repo)
+ - escaped_ssh_url_to_repo = CGI.escape(ssh_clone_url_to_repo(project))
%a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_ssh_url_to_repo }
.gl-dropdown-item-text-wrapper
= _('Visual Studio Code (SSH)')
- if http_enabled?
- - escaped_http_url_to_repo = CGI.escape(project.http_url_to_repo)
+ - escaped_http_url_to_repo = CGI.escape(http_clone_url_to_repo(project))
%a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_http_url_to_repo }
.gl-dropdown-item-text-wrapper
= _('Visual Studio Code (HTTPS)')
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
index 16df743475d..f7ae462e8f9 100644
--- a/app/views/projects/commit/_pipelines_list.haml
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -3,7 +3,8 @@
#commit-pipeline-table-view{ data: { disable_initialization: disable_initialization,
endpoint: endpoint,
- "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
+ full_path: @project.full_path,
+ "empty-state-svg-path" => image_path('illustrations/empty-state/empty-pipeline-md.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"project-id": @project.id,
"artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json),
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index 88631f14e56..9cca928e794 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -29,5 +29,5 @@
= link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3')
-%a.signature-badge.gl-display-flex{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
+%a.signature-badge.gl-display-inline-block{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= gl_badge_tag label, variant: variant
diff --git a/app/views/projects/commit/diff_files.html.haml b/app/views/projects/commit/diff_files.html.haml
index 0c52c1a15a4..7287d10a109 100644
--- a/app/views/projects/commit/diff_files.html.haml
+++ b/app/views/projects/commit/diff_files.html.haml
@@ -1 +1,5 @@
-= render partial: 'projects/diffs/file', collection: diffs.diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: 'is-commit' }
+- diff_files = conditionally_paginate_diff_files(diffs, paginate: true, page: params[:page], per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE)
+
+= render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: 'is-commit' }
+
+= paginate(diff_files, theme: "gitlab", params: { action: :show })
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/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index b5481f19352..6209ef48f96 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -15,6 +15,7 @@
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
- commit = commit.present(current_user: current_user)
- commit_status = commit.detailed_status_for(ref)
+- tags = commit.tags_for_display
- collapsible = local_assigns.fetch(:collapsible, true)
- link_data_attrs = local_assigns.fetch(:link_data_attrs, {})
- link = commit_path(project, commit, merge_request: merge_request)
@@ -55,6 +56,13 @@
= preserve(markdown_field(commit, :description))
.commit-actions.flex-row
+ - if tags.present?
+ = gl_badge_tag(variant: :neutral, icon: 'tag', class: 'gl-font-monospace') do
+ - if tags.size > 1
+ = link_to _('%{count} tags') % { count: tags.size } , project_commit_path(project, commit.id)
+ - else
+ = link_to tags.first, project_commits_path(project, tags.first, ref_type: 'tags'), class: 'gl-text-truncate gl-max-w-15'
+
- if request.xhr?
= render partial: 'projects/commit/signature', object: commit.signature
- else
diff --git a/app/views/projects/commits/_commit_list.html.haml b/app/views/projects/commits/_commit_list.html.haml
index 23b25b5dcbd..22f4594c1d5 100644
--- a/app/views/projects/commits/_commit_list.html.haml
+++ b/app/views/projects/commits/_commit_list.html.haml
@@ -3,9 +3,9 @@
- commits = Commit.decorate(commits, @project)
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-py-0'}) do |c|
- - c.header do
+ - c.with_header do
Commits (#{@total_commit_count})
- - c.body do
+ - c.with_body do
- if hidden > 0
%ul.content-list
- commits.each do |commit|
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index b79f17ae7b3..9cbabaee774 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -8,8 +8,9 @@
- hidden = @hidden_commit_count
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits|
- %li.commit-header.js-commit-header{ data: { day: day } }
- %span.day= l(day, format: '%d %b, %Y')
+ %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b{ data: { day: day } }
+ %span.day.font-weight-bold= l(day, format: '%d %b, %Y')
+ %span -
%span.commits-count= n_("%d commit", "%d commits", daily_commits.size) % daily_commits.size
%li.commits-row{ data: { day: day } }
@@ -20,7 +21,7 @@
= render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if context_commits.present?
- %li.commit-header.js-commit-header
+ %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b
%span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
- if can_update_merge_request
= render Pajamas::ButtonComponent.new(button_options: { class: 'gl-ml-3 add-review-item-modal-trigger', data: { context_commits_empty: 'false' } }) do
@@ -37,7 +38,7 @@
%li
= render Pajamas::AlertComponent.new(variant: :warning,
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if can_update_merge_request && context_commits&.empty? && !(defined?(@next_page) && @next_page)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 689862eae8a..4c5a9acdf83 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -6,7 +6,7 @@
= auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
.js-project-commits-show{ 'data-commits-limit' => @limit }
- .tree-holder
+ .tree-holder.gl-mt-5
.nav-block
.tree-ref-container
.tree-ref-holder.gl-max-w-26
diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml
index b3590eea631..58da76a3231 100644
--- a/app/views/projects/compare/index.html.haml
+++ b/app/views/projects/compare/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _("Compare Revisions")
-- page_title _("Compare")
+- breadcrumb_title _("Compare revisions")
+- page_title _("Compare revisions")
%h1.page-title.gl-font-size-h-display
= _("Compare Git revisions")
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 1bdf3d1e6e3..9185afc0771 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs _("Compare Revisions"), project_compare_index_path(@project)
+- add_to_breadcrumbs _("Compare revisions"), project_compare_index_path(@project)
- page_title "#{params[:from]}...#{params[:to]}"
.sub-header-block.gl-border-b-0.gl-mb-0
@@ -18,7 +18,7 @@
paginate_diffs_per_page: Projects::CompareController::COMMIT_DIFFS_PER_PAGE
- else
= render Pajamas::CardComponent.new(card_options: { class: "gl-bg-gray-50 gl-mb-5 gl-border-none gl-text-center" }) do |c|
- - c.body do
+ - c.with_body do
%h4
= s_("CompareBranches|There isn't anything to compare.")
%p.gl-mb-4.gl-line-height-24
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index ba79f0ee3cb..1e8b1255f0c 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,6 +1,5 @@
- page_title _("Value Stream Analytics")
- data_attributes = @request_params.valid? ? @request_params.to_data_attributes : {}
-- data_attributes.merge!(cycle_analytics_initial_data(@project, @group))
- add_page_specific_style 'page_bundles/cycle_analytics'
#js-cycle-analytics{ data: data_attributes }
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 8ff6d348d95..982ecbbae51 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,22 +2,20 @@
- 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"
-- paginate_diffs = local_assigns.fetch(:paginate_diffs, false) && !load_diff_files_async
+- 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
+.js-diff-files-changed.gl-py-3
.files-changed-inner
- .inline-parallel-buttons.gl-display-none.gl-md-display-flex
+ .inline-parallel-buttons.gl-display-none.gl-md-display-flex.gl-relative
- if !diffs_expanded? && diff_files.any?(&:collapsed?)
= link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'd-none d-sm-inline-block')
- - elsif current_controller?('projects/merge_requests/diffs')
- = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'd-none d-sm-inline-block')
- elsif current_controller?(:compare)
= diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'd-none d-sm-inline-block')
- elsif current_controller?(:wikis)
@@ -32,7 +30,7 @@
.files{ data: { can_create_note: can_create_note } }
- if load_diff_files_async
- - url = url_for(safe_params.merge(action: 'diff_files'))
+ - url = url_for(safe_params.merge(action: 'diff_files', page: page))
.js-diffs-batch{ data: { diff_files_path: url } }
= gl_loading_icon(size: "md", css_class: "gl-mt-4")
- else
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index e87005434e4..02a69f25985 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,12 +1,19 @@
- 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'
+- @force_desktop_expanded_sidebar = true
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
+- if Feature.enabled?(:show_pages_in_deployments_menu, current_user, type: :experiment)
+ = render Pajamas::AlertComponent.new(variant: :info,
+ title: _('GitLab Pages has moved'),
+ alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
+ - c.with_body do
+ = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deployments > Pages', project_pages_path(@project)).html_safe}
+
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
@@ -27,16 +34,15 @@
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe
.js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) }
-
- if show_merge_request_settings_callout?(@project)
%section.settings.expanded
= render Pajamas::AlertComponent.new(variant: :info,
title: _('Merge requests and approvals settings have moved.'),
alert_options: { class: 'js-merge-request-settings-callout gl-my-5', data: { feature_id: Users::CalloutsHelper::MERGE_REQUEST_SETTINGS_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c|
- = c.body do
+ - c.with_body do
= _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe }
-= render_if_exists 'projects/settings/analytics_dashboards', expanded: expanded
+= render_if_exists 'projects/settings/analytics', expanded: expanded
%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } }
.settings-header
@@ -56,6 +62,8 @@
= render 'projects/service_desk_settings'
+= render_if_exists 'product_analytics/project_settings', expanded: expanded
+
%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced')
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 43159a759f4..a51d1080d96 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/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index de7f976717b..11c36b5ea6d 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -1,5 +1,6 @@
-- breadcrumb_title _("Environments")
-- page_title _("New Environment")
+- add_to_breadcrumbs s_("Environments|Environments"), project_environments_path(@project)
+- breadcrumb_title s_("Environments|New")
+- page_title s_("Environments|New Environment")
- add_page_specific_style 'page_bundles/environments'
#js-new-environment{ data: { project_environments_path: project_environments_path(@project) } }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 53b2af88511..e97cae911d9 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -20,7 +20,9 @@
%p
= html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
.text-center
- = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button btn btn-confirm"
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: help_page_path("ci/environments/index.md")) do
+ = _('Read more')
+
- else
.table-holder.gl-overflow-visible
.ci-table.environments{ role: 'grid' }
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 53fe30422ca..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,
@@ -6,7 +6,7 @@
"error-state-svg-path" => image_path('illustrations/feature_flag.svg'),
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"),
"feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"),
- "feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "golang-application-example"),
+ "feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "go-application-example"),
"feature-flags-limit-exceeded" => @project.actual_limits.exceeded?(:project_feature_flags, @project.operations_feature_flags.count),
"feature-flags-limit" => @project.actual_limits.project_feature_flags,
"unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)),
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/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 1d4e907dd61..afb49c48146 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -17,8 +17,8 @@
%table.table.files-slider{ class: "table_#{@hex_path} tree-table" }
%tbody
.col-12.empty-state.hidden
- .svg-250.svg-content
- = image_tag('illustrations/profile-page/personal-projects.svg', alt: 'No files svg', lazy: true)
+ .svg-150.svg-content
+ = image_tag('illustrations/empty-state/empty-search-md.svg', alt: 'No files svg', lazy: true)
.text-center
%h4
= _('There are no matching files')
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 022a96b15a7..cff5899b960 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -4,7 +4,7 @@
variant: :danger,
alert_options: { class: 'gl-mt-5' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
%p
= _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) }
@@ -17,5 +17,5 @@
- else
= error
- = c.actions do
+ - c.with_actions do
= link_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: "btn gl-alert-action btn-info btn-md gl-button"
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/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index 754de2db8f3..9d6f67bd190 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -1,4 +1,4 @@
-- page_title _('Contributors')
+- page_title _('Contributor statistics')
- graph_path = project_graph_path(@project, current_ref, ref_type: @ref_type, format: :json)
- commits_path = project_commits_path(@project, current_ref, ref_type: @ref_type)
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..4d71161c96e 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -1,6 +1,6 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- breadcrumb_title _('Webhook Settings')
- page_title _('Webhooks')
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index efb364bd013..af337082141 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -9,7 +9,7 @@
dismissible: false,
variant: :danger,
alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
= @project.import_state.last_error
= gitlab_ui_form_for @project, url: project_import_path(@project), method: :post, html: { class: 'js-project-import' } do |f|
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 c5ce0549816..de8725df871 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -1,4 +1,4 @@
-- return if @issue.incident?
+- return if @issue.work_item_type&.incident?
- requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
@@ -12,7 +12,8 @@
issue_iid: @issue.iid,
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') } }
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
+ 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 c86f9c79912..c6e5102889a 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -12,4 +12,5 @@
show_timeline_view_toggle: show_timeline_view_toggle?(@issue).to_s,
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 } }
+ report_abuse_path: add_category_abuse_reports_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..3d6a266dc4d 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.with_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.with_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/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml
index 80f2b8b189c..2409c61fbf2 100644
--- a/app/views/projects/issues/_related_issues.html.haml
+++ b/app/views/projects/issues/_related_issues.html.haml
@@ -5,4 +5,5 @@
has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s,
help_path: help_page_path('user/project/issues/related_issues'),
show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s,
- has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s } }
+ has_iterations_feature: @project.licensed_feature_available?(:iterations).to_s,
+ report_abuse_path: add_category_abuse_reports_path } }
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index 3deceacec8d..981021c97e6 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,5 +1,5 @@
-.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid,
- project_path: @project.full_path,
+.js-work-item-links-root{ data: { issuable_id: @issue.id,
+ full_path: @project.full_path,
wi: work_items_index_data(@project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
sign_in_path: new_session_path(:user, redirect_to_referer: 'yes') } }
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/issues/service_desk/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml
index cc8d5bdaeec..b087a1d0151 100644
--- a/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml
+++ b/app/views/projects/issues/service_desk/_alert_moved_from_service_desk.html.haml
@@ -4,5 +4,5 @@
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5' }) do |c|
- = c.body do
+ - c.with_body do
= s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe }
diff --git a/app/views/projects/issues/service_desk/_nav_btns.html.haml b/app/views/projects/issues/service_desk/_nav_btns.html.haml
index 8d16c3d978f..a0a290f340a 100644
--- a/app/views/projects/issues/service_desk/_nav_btns.html.haml
+++ b/app/views/projects/issues/service_desk/_nav_btns.html.haml
@@ -1,20 +1,34 @@
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project)
- show_export_button = local_assigns.fetch(:show_export_button, true)
-- issuable_type = 'issues'
+- issuable_type = 'issue'
- can_edit = can?(current_user, :admin_project, @project)
- notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil
.nav-controls.issues-nav-controls.gl-font-size-0
- - if show_feed_buttons
- = render 'shared/issuable/feed_buttons'
-
- .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } }
-
- if @can_bulk_update
- = button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
+ = button_tag _("Bulk edit"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
= link_to _("New issue"), new_project_issue_path(@project,
issue: { milestone_id: finder.milestones.first.try(:id) }),
- class: "gl-button btn btn-confirm",
+ class: "gl-button btn btn-confirm gl-mr-3",
id: "new_issue_link"
+
+.dropdown.gl-dropdown
+ = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown 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: _('Actions') } do
+ = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
+ %span.gl-sr-only
+ = _('Actions')
+ = 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= _('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
+ .js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } }
+ %li.gl-dropdown-divider
+ %hr.dropdown-divider
+ %li.gl-dropdown-item
+ - if show_feed_buttons
+ = render 'shared/issuable/feed_buttons'
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index a8edf87b696..7e8bf4ae57f 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -8,4 +8,3 @@
- add_page_specific_style 'page_bundles/work_items'
= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
-= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index f95689c0b1d..ce7006001c7 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -9,18 +9,20 @@
#js-promote-label-modal
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
- .labels-container.gl-mt-5
- - if can_admin_label && search.blank?
- %p.text-muted
- = _('Labels can be applied to issues and merge requests. Star a label to make it a priority label.')
+ - if can_admin_label && search.blank?
+ %p.text-muted.gl-mt-5
+ = _('Labels can be applied to issues and merge requests. Star a label to make it a priority label.')
+ .labels-container
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
- .prioritized-labels.gl-mb-7{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
- %h4.gl-mt-3= _('Prioritized Labels')
- %p.text-muted
- = _('Drag to reorder prioritized labels and change their relative priority.')
- .manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
+ .prioritized-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
+ = _('Prioritized labels')
+ .gl-font-sm.gl-font-weight-semibold.gl-text-gray-500
+ = _('Drag to reorder prioritized labels and change their relative priority.')
+ .js-prioritized-labels.gl-px-3.gl-rounded-base.manage-labels-list{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
= render 'shared/empty_states/priority_labels'
- if @prioritized_labels.any?
@@ -30,16 +32,18 @@
= _('No prioritized labels with such name or description')
- if @labels.any?
- .other-labels
- %h4{ class: ('hide' if hide) }= _('Other Labels')
- .manage-labels-list.js-other-labels
+ .other-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ %h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24{ class: ('hide' if hide) }= _('Other labels')
+ .js-other-labels.gl-px-3.gl-rounded-base.manage-labels-list
= render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
= paginate @labels, theme: 'gitlab'
+
- elsif search.present?
.other-labels
- if @available_labels.any?
%h4
- = _('Other Labels')
+ = _('Other labels')
.nothing-here-block
= _('No other labels with such name or description')
- else
@@ -53,5 +57,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.js-priority-badge.inline.gl-ml-3
- .label-badge.gl-bg-blue-50= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-mr-3
+ .label-badge.gl-bg-blue-50= _('Prioritized')
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index 98221125443..397f96d6846 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -2,7 +2,7 @@
This service will be installed on the Mattermost instance at
%strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host
%hr
-= form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input' }) do |f|
+= gitlab_ui_form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input' }) do |f|
%h4 Team
%p
= @teams.one? ? 'The team' : 'Select the team'
@@ -42,5 +42,6 @@
%hr
.clearfix
.float-right
- = link_to _('Cancel'), edit_project_settings_integration_path(@project, @integration), class: 'gl-button btn btn-lg'
- = f.submit 'Install', class: 'gl-button btn btn-confirm btn-lg'
+ = render Pajamas::ButtonComponent.new(href: edit_project_settings_integration_path(@project, @integration)) do
+ = _('Cancel')
+ = f.submit s_('MattermostService|Install'), pajamas_button: true
diff --git a/app/views/projects/mattermosts/new.html.haml b/app/views/projects/mattermosts/new.html.haml
index 8254198bd41..c73c256c1c3 100644
--- a/app/views/projects/mattermosts/new.html.haml
+++ b/app/views/projects/mattermosts/new.html.haml
@@ -2,11 +2,10 @@
- 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|
- = c.body do
+ - c.with_body do
= @teams_error_message
%h3
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..9bfa0e7a309 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,8 @@
- 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
+ %span.js-sidebar-header-popover
+ = button_tag type: 'button', id: "new-actions-header-dropdown", 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..9142893d400 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -4,7 +4,7 @@
= render Pajamas::CheckboxTagComponent.new(name: dom_id(merge_request, "selected"),
value: nil,
checkbox_options: { 'data-id' => merge_request.id }) do |c|
- = c.label do
+ - c.with_label do
%span.gl-sr-only
= merge_request.title
@@ -15,43 +15,42 @@
= hidden_merge_request_icon(merge_request)
= link_to merge_request.title, merge_request_path(merge_request), class: 'js-prefetch-document'
- if merge_request.tasks?
- %span.task-status.d-none.d-sm-inline-block
+ %span.task-status.d-none.d-sm-inline-block.gl-font-sm
&nbsp;
= merge_request.task_status
.issuable-info
%span.issuable-reference
#{issuable_reference(merge_request)}
- %span.issuable-authored.d-none.d-sm-inline-block
+ %span.issuable-authored.d-none.d-sm-inline-block.gl-text-gray-500!
&middot;
- #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false) }}
+ #{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false, extra_class: 'gl-text-gray-500!') }}
= render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author
- if merge_request.milestone
- %span.issuable-milestone.d-none.d-sm-inline-block
+ %span.issuable-milestone.d-none.d-sm-inline-block.gl-text-truncate.gl-max-w-26.gl-vertical-align-bottom
&nbsp;
- = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do
- = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom')
+ = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), class: 'gl-text-gray-500!', data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do
+ = sprite_icon('clock', size: 12, css_class: 'gl-vertical-align-text-bottom')
= merge_request.milestone.title
- if merge_request.target_project.default_branch != merge_request.target_branch
- %span.project-ref-path.has-tooltip{ title: _('Target branch') }
+ %span.project-ref-path.has-tooltip.d-inline-block.gl-text-truncate.gl-max-w-26.gl-vertical-align-bottom{ title: _('Target branch: %{target_branch}') % {target_branch: merge_request.target_branch} }
&nbsp;
- = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do
+ = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name gl-text-gray-500!' do
= 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,7 @@
= render 'shared/issuable_meta_data', issuable: merge_request
- .float-right.issuable-updated-at.d-none.d-sm-inline-block
- %span
- = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago') }
+ - if merge_request.updated_at != merge_request.created_at
+ .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 9d25603994a..15339becb74 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)
@@ -10,10 +9,10 @@
= render Pajamas::AlertComponent.new(alert_options: { class: 'gl-mb-5' },
variant: :danger,
dismissible: false) do |c|
- = c.body do
+ - c.with_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 } }
@@ -27,7 +26,8 @@
.detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex
- if can_update_merge_request
- = link_to _('Edit'), edit_project_merge_request_path(@project, @merge_request), class: "gl-display-none gl-md-display-block btn gl-button btn-default js-issuable-edit", data: { qa_selector: "edit_button" }
+ = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_button" }}) do
+ = _('Edit')
- if @merge_request.source_project
= render 'projects/merge_requests/code_dropdown'
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index 1efea6a1d37..80085cc6a34 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -1,12 +1,27 @@
-- issuable_type = 'merge-requests'
+- issuable_type = 'merge_request'
- notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil
-= render 'shared/issuable/feed_buttons', show_calendar_button: false
-.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
-
- if @can_bulk_update
= render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-mr-3 js-bulk-update-toggle' }) do
- = _("Edit merge requests")
+ = _("Bulk edit")
- if merge_project
= render Pajamas::ButtonComponent.new(href: new_merge_request_path, variant: :confirm) do
= _("New merge request")
+
+.dropdown.gl-dropdown
+ = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown 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: _('Actions') } do
+ = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
+ %span.gl-sr-only
+ = _('Actions')
+ = 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= _('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
+ .js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters) } }
+ %li.gl-dropdown-divider
+ %hr.dropdown-divider
+ %li.gl-dropdown-item
+ = render 'shared/issuable/feed_buttons', show_calendar_button: false
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 880bffc43ab..3e56148f777 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -16,7 +16,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 } }
@@ -36,7 +36,7 @@
= render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do
= tab_link_for @merge_request, :commits do
= _("Commits")
- = gl_badge_tag @commits_count, { size: :sm }
+ = gl_badge_tag tab_count_display(@merge_request, @commits_count), { size: :sm }, { class: 'js-commits-count' }
- if @project.builds_enabled?
= render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
@@ -45,7 +45,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 tab_count_display(@merge_request, @diffs_count), { 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?
@@ -81,7 +81,8 @@
help_page_path: suggest_changes_help_path,
current_user_data: @current_user_data,
is_locked: @merge_request.discussion_locked.to_s,
- report_abuse_path: add_category_abuse_reports_path } }
+ report_abuse_path: add_category_abuse_reports_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
@@ -90,7 +91,7 @@
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- if @project.builds_enabled?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- - params = request.query_parameters.merge(diff_head: true)
+ - params = request.query_parameters.merge(ck: @diffs_batch_cache_key, diff_head: true)
= render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params)
.mr-loading-status
@@ -105,10 +106,9 @@
- if @merge_request.can_be_cherry_picked?
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
-#js-review-bar
+#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 } }
-= render 'projects/invite_members_modal', project: @project
= render 'shared/web_ide_path'
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 4f6983c6fe3..576fed58609 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -11,12 +11,12 @@
window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}';
window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}';
window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}';
- window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}';
+ window.gl.mrWidgetData.code_coverage_check_help_page_path = '#{help_page_path('ci/testing/code_coverage.md', anchor: 'coverage-check-approval-rule')}';
window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}';
- window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}';
+ window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}';
- window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
+ window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/empty-state/empty-pipeline-md.svg')}';
window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
window.gl.mrWidgetData.false_positive_doc_url = '#{help_page_path('user/application_security/vulnerabilities/index')}';
window.gl.mrWidgetData.can_view_false_positive = '#{@merge_request.project.licensed_feature_available?(:sast_fp_reduction).to_s}';
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index a882196ffa2..3facca4d4f7 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,3 +1,6 @@
+- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project)
+- add_to_breadcrumbs @merge_request.to_reference, project_merge_request_path(@project, @merge_request)
+- breadcrumb_title _("Merge conflicts")
- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
- add_page_specific_style 'page_bundles/merge_conflicts'
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/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index 4596fcd280d..4ea33cbbd99 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -11,7 +11,7 @@
= render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
- if @merge_request.for_fork? && !@merge_request.source_project
= err_fork_project_removed
- elsif !@merge_request.source_branch_exists?
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index cdb8a63bca9..be6f9ac83dc 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,32 +1,32 @@
-= gitlab_ui_form_for [@project, @milestone],
- html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+= gitlab_ui_form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
+
+ - if @conflict
+ = render 'shared/model_version_conflict', model_name: _('milestone'), link_path: project_milestone_path(@project, @milestone)
+
- if @redirect_path.present?
= f.hidden_field(:redirect_path, name: :redirect_path, id: :redirect_path, value: @redirect_path)
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :title, _('Title')
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true
+ .form-group
+ = f.label :title, _('Title')
+ = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true
= render 'shared/milestones/form_dates', f: f
- .form-group.row.milestone-description
- .col-form-label.col-sm-2
- = f.label :description, _('Description')
- .col-sm-10
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
- = render 'shared/notes/hints'
- .clearfix
- .error-alert
+ .form-group
+ = f.label :description, _('Description')
+ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
+ = render 'shared/zen', f: f, attr: :description,
+ classes: 'note-textarea',
+ qa_selector: 'milestone_description_field',
+ supports_autocomplete: true,
+ placeholder: _('Write milestone description...')
+ = render 'shared/notes/hints'
+ .clearfix
+ .error-alert
+
+ = f.hidden_field :lock_version
- .form-actions
- - if @milestone.new_record?
- = f.submit _('Create milestone'), data: { qa_selector: 'create_milestone_button' }, pajamas_button: true
- = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-default btn-cancel'
- - else
- = f.submit _('Save changes'), pajamas_button: true
- = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-default btn-cancel'
+ - if @milestone.new_record?
+ = f.submit _('Create milestone'), data: { qa_selector: 'create_milestone_button' }, class: 'gl-mr-2', pajamas_button: true
+ = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ - else
+ = f.submit _('Save changes'), class: 'gl-mr-2', pajamas_button: true
+ = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 95ef856daba..36500e157b4 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -16,7 +16,7 @@
= render Pajamas::AlertComponent.new(dismissible: false,
alert_options: { class: 'gl-mt-3 gl-mb-5',
data: { testid: 'no-issues-alert' }}) do |c|
- = c.body do
+ - c.with_body do
= _('Assign some issues to this milestone.')
- else
= render 'shared/milestones/milestone_complete_alert', milestone: @milestone do
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 4cfe463fa38..110bc8d82f8 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -33,7 +33,7 @@
= f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' }
- else
= render Pajamas::AlertComponent.new(dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= _('Mirror settings are only available to GitLab administrators.')
= render 'projects/mirrors/mirror_repos_list'
diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml
index 46833b5986b..185d86245c5 100644
--- a/app/views/projects/mirrors/_mirror_repos_list.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml
@@ -3,14 +3,14 @@
.panel.panel-default
.table-responsive
- if !@project.mirror? && @project.remote_mirrors.count == 0
- .gl-card.gl-mt-5
- .gl-card-header
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mt-5' }) do |c|
+ - c.with_header do
%strong
= _('Mirrored repositories') + ' (0)'
- .gl-card-body
+ - c.with_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/candidates/show.html.haml b/app/views/projects/ml/candidates/show.html.haml
index 77262243efb..5d5059551d3 100644
--- a/app/views/projects/ml/candidates/show.html.haml
+++ b/app/views/projects/ml/candidates/show.html.haml
@@ -2,5 +2,7 @@
- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project)
- add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid)
- breadcrumb_title "Candidate #{@candidate.iid}"
+- add_page_specific_style 'page_bundles/ml_experiment_tracking'
+- presenter = ::Ml::CandidateDetailsPresenter.new(@candidate)
-#js-show-ml-candidate{ data: { view_model: show_candidate_view_model(@candidate) } }
+#js-show-ml-candidate{ data: { view_model: presenter.present } }
diff --git a/app/views/projects/ml/experiments/_experiment.html.haml b/app/views/projects/ml/experiments/_experiment.html.haml
deleted file mode 100644
index 42823f47469..00000000000
--- a/app/views/projects/ml/experiments/_experiment.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-%li.ml-experiment-row.py-3
- = link_to project_ml_experiment_path(@project, experiment.iid), class: "title" do
- = experiment.name
diff --git a/app/views/projects/ml/experiments/_experiment_list.html.haml b/app/views/projects/ml/experiments/_experiment_list.html.haml
deleted file mode 100644
index a25e814b2b5..00000000000
--- a/app/views/projects/ml/experiments/_experiment_list.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-- if experiments.blank?
- .nothing-here-block= s_('MlExperimentsEmptyState|No Experiments to Show')
-- else
- .ml-experiments-list-holder
- %ul.content-list
- = render partial: 'experiment', collection: experiments, as: :experiment
- = paginate_collection @experiments
diff --git a/app/views/projects/ml/experiments/_incubation_banner.html.haml b/app/views/projects/ml/experiments/_incubation_banner.html.haml
deleted file mode 100644
index e34f3fd2d2f..00000000000
--- a/app/views/projects/ml/experiments/_incubation_banner.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-= render Pajamas::AlertComponent.new(variant: :warning,
- title: _('Machine Learning Experiment Tracking is in Incubating Phase'),
- alert_options: { class: 'gl-my-3' }) do |c|
- = c.body do
- = _('GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited')
- = link_to _('Learn more.'), 'https://about.gitlab.com/handbook/engineering/incubation/', target: "_blank"
- = c.actions do
- = link_to _('Feedback and Updates'), 'https://gitlab.com/groups/gitlab-org/-/epics/8560', target: "_blank"
diff --git a/app/views/projects/ml/experiments/index.html.haml b/app/views/projects/ml/experiments/index.html.haml
index dd064239e36..612481dbf41 100644
--- a/app/views/projects/ml/experiments/index.html.haml
+++ b/app/views/projects/ml/experiments/index.html.haml
@@ -3,5 +3,6 @@
#js-project-ml-experiments-index{ data: {
experiments: experiments_as_data(@project, @experiments),
- page_info: formatted_page_info(@page_info)
+ page_info: formatted_page_info(@page_info),
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'),
} }
diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml
index 4433d1fafe9..d4a05b613c9 100644
--- a/app/views/projects/ml/experiments/show.html.haml
+++ b/app/views/projects/ml/experiments/show.html.haml
@@ -1,17 +1,19 @@
- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project)
- breadcrumb_title @experiment.name
- 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,
- page_info: page_info
+ page_info: page_info,
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'),
} }
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 56581fe7b18..59a21cecd39 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -1,14 +1,24 @@
-- @hide_breadcrumbs = true
+- @hide_top_bar = true
- @hide_top_links = true
- page_title _('New Project')
- header_title _("Projects"), dashboard_projects_path
- add_page_specific_style 'page_bundles/new_namespace'
-.project-edit-container.gl-mt-5
+.project-edit-container
.project-edit-errors
= render 'projects/errors'
- .js-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?).to_s, has_errors: @project.errors.any?.to_s, 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") } }
+ .js-new-project-creation{ data: {
+ is_ci_cd_available: remote_mirror_setting_enabled?.to_s,
+ has_errors: @project.errors.any?.to_s,
+ 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,
+ can_import_projects: params[:namespace_id].presence ? current_user.can?(:import_projects, @namespace).to_s : 'true' } }
.row{ 'v-cloak': true }
#blank-project-pane.tab-pane.active
@@ -17,7 +27,7 @@
#create-from-template-pane.tab-pane
= render Pajamas::CardComponent.new(card_options: { class: 'gl-my-5' }) do |c|
- = c.body do
+ - c.with_body do
%div
- contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing'
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url }
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/_access.html.haml b/app/views/projects/pages/_access.html.haml
index 28f04d78861..50e48187be3 100644
--- a/app/views/projects/pages/_access.html.haml
+++ b/app/views/projects/pages/_access.html.haml
@@ -1,20 +1,18 @@
- if @project.pages_deployed?
+ - pages_url = @project.pages_url(with_unique_domain: true)
+
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c|
- - c.header do
+ - c.with_header do
= s_('GitLabPages|Access pages')
- - c.body do
- %p
- %strong
- = s_('GitLabPages|Your pages are served under:')
-
+ - c.with_body do
%p
- = external_link(@project.pages_url, @project.pages_url)
+ = external_link(pages_url, pages_url)
- @project.pages_domains.each do |domain|
%p
= external_link(domain.url, domain.url)
- unless @project.public_pages?
- - c.footer do
+ - c.with_footer do
- help_page = help_page_path('user/project/pages/pages_access_control')
- link_start = '<a href="%{url}" target="_blank" class="gl-alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page }
- link_end = '</a>'.html_safe
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index ff30c9ce1ea..3d95fa8a655 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -1,16 +1,13 @@
- if @project.pages_deployed?
- if can?(current_user, :remove_pages, @project)
- = render Pajamas::CardComponent.new(card_options: { class: 'border-danger' }, header_options: {class: 'bg-danger text-white'}) do |c|
- - c.with_header do
+ .gl-bg-red-50.gl-inset-border-l-3-red-600.gl-py-5.gl-px-6
+ %h4.gl-font-lg.gl-mt-0= s_('GitLabPages|Remove pages')
+ %p= s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.')
+ = render Pajamas::ButtonComponent.new(href: project_pages_path(@project),
+ variant: :danger,
+ method: :delete,
+ button_options: {data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_('GitLabPages|Remove pages')}) do
= s_('GitLabPages|Remove pages')
- - c.with_body do
- = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.')
- - c.with_footer do
- = render Pajamas::ButtonComponent.new(href: project_pages_path(@project),
- variant: :danger,
- method: :delete,
- button_options: {data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_('GitLabPages|Remove pages')}) do
- = s_('GitLabPages|Remove pages')
- else
.nothing-here-block
= s_('GitLabPages|Only project maintainers can remove pages')
diff --git a/app/views/projects/pages/_header.html.haml b/app/views/projects/pages/_header.html.haml
index cf51796e878..c34034ccafc 100644
--- a/app/views/projects/pages/_header.html.haml
+++ b/app/views/projects/pages/_header.html.haml
@@ -1,10 +1,7 @@
-- can_add_new_domain = can_create_pages_custom_domains?(current_user, @project)
+- @content_class = 'limit-container-width'
-%h1.page-title.gl-font-size-h-display.with-button
+%h4.page-title
= s_('GitLabPages|Pages')
- - if can_add_new_domain
- = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'float-right'}, href: new_project_pages_domain_path(@project)) do
- = s_('GitLabPages|New Domain')
%p
- docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
- docs_link_end = '</a>'.html_safe
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 32e67fdadb8..57371aa49f6 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -1,39 +1,47 @@
+- can_add_new_domain = can_create_pages_custom_domains?(current_user, @project)
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
-- if can?(current_user, :update_pages, @project) && @domains.any?
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}) do |c|
- - c.header do
+- if can?(current_user, :update_pages, @project)
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, header_options: { class: 'gl-display-flex gl-align-items-center gl-justify-content-space-between' }) do |c|
+ - c.with_header do
Domains (#{@domains.size})
- - c.body do
- %ul.list-group.list-group-flush
- - @domains.each do |domain|
- %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-p-0
- .gl-display-flex.gl-align-items-center
- - if verification_enabled
- - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success']
- .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip }
- = sprite_icon("status_#{status}")
- .domain-name
- = external_link(domain.url, domain.url)
- - if domain.certificate
- %div
- = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
- - if domain.expired?
- = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
- %div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
- = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
- - if domain.needs_verification?
- %li.list-group-item.bs-callout-warning
- - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
- - details_link_end = '</a>'.html_safe
- = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain,
- link_start: details_link_start,
- link_end: details_link_end }
- - if domain.show_auto_ssl_failed_warning?
- %li.list-group-item.bs-callout-warning
- - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
- - details_link_end = '</a>'.html_safe
- = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain,
- link_start: details_link_start,
- link_end: details_link_end }
+ - if can_add_new_domain
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_pages_domain_path(@project)) do
+ = s_('GitLabPages|New Domain')
+ - c.with_body do
+ - if @domains.any?
+ %ul.list-group.list-group-flush
+ - @domains.each do |domain|
+ %li.list-group-item.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-p-0
+ .gl-display-flex.gl-align-items-center
+ - if verification_enabled
+ - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success']
+ .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip }
+ = sprite_icon("status_#{status}")
+ .domain-name
+ = external_link(domain.url, domain.url)
+ - if domain.certificate
+ %div
+ = gl_badge_tag(s_('GitLabPages|Certificate: %{subject}') % { subject: domain.pages_domain.subject })
+ - if domain.expired?
+ = gl_badge_tag s_('GitLabPages|Expired'), variant: :danger
+ %div
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-confirm btn-inverted"
+ = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?'), 'confirm-btn-variant': 'danger'}, "aria-label": s_("GitLabPages|Remove domain"), method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
+ - if domain.needs_verification?
+ %li.list-group-item.bs-callout-warning
+ - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
+ - details_link_end = '</a>'.html_safe
+ = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain,
+ link_start: details_link_start,
+ link_end: details_link_end }
+ - if domain.show_auto_ssl_failed_warning?
+ %li.list-group-item.bs-callout-warning
+ - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
+ - details_link_end = '</a>'.html_safe
+ = s_("GitLabPages|Something went wrong while obtaining the Let's Encrypt certificate for %{domain}. To retry visit your %{link_start}domain details%{link_end}.").html_safe % { domain: domain.domain,
+ link_start: details_link_start,
+ link_end: details_link_end }
+ - else
+ .gl-text-center
+ = s_("You currently have no custom domains.")
diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml
index eee7d062d00..97c3ad11f1a 100644
--- a/app/views/projects/pages/_no_domains.html.haml
+++ b/app/views/projects/pages/_no_domains.html.haml
@@ -1,6 +1,11 @@
+- can_add_new_domain = can_create_pages_custom_domains?(current_user, @project)
+
- if can?(current_user, :update_pages, @project)
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5'}, body_options: { class: 'gl-text-center nothing-here-block' }) do |c|
- - c.header do
+ - c.with_header do
= s_('GitLabPages|Domains')
- - c.body do
+ - if can_add_new_domain
+ = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_pages_domain_path(@project)) do
+ = s_('GitLabPages|New Domain')
+ - c.with_body do
= s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 0010564081e..4c8ec21db39 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -17,5 +17,14 @@
%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, @project)
+ .form-group
+ = f.fields_for :project_setting do |settings|
+ = settings.gitlab_ui_checkbox_component :pages_unique_domain_enabled,
+ s_('GitLabPages|Use unique domain'),
+ label_options: { class: 'label-bold' }
+ %p.gl-pl-6
+ = s_("GitLabPages|When enabled, a unique domain is generated to access pages.").html_safe
+
.gl-mt-3
- = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
+ = f.submit s_('GitLabPages|Save changes'), pajamas_button: true
diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml
index dccf61c6ec5..4c39c1be060 100644
--- a/app/views/projects/pages/_use.html.haml
+++ b/app/views/projects/pages/_use.html.haml
@@ -1,8 +1,8 @@
- unless @project.pages_deployed?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
- - c.header do
+ - c.with_header do
= s_('GitLabPages|Configure pages')
- - c.body do
+ - c.with_body do
- docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_docs_link'>".html_safe
- samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer' data-track-action='click_link' data-track-label='pages_samples_link'>".html_safe
- link_end = '</a>'.html_safe
diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml
index 4ba3e084dc4..f80fd495695 100644
--- a/app/views/projects/pages_domains/_certificate.html.haml
+++ b/app/views/projects/pages_domains/_certificate.html.haml
@@ -30,10 +30,10 @@
- if has_user_defined_certificate
.row
.col-sm-10.offset-sm-2
- .card
- .card-header
+ = render Pajamas::CardComponent.new(body_options: { class: 'gl-display-flex gl-align-items-center gl-justify-content-space-between gl-p-5' }) do |c|
+ - c.with_header do
= _('Certificate')
- .d-flex.justify-content-between.align-items-center.p-3
+ - c.with_body do
%span
= domain_presenter.pages_domain.subject || _('missing')
= link_to _('Remove'),
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index 2c6b808eb1c..3e6a92d8bc0 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -13,7 +13,6 @@
%p.form-text.text-muted
= _("To access this domain create a new DNS record")
- if verification_enabled
- - verification_record = "#{domain_presenter.verification_domain} TXT #{domain_presenter.keyed_verification_code}"
.form-group.border-section
.row
.col-sm-2
@@ -24,7 +23,7 @@
= gl_badge_tag text, variant: status
= link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-ml-2 gl-button btn btn-sm btn-default has-tooltip", title: _("Retry verification")
.input-group
- = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
+ = text_field_tag :domain_verification, domain_presenter.verification_record, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
= clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block')
%p.form-text.text-muted
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index 9d9603b0947..bca955dcdae 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,6 +1,6 @@
- if domain_presenter.errors.any?
= render Pajamas::AlertComponent.new(variant: :danger, dismissible: false) do |c|
- = c.body do
+ - c.with_body do
- domain_presenter.errors.full_messages.each do |msg|
= msg
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 5de5188ae6a..89e64d607a6 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -7,7 +7,7 @@
- if verification_enabled && domain_presenter.unverified?
= content_for :flash_message do
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c|
- = c.body do
+ - c.with_body do
.container-fluid.container-limited
= _("This domain is not verified. You will need to verify ownership before access is enabled.")
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 0de31f59033..37b2b3ecfde 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -37,7 +37,7 @@
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
= link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do
= sprite_icon('play')
- - if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
+ - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) && pipeline_schedule.owner != current_user
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-take-ownership-button has-tooltip', title: s_('PipelineSchedule|Take ownership to edit'), data: { url: take_ownership_pipeline_schedule_path(pipeline_schedule) } }) do
= s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 8f7f0a15e69..3ff370dfaa4 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -11,17 +11,17 @@
= preserve(markdown(commit.description, pipeline: :single_line))
.info-well
- .well-segment.pipeline-info{ class: "gl-align-items-baseline!" }
- .icon-container
- = sprite_icon('clock', css_class: 'gl-top-0!')
- - jobs = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size
- - if @pipeline.duration
- = s_('Pipelines|%{jobs} %{ref_text} in %{duration}').html_safe % { jobs: jobs, ref_text: @pipeline.ref_text, duration: time_interval_in_words(@pipeline.duration) }
- - else
- = jobs
+ .well-segment.pipeline-info{ class: "gl-align-items-baseline! gl-flex-direction-column" }
+ %div
+ .icon-container
+ = sprite_icon('clock', css_class: 'gl-top-0!')
+ = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size
= @pipeline.ref_text
- - if @pipeline.queued_duration
- = s_("Pipelines|(queued for %{queued_duration})") % { queued_duration: time_interval_in_words(@pipeline.queued_duration)}
+ - if @pipeline.finished_at
+ - 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' }
+ = 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/_pipeline_stats_text.html.haml b/app/views/projects/pipelines/_pipeline_stats_text.html.haml
new file mode 100644
index 00000000000..8adf94e61c4
--- /dev/null
+++ b/app/views/projects/pipelines/_pipeline_stats_text.html.haml
@@ -0,0 +1 @@
+= s_("in %{duration} and was queued for %{queued_duration}").html_safe % { duration: duration, queued_duration: queued_duration }
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index d2b2a58fcf8..210f9c35c79 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,4 +1,5 @@
-- breadcrumb_title _('Pipelines')
+- breadcrumb_title s_('Pipeline|Run pipeline')
+- add_to_breadcrumbs s_('Pipeline|Pipelines'), project_pipelines_path(@project)
- page_title s_('Pipeline|Run pipeline')
%h1.page-title.gl-font-size-h-display
@@ -7,8 +8,9 @@
#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),
ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json,
file_param: params[:file_var].to_json,
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 9b0a81a2f60..a7d670f8475 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -17,11 +17,17 @@
= render "projects/pipelines/info", commit: @pipeline.commit
- if pipeline_has_errors
- .bs-callout.bs-callout-danger
- %h4= _('Unable to create pipeline')
- %ul
- - @pipeline.yaml_errors.split("\n").each do |error|
- %li= error
+ = render Pajamas::AlertComponent.new(title: s_('Pipelines|Unable to create pipeline'),
+ variant: :danger,
+ dismissible: false,
+ alert_options: { class: 'gl-mb-5' }) do |c|
+ - c.with_body do
+ %ul
+ - @pipeline.yaml_errors.split("\n").each do |error|
+ %li= error
+ - if can_view_pipeline_editor?(@project)
+ = render Pajamas::ButtonComponent.new(href: project_ci_pipeline_editor_path(@project), variant: :confirm) do
+ = s_("Pipelines|Go to the pipeline editor")
- else
#js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 4ac0e28d386..a0a90fbe204 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -25,7 +25,6 @@
classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3',
trigger_source: 'project-members-page',
display_text: _('Invite members') } }
- = render 'projects/invite_members_modal', project: @project, reload_page_on_submit: true
- else
- if project_can_be_shared?
%h4
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index d19a6401fc8..ef3974b04b5 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -1,9 +1,9 @@
- content_for :create_access_levels do
.create_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-create wide',
+ options: { toggle_class: 'js-allowed-to-create js-multiselect wide',
dropdown_class: 'dropdown-menu-selectable capitalize-header',
- dropdown_qa_selector: 'access_levels_content',
+ dropdown_qa_selector: 'access_levels_content', dropdown_testid: 'allowed-to-create-dropdown',
data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes', qa_selector: 'access_levels_dropdown' }})
= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
index e0912bf39c0..68e4a5e97a3 100644
--- a/app/views/projects/protected_tags/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -1,4 +1,4 @@
= render layout: 'projects/protected_tags/shared/protected_tag', locals: { protected_tag: protected_tag } do
- %td
- = render 'projects/protected_tags/protected_tag_create_access_levels', protected_tag: protected_tag, create_access_level: protected_tag.create_access_levels.for_role.first
+ %td.create_access_levels-container
+ = render 'projects/protected_tags/protected_tag_create_access_levels', protected_tag: protected_tag, create_access_level: protected_tag.create_access_levels.for_role
= render_if_exists 'projects/protected_tags/protected_tag_extra_create_access_levels', protected_tag: protected_tag
diff --git a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml
index 1d4e9565156..30b9e3e9005 100644
--- a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml
+++ b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml
@@ -1,8 +1,8 @@
- protected_tag = local_assigns.fetch(:protected_tag)
- create_access_level = local_assigns.fetch(:create_access_level)
-- dropdown_label = create_access_level&.humanize || 'Select'
+- dropdown_label = create_access_level.first&.humanize || 'Select'
-= hidden_field_tag "allowed_to_create_#{protected_tag.id}", create_access_level&.access_level
+= hidden_field_tag "allowed_to_create_#{protected_tag.id}", create_access_level.first&.access_level
= dropdown_tag(dropdown_label,
- options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container',
- data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: create_access_level&.id }})
+ options: { toggle_class: 'js-allowed-to-create js-multiselect', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container',
+ data: { field_name: "allowed_to_create_#{protected_tag.id}", preselected_items: access_levels_data(create_access_level) }})
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index 1db1da5e428..d67c3dc19d7 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -1,9 +1,9 @@
= gitlab_ui_form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' }
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c|
- - c.header do
+ - c.with_header do
= _('Protect a tag')
- - c.body do
+ - c.with_body do
= form_errors(@protected_tag)
.form-group.row
= f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right'
@@ -19,5 +19,5 @@
.create_access_levels-container
= yield :create_access_levels
- - c.footer do
+ - c.with_footer do
= f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/readme_templates/default.md.tt b/app/views/projects/readme_templates/default.md.tt
index cd0b2db1d31..ad1d6cce08d 100644
--- a/app/views/projects/readme_templates/default.md.tt
+++ b/app/views/projects/readme_templates/default.md.tt
@@ -17,8 +17,8 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
```
cd existing_repo
git remote add origin <%= project.http_url_to_repo %>
-git branch -M <%= project.default_branch_or_main %>
-git push -uf origin <%= project.default_branch_or_main %>
+git branch -M <%= params[:default_branch] %>
+git push -uf origin <%= params[:default_branch] %>
```
## Integrate with your tools
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 910aab6da72..e1ec510d271 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
@@ -19,6 +18,7 @@
"gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
"show_cleanup_policy_link": show_cleanup_policy_link(@project).to_s,
+ "show_container_registry_settings": show_container_registry_settings(@project).to_s,
"cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project),
connection_error: (!!@connection_error).to_s,
invalid_path_error: (!!@invalid_path_error).to_s,
diff --git a/app/views/projects/releases/new.html.haml b/app/views/projects/releases/new.html.haml
index 4348035a324..908cbd00f47 100644
--- a/app/views/projects/releases/new.html.haml
+++ b/app/views/projects/releases/new.html.haml
@@ -1,3 +1,5 @@
-- page_title s_('Releases|New Release')
+- add_to_breadcrumbs s_("Releases|Releases"), project_releases_path(@project)
+- page_title s_('Releases|New')
+- add_page_specific_style 'page_bundles/releases'
#js-new-release-page{ data: data_for_new_release_page }
diff --git a/app/views/projects/runners/_project_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml
index 543a564568b..1d4e45c71b5 100644
--- a/app/views/projects/runners/_project_runners.html.haml
+++ b/app/views/projects/runners/_project_runners.html.haml
@@ -2,18 +2,26 @@
= s_('Runners|Project runners')
.bs-callout.help-callout
- - if can?(current_user, :register_project_runners, @project)
- = s_('Runners|These runners are assigned to this project.')
- %hr
- = render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @project.runners_token,
- type: _('project'),
- reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
- project_path: @project.path_with_namespace,
- group_path: '' }
+ %p= s_('Runners|These runners are assigned to this project.')
+ - if Feature.enabled?(:create_runner_workflow_for_namespace, @project.namespace)
+ - if can?(current_user, :create_runner, @project)
+ = render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do
+ = s_('Runners|New project runner')
+ #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } }
+ - else
+ = _('Please contact an admin to create runners.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
- else
- = _('Please contact an admin to register runners.')
- = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ - if can?(current_user, :register_project_runners, @project)
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: @project.runners_token,
+ type: _('project'),
+ reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
+ project_path: @project.path_with_namespace,
+ group_path: '' }
+ - else
+ = _('Please contact an admin to register runners.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
%hr
diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml
index ce56b160187..58955663bfa 100644
--- a/app/views/projects/runners/edit.html.haml
+++ b/app/views/projects/runners/edit.html.haml
@@ -1,10 +1,11 @@
+- runner_name = runner_short_name(@runner)
- breadcrumb_title _('Edit')
-- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})"
+- page_title _('Edit'), runner_name
- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project)
-- add_to_breadcrumbs "#{@runner.short_sha}", project_runner_path(@project, @runner)
+- add_to_breadcrumbs runner_name, project_runner_path(@project, @runner)
%h1.page-title.gl-font-size-h-display
- = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
+ = s_('Runners|Runner #%{runner_id}') % { runner_id: @runner.id }
= render 'shared/runners/runner_type_badge', runner: @runner
= render 'shared/runners/runner_type_alert', runner: @runner
diff --git a/app/views/projects/runners/new.html.haml b/app/views/projects/runners/new.html.haml
new file mode 100644
index 00000000000..4aeed910452
--- /dev/null
+++ b/app/views/projects/runners/new.html.haml
@@ -0,0 +1,5 @@
+- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project)
+- breadcrumb_title s_('Runners|New runner')
+- page_title s_('Runners|Create a project runner')
+
+#js-project-new-runner{ data: { project_id: @project.to_global_id } }
diff --git a/app/views/projects/runners/register.html.haml b/app/views/projects/runners/register.html.haml
new file mode 100644
index 00000000000..a8ce08f051f
--- /dev/null
+++ b/app/views/projects/runners/register.html.haml
@@ -0,0 +1,6 @@
+- runner_name = runner_short_name(@runner)
+- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project)
+- breadcrumb_title s_('Runners|Register runner')
+- page_title s_('Runners|Register'), runner_name
+
+#js-project-register-runner{ data: { runner_id: @runner.id, runners_path: project_runners_path(@project) } }
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
index cb7984729c8..60f403b1015 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -1,3 +1,6 @@
+- runner_name = runner_short_name(@runner)
+- breadcrumb_title runner_name
+- page_title runner_name
- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project)
= render 'shared/runners/runner_details', runner: @runner
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index 4b82f74d035..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
+- breadcrumb_title _("Security configuration")
+- page_title _("Security configuration")
#js-security-configuration{ data: { **@configuration.to_html_data_attribute,
vulnerability_training_docs_path: vulnerability_training_docs_path,
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 5f1dee39e25..847f9ad3e2a 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -16,7 +16,6 @@
.row
.form-group.col-md-9
- = f.label :topics, _('Topics'), class: 'label-bold'
.js-topics-selector{ data: { hidden_input_id: hidden_topics_field_id } }
= f.hidden_field :topics, value: @project.topic_list.join(', '), id: hidden_topics_field_id
@@ -31,8 +30,7 @@
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
.form-group.gl-mt-3.gl-mb-3
- .avatar-container.rect-avatar.s90
- = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90')
+ = render Pajamas::AvatarComponent.new(@project, size: 96, alt: '', class: 'gl-float-left gl-mr-5')
= f.label :avatar, _('Project avatar'), class: 'label-bold d-block'
= render 'shared/choose_avatar_button', f: f
- if @project.avatar?
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index f6c5c4e2950..26c08fcdfe4 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -2,7 +2,7 @@
- page_title _('Project Access Tokens')
- type = _('project access token')
- type_plural = _('project access tokens')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.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 80a41bb579b..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: { project_path: @project.full_path, protected_branches_path: project_settings_repository_path(@project, anchor: 'js-protected-branches-settings'), approval_rules_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-approval-settings'), status_checks_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-settings'), branches_path: project_branches_path(@project) } }
+#js-branch-rules{ data: branch_rules_data(@project) }
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 86238a41f0b..46cfcf20535 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -20,15 +20,15 @@
%fieldset.builds-feature.js-auto-devops-settings
.form-group
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
- .card.gl-mb-3
- .card-body
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }, footer_options: { class: auto_devops_enabled || 'hidden' }) do |c|
+ - c.with_body do
- autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer'
- auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : ''
= form.gitlab_ui_checkbox_component :enabled,
(s_('CICD|Default to Auto DevOps pipeline') + auto_devops_badge).html_safe,
checkbox_options: { class: 'js-toggle-extra-settings', checked: auto_devops_enabled, data: { qa_selector: 'enable_autodevops_checkbox' } },
help_text: (s_('CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') + ' ' + autodevops_help_link).html_safe
- .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
+ - c.with_footer do
- if @project.all_clusters.empty?
%p.settings-message.text-center
= s_('CICD|Add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} with a domain, or create an AUTO_DEVOPS_PLATFORM_TARGET CI variable.').html_safe % { kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end }
diff --git a/app/views/projects/settings/ci_cd/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml
index 99eef38827b..e9766b36c79 100644
--- a/app/views/projects/settings/ci_cd/_badge.html.haml
+++ b/app/views/projects/settings/ci_cd/_badge.html.haml
@@ -3,14 +3,14 @@
%h4
= badge.title.capitalize
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, header_options: { class: 'gl-display-flex gl-align-items-center' }) do |c|
- - c.header do
+ - c.with_header do
.gl-flex-grow-1
%b
= badge.title.capitalize
&middot;
= badge.to_html
- = render 'shared/ref_switcher', destination: 'badges', align_right: true
- - c.body do
+ .js-ref-switcher-badge{ id: "js-project-ci-cd-ref-switcher-#{badge.title.parameterize(separator: '-') }", data: { project_id: @project.id, ref: @ref } }
+ - c.with_body do
.row
.col-md-2.gl-text-center
Markdown
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 68dc7f2be8d..6f64d3f3f76 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -18,7 +18,7 @@
_("Auto-cancel redundant pipelines"),
checked_value: 'enabled',
unchecked_value: 'disabled',
- help_text: (_('New pipelines cause older pending or running pipelines on the same branch to be cancelled.') + ' ' + help_link_auto_canceling).html_safe
+ help_text: (_('Pipelines for new changes cause older pending or running pipelines on the same branch to be cancelled.') + ' ' + help_link_auto_canceling).html_safe
.form-group
= f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index b27f5a0e5ed..c7bb6a7f5da 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -1,6 +1,6 @@
-- @content_class = "limit-container-width" unless fluid_layout
- page_title _("CI/CD Settings")
- page_title _("CI/CD")
+- @force_desktop_expanded_sidebar = true
- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
@@ -58,7 +58,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/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml
index 9d74f99bb19..39dfd410727 100644
--- a/app/views/projects/settings/integrations/_form.html.haml
+++ b/app/views/projects/settings/integrations/_form.html.haml
@@ -8,9 +8,12 @@
= render Pajamas::AlertComponent.new(title: s_('ExternalIssueIntegration|Another issue tracker is already in use'),
variant: :warning,
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= s_('ExternalIssueIntegration|Only one issue tracker integration can be active at a time. Please disable the active tracker first and try again.')
+- if integration.to_param === 'slack'
+ = render 'shared/integrations/slack_notifications_deprecation_alert'
+
%h2.gl-mb-4
= integration.title
- if integration.operating?
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 2077d244b24..59cdda5bb92 100644
--- a/app/views/projects/settings/integrations/index.html.haml
+++ b/app/views/projects/settings/integrations/index.html.haml
@@ -1,6 +1,8 @@
-- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title _('Integration Settings')
- page_title _('Integrations')
+- @force_desktop_expanded_sidebar = true
+
+= render 'shared/integrations/slack_notifications_deprecation_alert'
%section.js-search-settings-section
%h3= _('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..ef528d17fc9 100644
--- a/app/views/projects/settings/merge_requests/show.html.haml
+++ b/app/views/projects/settings/merge_requests/show.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title _('Merge requests')
- page_title _('Merge requests')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
%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..d44ebf1eb83 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -1,10 +1,15 @@
-- @content_class = 'limit-container-width' unless fluid_layout
- page_title _('Monitor Settings')
- breadcrumb_title _('Monitor Settings')
+- @force_desktop_expanded_sidebar = true
+
+- if Feature.disabled?(:remove_monitor_metrics)
+ = render 'projects/settings/operations/metrics_dashboard'
-= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
-= render 'projects/settings/operations/grafana_integration'
+
+- if Feature.disabled?(:remove_monitor_metrics)
+ = render 'projects/settings/operations/grafana_integration'
+
= render_if_exists 'projects/settings/operations/status_page'
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..6f38a3ace92 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,5 @@
- breadcrumb_title _('Packages and registries settings')
- page_title _('Packages and registries settings')
-- @content_class = 'limit-container-width' unless fluid_layout
+- @force_desktop_expanded_sidebar = true
#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..12404180362 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -1,7 +1,7 @@
- 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.')
+- @force_desktop_expanded_sidebar = true
= render "projects/branch_defaults/show"
- if Feature.enabled?(:branch_rules, @project)
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 5fa70c3af32..8a35db357ee 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'
@@ -8,7 +7,9 @@
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
+= render_if_exists 'shared/promotions/promote_mobile_devops', project: @project
= render partial: 'flash_messages', locals: { project: @project }
+
= render 'clusters_deprecation_alert'
= render "projects/last_push"
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/index.html.haml b/app/views/projects/snippets/index.html.haml
index f53b2051835..7c936c849d0 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -4,7 +4,7 @@
- if @snippets.exists?
- if current_user
.top-area
- - include_private = @project.team.member?(current_user) || current_user.admin?
+ - include_private = @project.member?(current_user) || current_user.admin?
= render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private, counts: @snippet_counts }
- if new_project_snippet_link.present?
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index 5086b5eaa3d..fd74ffef425 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -1,9 +1,7 @@
- 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")
-%hr
= render "shared/snippets/form", url: project_snippets_path(@project, @snippet)
diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml
index c1cd2488142..16ae003255c 100644
--- a/app/views/projects/starrers/_starrer.html.haml
+++ b/app/views/projects/starrers/_starrer.html.haml
@@ -1,8 +1,8 @@
- starrer = local_assigns.fetch(:starrer)
.col-lg-3.col-md-4.col-sm-12
- .card
- .card-body.gl-display-flex
+ = render Pajamas::CardComponent.new(body_options: { class: 'gl-display-flex' }) do |c|
+ - c.with_body do
= render Pajamas::AvatarComponent.new(starrer.user, size: 48, alt: "", class: 'gl-mr-3')
.user-info
diff --git a/app/views/projects/tags/_edit_release_button.html.haml b/app/views/projects/tags/_edit_release_button.html.haml
index 1c2626e5612..9a6c18df2ca 100644
--- a/app/views/projects/tags/_edit_release_button.html.haml
+++ b/app/views/projects/tags/_edit_release_button.html.haml
@@ -5,5 +5,5 @@
- if release
- release_btn_text = s_('TagsPage|Edit release')
- release_btn_path = edit_project_release_path(project, release)
-= link_to release_btn_path, class: css_classes, title: release_btn_text, data: { container: "body" } do
- = sprite_icon('pencil', css_class: 'gl-icon')
+= link_to release_btn_path, class: css_classes do
+ = release_btn_text
diff --git a/app/views/projects/tags/_release_link.html.haml b/app/views/projects/tags/_release_link.html.haml
index 6c79b13f438..9284204af77 100644
--- a/app/views/projects/tags/_release_link.html.haml
+++ b/app/views/projects/tags/_release_link.html.haml
@@ -1,5 +1,5 @@
- if can?(current_user, :read_release, release)
- .gl-text-secondary
- = sprite_icon("rocket", size: 12)
- = _("Release")
+ %span
+ = sprite_icon("rocket", size: 12, css_class: "gl-text-secondary")
+ = _("Release:")
= link_to release.name, project_release_path(project, release), class: "gl-text-blue-600!"
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index fcad8509a7d..cc49ff9e293 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -2,9 +2,9 @@
- release = @releases.find { |release| release.tag == tag.name }
- commit_status = @tag_pipeline_statuses[tag.name] unless @tag_pipeline_statuses.nil?
-%li.flex-row.js-tag-list{ class: "gl-white-space-normal! gl-align-items-flex-start!" }
+%li.gl-justify-content-space-between{ class: "gl-md-display-flex! gl-align-items-flex-start!", data: { testid: 'tag-row' } }
.row-main-content
- = sprite_icon('tag')
+ = sprite_icon('tag', css_class: "gl-text-secondary")
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
- if protected_tag?(@project, tag)
@@ -21,12 +21,13 @@
= render 'release_link', project: @project, release: release
- if tag.message.present?
- %pre.wrap
+ %pre.wrap.gl-mt-3.gl-max-w-80
= strip_signature(tag.message)
- .row-fixed-content.controls.flex-row
+ .row-fixed-content.flex-row
- if tag.has_signature?
- = render partial: 'projects/commit/signature', object: tag.signature
+ .gl-mr-3
+ = render partial: 'projects/commit/signature', object: tag.signature
- if commit_status
= render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
@@ -34,8 +35,11 @@
.gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
%svg.s24
- = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
-
- if can?(current_user, :admin_tag, @project)
= render 'edit_release_button', tag: tag, project: @project, release: release, option_css_classes: 'gl-mr-3!'
+
+ .gl-mr-3
+ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
+
+ - if can?(current_user, :admin_tag, @project)
= render 'projects/buttons/remove_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 2f8291d255f..1df323e7451 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -1,9 +1,10 @@
-- page_title s_('TagsPage|New Tag')
+- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project)
+- page_title s_('TagsPage|New')
- default_ref = params[:ref] || @project.default_branch
- if @error
= render Pajamas::AlertComponent.new(variant: :danger, dismissible: true) do |c|
- = c.body do
+ - c.with_body do
= @error
%h1.page-title.gl-font-size-h-display
@@ -37,4 +38,3 @@
= s_('TagsPage|Create tag')
= render Pajamas::ButtonComponent.new(href: project_tags_path(@project)) do
= s_('TagsPage|Cancel')
-
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/terraform/index.html.haml b/app/views/projects/terraform/index.html.haml
index 21a4fe5eae6..2182778469a 100644
--- a/app/views/projects/terraform/index.html.haml
+++ b/app/views/projects/terraform/index.html.haml
@@ -1,6 +1,6 @@
- add_page_specific_style 'page_bundles/ci_status'
-- breadcrumb_title _('Terraform')
-- page_title _('Terraform')
+- breadcrumb_title s_('Terraform|Terraform states')
+- page_title s_('Terraform|Terraform states')
#js-terraform-list{ data: js_terraform_list_data(current_user, @project) }
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index d494d9cc36d..c834a0bc818 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,7 +1,7 @@
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
.tree-ref-container.gl-display-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0
- .tree-ref-holder.gl-max-w-26
+ .tree-ref-holder.gl-max-w-26{ data: { qa_selector: 'ref_dropdown_container' } }
#js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } }
#js-repo-breadcrumb{ data: breadcrumb_data_attributes }
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/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index de127d15351..b68aad24b50 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,13 +1,13 @@
.row.gl-mt-3.gl-mb-3
.col-lg-12
= render Pajamas::CardComponent.new do |c|
- - c.header do
+ - c.with_header do
= _("Manage your project's triggers")
- - c.body do
+ - c.with_body do
= render 'projects/triggers/form', btn_text: _('Add trigger')
.gl-mb-5
#js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- - c.footer do
+ - c.with_footer do
%p
= _("These examples show how to trigger this project's pipeline for a branch or tag.")
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
index 5e2217d3c9f..aad96151678 100644
--- a/app/views/projects/usage_quotas/index.html.haml
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -1,11 +1,13 @@
- page_title s_("UsageQuota|Usage")
+- add_page_specific_style 'page_bundles/projects_usage_quotas'
+- @force_desktop_expanded_sidebar = true
= render_if_exists 'shared/ultimate_feature_removal_banner', project: @project
= render Pajamas::AlertComponent.new(title: _('Repository usage recalculation started'),
variant: :info,
alert_options: { class: 'js-recalculation-started-alert gl-mt-4 gl-mb-5 gl-display-none' }) do |c|
- = c.body do
+ - c.with_body do
= _('To view usage, refresh this page in a few minutes.')
%h1.page-title.gl-font-size-h-display
@@ -17,10 +19,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/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml
index 69597aab7ef..01b27eed267 100644
--- a/app/views/projects/work_items/index.html.haml
+++ b/app/views/projects/work_items/index.html.haml
@@ -1,7 +1,7 @@
-- page_title s_('WorkItem|Work Items')
+- page_title "##{request.params['work_items_path']}"
+- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- add_page_specific_style 'page_bundles/work_items'
- @gfm_form = true
- @noteable_type = 'WorkItem'
#js-work-items{ data: work_items_index_data(@project) }
-= render 'projects/invite_members_modal', project: @project
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/protected_branches/shared/_update_protected_branch.html.haml b/app/views/protected_branches/shared/_update_protected_branch.html.haml
index 0244f9e2158..ad61f557bb8 100644
--- a/app/views/protected_branches/shared/_update_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_update_protected_branch.html.haml
@@ -1,37 +1,21 @@
- merge_access_levels = protected_branch.merge_access_levels.for_role
- push_access_levels = protected_branch.push_access_levels.for_role
-- user_merge_access_levels = protected_branch.merge_access_levels.for_user
-- user_push_access_levels = protected_branch.push_access_levels.for_user
-
-- group_merge_access_levels = protected_branch.merge_access_levels.for_group
-- group_push_access_levels = protected_branch.push_access_levels.for_group
-
%td.merge_access_levels-container
= hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level
= dropdown_tag((merge_access_levels.first&.humanize || 'Select') ,
options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header',
data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }})
- - if user_merge_access_levels.any?
- %p.small
- = _('The following %{user} can also merge into this branch: %{branch}') % { user: 'user'.pluralize(user_merge_access_levels.size), branch: user_merge_access_levels.map(&:humanize).to_sentence }
-
- - if group_merge_access_levels.any?
- %p.small
- = _('Members of %{group} can also merge into this branch: %{branch}') % { group: (group_merge_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_merge_access_levels.map(&:humanize).to_sentence }
+ = render_if_exists 'protected_branches/shared/user_merge_access_levels', protected_branch: protected_branch
+ = render_if_exists 'protected_branches/shared/group_merge_access_levels', protected_branch: protected_branch
%td.push_access_levels-container
= hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level
= dropdown_tag((push_access_levels.first&.humanize || 'Select') ,
options: { toggle_class: "js-allowed-to-push js-multiselect", dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }})
- - if user_push_access_levels.any?
- %p.small
- = _('The following %{user} can also push to this branch: %{branch}') % { user: 'user'.pluralize(user_push_access_levels.size), branch: user_push_access_levels.map(&:humanize).to_sentence }
-
- - if group_push_access_levels.any?
- %p.small
- = _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence }
+ = render_if_exists 'protected_branches/shared/user_push_access_levels', protected_branch: protected_branch
+ = render_if_exists 'protected_branches/shared/group_push_access_levels', protected_branch: protected_branch
%td
= render Pajamas::ToggleComponent.new(classes: 'js-force-push-toggle',
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index f4e9a597fe2..6c8ab5654a0 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"
@@ -17,7 +18,7 @@
- else
%p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text }
= gitlab_ui_form_for(current_user,
- url: users_sign_up_welcome_path(glm_tracking_params),
+ url: users_sign_up_welcome_path(welcome_update_params),
html: { class: 'gl-w-full! gl-p-5 js-users-signup-welcome',
'aria-live' => 'assertive',
data: { testid: 'welcome-form' } }) do |f|
@@ -28,7 +29,7 @@
.row
.form-group.col-sm-12
= f.label :role, _('Role'), class: 'label-bold'
- = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { include_blank: _('Select a role') }, class: 'form-control js-user-role-dropdown', autofocus: true, required: true, data: { qa_selector: 'role_dropdown' }
+ = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { include_blank: _('Select a role') }, class: 'form-control js-user-role-dropdown', required: true, data: { qa_selector: 'role_dropdown' }
= render_if_exists "registrations/welcome/jobs_to_be_done", f: f
= render_if_exists "registrations/welcome/setup_for_company", f: f
= render_if_exists "registrations/welcome/joining_project"
@@ -38,4 +39,5 @@
- if partial_exists? "registrations/welcome/button"
= render "registrations/welcome/button"
- else
- = f.submit _('Get started!'), class: 'btn-confirm gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
+ = render Pajamas::ButtonComponent.new(block: true, type: :submit, variant: :confirm, button_options: { class: 'gl-mb-0', data: { qa_selector: 'get_started_button' }}) do
+ = _('Get started!')
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index fee943042f9..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' unless @search_objects.to_a.empty?
- = 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 7a57b5cc0fc..fcbf0ba4452 100644
--- a/app/views/search/_results_list.html.haml
+++ b/app/views/search/_results_list.html.haml
@@ -2,19 +2,22 @@
= render partial: "search/results/timeout"
- elsif @search_results.respond_to?(:failed?) && @search_results.failed?
= render partial: "search/results/error"
-- elsif @search_objects.to_a.empty?
+- elsif @search_objects.blank?
= render partial: "search/results/empty"
- else
- - if @scope == 'commits'
- %ul.content-list.commit-list
- = render partial: "search/results/commit", collection: @search_objects
- - else
- .search-results.js-search-results
- - if @scope == 'projects'
- .term
- = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- - else
- = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ - statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : ''
- - if @scope != 'projects'
- = paginate_collection(@search_objects)
+ .section{ class: statusBarClass }
+ - if @scope == 'commits'
+ %ul.content-list.commit-list
+ = render partial: "search/results/commit", collection: @search_objects
+ - else
+ .search-results.js-search-results
+ - if @scope == 'projects'
+ .term
+ = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
+ - else
+ = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
+
+ - if @scope != 'projects'
+ = paginate_collection(@search_objects)
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
index 27405631360..6fc07d35296 100644
--- a/app/views/search/_results_status.html.haml
+++ b/app/views/search/_results_status.html.haml
@@ -1,25 +1,28 @@
- return unless @search_service_presenter.show_results_status?
+- statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : ''
-.search-results-status
- .gl-display-flex.gl-flex-direction-column
- .gl-p-5.gl-display-flex
- .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1.gl-white-space-nowrap.gl-max-w-full
- - unless @search_service_presenter.without_count?
- = search_entries_info(@search_objects, @scope, @search_term)
- - unless @search_service_presenter.show_snippets?
- - if @project
- - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1 gl-text-truncate search-wrap-f-md-down')
- - if @scope == 'blobs'
- = _("in")
- .mx-md-1
- #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } }
- = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- - else
- = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- - elsif @group
- - link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
- = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
- - if @search_service_presenter.show_sort_dropdown?
- .gl-md-display-flex.gl-flex-direction-column
- #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } }
- %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full
+.section{ class: statusBarClass }
+ .search-results-status
+ .gl-display-flex.gl-flex-direction-column
+ .gl-p-5.gl-display-flex
+ .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1.gl-white-space-nowrap.gl-max-w-full
+ - unless @search_service_presenter.without_count?
+ .gl-text-truncate
+ = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @search_service_presenter.show_snippets?
+ - if @project
+ - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1 gl-text-truncate search-wrap-f-md-down')
+ - if @scope == 'blobs'
+ = _("in")
+ .mx-md-1
+ #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } }
+ = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
+ - else
+ = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
+ - elsif @group
+ - link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
+ = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
+ - if @search_service_presenter.show_sort_dropdown?
+ .gl-md-display-flex.gl-flex-direction-column
+ #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } }
+ %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 3681f823ef5..115bb6cc9fa 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,6 +1,8 @@
- project = blob.project
- return unless project
-- blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path))
-- blame_link = project_blame_path(project, tree_join(repository_ref(project), blob.path))
+- project_repository_ref = repository_ref(project) || ''
+- blob_path = blob.path || ''
+- blob_link = project_blob_path(project, tree_join(project_repository_ref, blob_path))
+- blame_link = project_blame_path(project, tree_join(project_repository_ref, blob_path))
-= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link, blame_link: blame_link }
+= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob_path, blob_link: blob_link, blame_link: blame_link }
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 04103794e60..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
-- @hide_breadcrumbs = true
+- 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?
@@ -19,7 +21,8 @@
%h1.page-title.gl-font-size-h-display.gl-mr-5= _('Search')
= render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
-.gl-mt-3
- #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'
+#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 } }
+.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/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index 4d286713cef..a2fed883739 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -9,6 +9,8 @@
= sprite_icon(icon_name)
.gl-broadcast-message-text.js-broadcast-message-preview
- if message.message.present?
+ %h2.gl-sr-only
+ = s_("Admin message")
= render_broadcast_message(message)
- else
= yield
@@ -24,6 +26,8 @@
.broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } }
= sprite_icon(icon_name, css_class: 'vertical-align-text-top')
- if message.message.present?
+ %h2.gl-sr-only
+ = s_("Admin message")
= render_broadcast_message(message)
- else
= yield
diff --git a/app/views/shared/_captcha_check.html.haml b/app/views/shared/_captcha_check.html.haml
index a10ae655ea6..b3b2ae0d969 100644
--- a/app/views/shared/_captcha_check.html.haml
+++ b/app/views/shared/_captcha_check.html.haml
@@ -10,7 +10,7 @@
%p
= _("We detected potential spam in the %{humanized_resource_name}. Please solve the reCAPTCHA to proceed.") % { humanized_resource_name: humanized_resource_name }
-= form_for resource_name, method: method, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
+= gitlab_ui_form_for resource_name, method: method, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
.recaptcha
-# Create a hidden field for each param of the resource
- params[resource_name].each do |field, value|
@@ -34,4 +34,4 @@
= yield
.row-content-block.footer-block
- = f.submit _("Create %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'gl-button btn btn-confirm'
+ = f.submit _("Create %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, pajamas_button: true
diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml
index 904854c3fb7..2b55d35cf1f 100644
--- a/app/views/shared/_commit_message_container.html.haml
+++ b/app/views/shared/_commit_message_container.html.haml
@@ -1,19 +1,18 @@
-.form-group.row.commit_message-group
+.form-group.commit_message-group.gl-mt-5
- nonce = SecureRandom.hex
- descriptions = local_assigns.slice(:message_with_description, :message_without_description)
- = label_tag "commit_message-#{nonce}", class: 'col-form-label col-sm-2' do
+ = label_tag "commit_message-#{nonce}" do
#{ _('Commit message') }
- .col-sm-10
- .commit-message-container
- .max-width-marker
- = text_area_tag 'commit_message',
- (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]),
- class: 'form-control gl-form-input js-commit-message',
- placeholder: local_assigns[:placeholder],
- data: descriptions,
- 'data-qa-selector': 'commit_message_field',
- required: true, rows: (local_assigns[:rows] || 3),
- id: "commit_message-#{nonce}"
+ .commit-message-container
+ .max-width-marker
+ = text_area_tag 'commit_message',
+ (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]),
+ class: 'form-control gl-form-input js-commit-message',
+ placeholder: local_assigns[:placeholder],
+ data: descriptions,
+ 'data-qa-selector': 'commit_message_field',
+ required: true, rows: (local_assigns[:rows] || 3),
+ id: "commit_message-#{nonce}"
- if local_assigns[:hint]
%p.hint
= _('Try to keep the first line under 52 characters and the others under 72.')
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/_issues.html.haml b/app/views/shared/_issues.html.haml
deleted file mode 100644
index 360a3f3eb89..00000000000
--- a/app/views/shared/_issues.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-= render 'shared/alerts/positioning_disabled' if @sort == 'relative_position'
-
-- if @issues.to_a.any?
- %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
- = render partial: 'projects/issues/issue', collection: @issues
- = paginate @issues, theme: "gitlab"
-- else
- = render 'shared/empty_states/issues'
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 8a626f1620b..1aac7af443f 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -6,56 +6,59 @@
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- tooltip_title = label_status_tooltip(label, status) if status
-%li.label-list-item{ id: label_css_id, class: "gl-p-5 gl-border-b", data: { id: label.id } }
- = render "shared/label_row", label: label, force_priority: force_priority
- %ul.label-actions-list
- - if can?(current_user, :admin_label, @project)
- %li.gl-display-inline-block.js-toggle-priority.gl-ml-3{ data: { url: remove_priority_project_label_path(@project, label),
- dom_id: dom_id(label), type: label.type } }
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- icon: 'star',
- button_options: { class: 'remove-priority has-tooltip', 'title': _('Remove priority'), 'aria_label': _('Deprioritize label'), data: { placement: 'bottom' } })
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- icon: 'star-o',
- button_options: { class: 'add-priority has-tooltip', title: _('Prioritize'), aria_label: _('Prioritize label'), data: { placement: 'bottom' } })
- - if can?(current_user, :admin_label, label)
- %li.gl-display-inline-block
- = render Pajamas::ButtonComponent.new(href: label.edit_path, category: :tertiary, icon: 'pencil', button_options: { class: 'edit has-tooltip', 'title': _('Edit'), 'aria_label': _('Edit'), data: { placement: 'bottom' } })
- - if can?(current_user, :admin_label, label)
- %li.gl-display-inline-block
- .dropdown
+%li.label-list-item.gl-list-style-none.gl-py-3{ id: label_css_id, data: { id: label.id } }
+ .label-content.gl-px-3.gl-py-2.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" }
+ = render "shared/label_row", label: label, force_priority: force_priority
+ %ul.label-actions-list
+ - if can?(current_user, :admin_label, @project)
+ %li.gl-display-inline-block.js-toggle-priority.gl-ml-3{ data: { url: remove_priority_project_label_path(@project, label),
+ dom_id: dom_id(label), type: label.type } }
= render Pajamas::ButtonComponent.new(category: :tertiary,
- icon: 'ellipsis_v',
- button_options: { class: 'js-label-options-dropdown', 'aria_label': _('Label actions dropdown'), data: { toggle: 'dropdown' } })
- .dropdown-menu.dropdown-open-left
- %ul
- - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
- %li
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } }) do
- = _('Promote to group label')
- %li
- %span
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }) do
- = _('Delete')
- - if current_user
- %li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3
- - if label.can_subscribe_to_label_in_different_levels?
- = render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{'hidden' if status.unsubscribed?}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do
- = _('Unsubscribe')
- .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
- = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-w-full', data: { toggle: 'dropdown' } }) do
- = _('Subscribe')
- = sprite_icon('chevron-down')
- .dropdown-menu.dropdown-open-left
+ size: :small,
+ icon: 'star',
+ button_options: { class: 'remove-priority has-tooltip', 'title': _('Remove priority'), 'aria_label': _('Deprioritize label'), data: { placement: 'bottom' } })
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ size: :small,
+ icon: 'star-o',
+ button_options: { class: 'add-priority has-tooltip', title: _('Prioritize'), aria_label: _('Prioritize label'), data: { placement: 'bottom' } })
+ - if current_user
+ %li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3.gl-mt-1
+ - if label.can_subscribe_to_label_in_different_levels?
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "js-unsubscribe-button gl-w-full #{'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(size: :small, button_options: { class: "gl-w-full", data: { toggle: 'dropdown' } }) do
+ = _('Subscribe')
+ = sprite_icon('chevron-down')
+ .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
+ = _('Subscribe at project level')
+ %li
+ = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }) do
+ = _('Subscribe at group level')
+ - else
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do
+ = label_subscription_toggle_button_text(label, @project)
+ - if can?(current_user, :admin_label, label)
+ %li.gl-display-inline-block
+ .dropdown
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ size: :small,
+ icon: 'ellipsis_v',
+ button_options: { class: 'js-label-options-dropdown gl-ml-3', 'aria_label': _('Label actions dropdown'), title: _('Label actions dropdown'), data: { toggle: 'dropdown' } })
+ .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
- = _('Subscribe at project level')
+ = render Pajamas::ButtonComponent.new(category: :tertiary, href: label.edit_path, variant: :link) do
+ = _('Edit')
+ - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
+ %li
+ = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
+ button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } }) do
+ = _('Promote to group label')
%li
- = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }) do
- = _('Subscribe at group level')
- - else
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do
- = label_subscription_toggle_button_text(label, @project)
+ = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link,
+ button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }) do
+ = _('Delete')
diff --git a/app/views/shared/_label_full_path.html.haml b/app/views/shared/_label_full_path.html.haml
index fd67bbbbd10..b9e5e979ce5 100644
--- a/app/views/shared/_label_full_path.html.haml
+++ b/app/views/shared/_label_full_path.html.haml
@@ -1,4 +1,8 @@
- full_path = label.subject_full_name
-.label-badge.gl-bg-gray-50.gl-max-w-full.gl-text-truncate{ title: full_path }
+.gl-font-sm.gl-font-weight-semibold.gl-text-secondary
+ - if label.project_label?
+ = sprite_icon('project', size: 12, css_class: 'gl-text-gray-600')
+ - else
+ = sprite_icon('group', size: 12, css_class: 'gl-text-gray-600')
= full_path
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index c351ea29c7c..19489981d94 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -3,28 +3,25 @@
- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues)
- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests)
-.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-5
+.label-name.gl-flex-shrink-0.gl-mr-5
= render_label(label, tooltip: false)
-.label-description.gl-overflow-hidden.gl-w-full
- .gl-display-flex.gl-align-items-stretch.gl-flex-wrap.gl-mt-2
- .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-5
+ - if show_labels_full_path?(@project, @group)
+ .gl-mt-2
+ = render 'shared/label_full_path', label: label
+.label-description.gl-w-full
+ .gl-display-flex.gl-align-items-stretch.gl-flex-wrap
+ .gl-flex-basis-half.gl-flex-grow-1.gl-mr-5
- if label.description.present?
- = markdown_field(label, :description)
- - elsif show_labels_full_path?(@project, @group)
- = render 'shared/label_full_path', label: label
+ .gl-my-1
+ = markdown_field(label, :description)
%ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap
+ - if force_priority
+ %li.js-priority-badge.inline.gl-mr-3.gl-mt-1
+ .label-badge.gl-bg-blue-50= _('Prioritized')
- if show_label_issues_link
- %li.inline
- = link_to_label(label, css_class: 'gl-text-blue-600!') { _('Issues') }
+ %li.inline.gl-my-1
+ = link_to_label(label, css_class: 'gl-mr-5') { _('Issues') }
- if show_label_merge_requests_link
- &middot;
- %li.inline
- = link_to_label(label, type: :merge_request, css_class: 'gl-text-blue-600!') { _('Merge requests') }
+ %li.inline.gl-my-1
+ = link_to_label(label, type: :merge_request) { _('Merge requests') }
= render_if_exists 'shared/label_row_epics_link', label: label
- - if force_priority
- &middot;
- %li.js-priority-badge.inline.gl-ml-3
- .label-badge.gl-bg-blue-50= _('Prioritized label')
- - if label.description.present? && show_labels_full_path?(@project, @group)
- .gl-mt-3
- = render 'shared/label_full_path', label: label
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index 2fff70cdc74..dd3a31f5a59 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -8,21 +8,18 @@
= _('Only project members can comment.')
.md-area.position-relative
- .md-header
- = gl_tabs_nav({ class: 'clearfix nav-links'}) do
- %li.md-header-tab.active
- %button.js-md-write-button{ class: 'gl-py-3!' }
- = _("Write")
- %li.md-header-tab
- %button.js-md-preview-button{ class: 'gl-py-3!' }
- = _("Preview")
-
- %li.md-header-toolbar.active.gl-py-2
- = render 'shared/blob/markdown_buttons', show_fullscreen_button: true
+ .md-header.gl-bg-gray-50.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2
+ .gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-justify-content-space-between
+ .md-header-toolbar.gl-display-flex.gl-py-2.gl-flex-wrap
+ = render 'shared/blob/markdown_buttons'
+ .switch-preview.gl-py-2.gl-display-flex.gl-align-items-center.gl-ml-auto
+ = render Pajamas::ButtonComponent.new(category: :tertiary, size: :small, button_options: { class: 'js-md-preview-button', value: 'preview' }) do
+ = _('Preview')
+ = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, size: :small, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter gl-ml-2', data: { container: 'body' } })
.md-write-holder
= yield
- .md.md-preview-holder.js-md-preview.hide{ data: { url: url } }
+ .md.md-preview-holder.gl-px-5.js-md-preview.hide{ data: { url: url } }
.referenced-commands.hide
- if referenced_users
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 8b7ef838d2b..aa3043b8fd6 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -9,8 +9,8 @@
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
- if ssh_enabled?
%li
- = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true)
+ = dropdown_item_with_description(ssh_copy_label, ssh_clone_url_to_repo(project), href: ssh_clone_url_to_repo(project), data: { clone_type: 'ssh' }, default: true)
- if http_enabled?
%li
- = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' })
+ = dropdown_item_with_description(http_copy_label, http_clone_url_to_repo(project), href: http_clone_url_to_repo(project), data: { clone_type: 'http' })
= render_if_exists 'shared/mobile_kerberos_clone'
diff --git a/app/views/shared/_model_version_conflict.html.haml b/app/views/shared/_model_version_conflict.html.haml
new file mode 100644
index 00000000000..134dcf8db7f
--- /dev/null
+++ b/app/views/shared/_model_version_conflict.html.haml
@@ -0,0 +1,6 @@
+= render Pajamas::AlertComponent.new(variant: :danger,
+ dismissible: false,
+ alert_options: { class: 'gl-mb-5' }) do |c|
+ = c.body do
+ - link_to_model = link_to(model_name, link_path, target: '_blank', rel: 'noopener noreferrer')
+ = _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs.").html_safe % { model_name: model_name, link_to_model: link_to_model }
diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml
index 14ea96f9669..bdc7156242d 100644
--- a/app/views/shared/_new_commit_form.html.haml
+++ b/app/views/shared/_new_commit_form.html.haml
@@ -8,13 +8,12 @@
= hidden_field_tag 'branch_name', ref, class: 'js-branch-name'
- else
- if can?(current_user, :push_code, @project)
- .form-group.row.branch
- = label_tag 'branch_name', _('Target Branch'), class: 'col-form-label col-sm-2'
- .col-sm-10
- = text_field_tag 'branch_name', branch_name, required: true, class: "form-control gl-form-input js-branch-name ref-name"
+ .form-group.branch
+ = label_tag 'branch_name', _('Target Branch')
+ = text_field_tag 'branch_name', branch_name, required: true, class: "form-control gl-form-input js-branch-name ref-name"
- .js-create-merge-request-container
- = render 'shared/new_merge_request_checkbox'
+ .js-create-merge-request-container
+ = render 'shared/new_merge_request_checkbox'
- elsif project.can_current_user_push_to_branch?(branch_name)
= hidden_field_tag 'branch_name', branch_name
- else
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/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 770d335a88b..bc80ebe3950 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,6 +1,6 @@
- if remote_mirror.update_in_progress?
= render Pajamas::ButtonComponent.new(icon: 'retry',
- button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' } },
+ button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body' } },
icon_classes: 'spin')
- elsif remote_mirror.enabled?
= link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now') do
diff --git a/app/views/shared/_zen.html.haml b/app/views/shared/_zen.html.haml
index 5a4efe7fe7f..05bee9e4d42 100644
--- a/app/views/shared/_zen.html.haml
+++ b/app/views/shared/_zen.html.haml
@@ -4,6 +4,7 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
- qa_selector = local_assigns.fetch(:qa_selector, '')
- autofocus = local_assigns.fetch(:autofocus, false)
+
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index db53d78dadb..a3d3c1c8231 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -1,42 +1,44 @@
- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+')
- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
-.md-header-toolbar.active
- = markdown_toolbar_button({ icon: "bold",
- data: { "md-tag" => "**", "md-shortcuts": '["mod+b"]' },
- title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "bold",
+ css_class: 'gl-mr-3',
+ data: { "md-tag" => "**", "md-shortcuts": '["mod+b"]' },
+ title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "italic",
- data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' },
- title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "italic",
+ css_class: 'gl-mr-3',
+ data: { "md-tag" => "_", "md-shortcuts": '["mod+i"]' },
+ title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "strikethrough",
- data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' },
- title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "strikethrough",
+ css_class: 'gl-mr-3',
+ data: { "md-tag" => "~~", "md-shortcuts": '["mod+shift+x"]' },
+ title: sprintf(s_("MarkdownEditor|Add strikethrough text (%{modifier_key}⇧X)") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
- = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
+= markdown_toolbar_button({ icon: "quote", css_class: 'gl-mr-3', data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
+= markdown_toolbar_button({ icon: "code", css_class: 'gl-mr-3', data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
- = markdown_toolbar_button({ icon: "link",
- data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["mod+k"]' },
- title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "link",
+ css_class: 'gl-mr-3',
+ data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["mod+k"]' },
+ title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
- = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
- = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
- = markdown_toolbar_button({ icon: "list-indent",
- data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' },
- css_class: 'gl-display-none',
- title: sprintf(s_("MarkdownEditor|Indent line (%{modifier_key}])") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "list-outdent",
- data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' },
- css_class: 'gl-display-none',
- title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) })
- = markdown_toolbar_button({ icon: "details-block",
- data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" },
- title: _("Add a collapsible section") })
- = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") })
- - if supports_file_upload
- = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button', data: { testid: 'button-attach-file', container: 'body' } })
- - if show_fullscreen_button
- = render Pajamas::ButtonComponent.new(icon: 'maximize', category: :tertiary, button_options: { 'tabindex': -1, 'aria-label': _("Go full screen"), class: 'has-tooltip js-zen-enter', data: { container: 'body' } })
+= markdown_toolbar_button({ icon: "list-bulleted", css_class: 'gl-mr-3', data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
+= markdown_toolbar_button({ icon: "list-numbered", css_class: 'gl-mr-3', data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
+= markdown_toolbar_button({ icon: "list-task", css_class: 'gl-mr-3', data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a checklist") })
+= markdown_toolbar_button({ icon: "list-indent",
+ css_class: 'gl-display-none gl-mr-3',
+ data: { "md-command" => 'indentLines', "md-shortcuts": '["mod+]"]' },
+ title: sprintf(s_("MarkdownEditor|Indent line (%{modifier_key}])") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "list-outdent",
+ css_class: 'gl-display-none gl-mr-3',
+ data: { "md-command" => 'outdentLines', "md-shortcuts": '["mod+["]' },
+ title: sprintf(s_("MarkdownEditor|Outdent line (%{modifier_key}[)") % { modifier_key: modifier_key }) })
+= markdown_toolbar_button({ icon: "details-block",
+ css_class: 'gl-mr-3',
+ data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" },
+ title: _("Add a collapsible section") })
+= markdown_toolbar_button({ icon: "table", css_class: 'gl-mr-3', data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| | |\n| | |", "md-prepend" => true }, title: _("Add a table") })
+- if supports_file_upload
+ = render Pajamas::ButtonComponent.new(icon: 'paperclip', category: :tertiary, size: :small, button_options: { 'aria-label': _("Attach a file or image"), class: 'has-tooltip js-attach-file-button gl-mr-3', data: { testid: 'button-attach-file', container: 'body' } })
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/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index b40e2630011..628a34e1278 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -21,4 +21,4 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: @application, scopes: @scopes, f: f
.gl-mt-3
- = f.submit _('Save application'), class: "gl-button btn btn-confirm"
+ = f.submit _('Save application'), 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..abfe3baf8b4 100644
--- a/app/views/shared/doorkeeper/applications/_index.html.haml
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -1,4 +1,4 @@
-- @content_class = "limit-container-width" unless fluid_layout
+- @force_desktop_expanded_sidebar = true
.row.gl-mt-3.js-search-settings-section
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index 5b0cff2c1c0..b9095e2a1a1 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -8,21 +8,15 @@
%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 Feature.enabled?('hash_oauth_secrets')
- - if @application.plaintext_secret
- = clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
- %span= _('This is the only time the secret is accessible. Copy the secret and store it securely.')
- - else
- = _('The secret is only available when you first create the application.')
- - else
- = clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
+ #js-oauth-application-secret{ data: { initial_secret: @application.plaintext_secret, renew_path: renew_path } }
+
%tr
%td
= _('Callback URL')
@@ -52,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/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 37f7fbc0de5..1ab9e288a9e 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -4,7 +4,6 @@
- opened_issues_count = issuables_count_for_state(:issues, :opened)
- is_opened_state = params[:state] == 'opened'
- is_closed_state = params[:state] == 'closed'
-- issuable_type = 'issues'
- can_edit = can?(current_user, :admin_project, @project)
.row.empty-state
@@ -43,7 +42,7 @@
= link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
- .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-w-full gl-sm-w-auto gl-sm-mr-3 gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
+ .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: 'issue', import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } }
%hr
%p.gl-text-center.gl-mb-0
%strong
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/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 8e4051fa335..94589996c3a 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -7,8 +7,8 @@
.row.empty-state.merge-requests
.col-12
- .svg-content
- = image_tag 'illustrations/merge_requests.svg', { auto_dark: true }
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-merge-requests-md.svg', { auto_dark: true }
.col-12
.text-content
- if has_filter_bar_param?
diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml
index 3381c5f0c67..b24fa0b3bdb 100644
--- a/app/views/shared/empty_states/_priority_labels.html.haml
+++ b/app/views/shared/empty_states/_priority_labels.html.haml
@@ -1,6 +1,8 @@
-.text-center
+.text-center.gl-mt-1.gl-mb-6
.svg-content{ data: { qa_selector: 'label_svg_content' } }
- = image_tag 'illustrations/priority_labels.svg'
+ = image_tag 'illustrations/empty-state/empty-labels-starred-md.svg'
- if can?(current_user, :admin_label, @project)
- %p
- = _("Star labels to start sorting by priority")
+ %div
+ = _("No prioritized labels yet!")
+ %div
+ = _("Star labels to start sorting by priority.")
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/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 8304a2f18a0..57f1c9d381e 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -1,7 +1,8 @@
- layout_path = 'shared/empty_states/wikis_layout'
- messages = wiki_empty_state_messages(@wiki)
+- hide_create = local_assigns[:hide_create]
-- if can?(current_user, :create_wiki, @wiki.container)
+- if !hide_create && can?(current_user, :create_wiki, @wiki.container)
- create_path = wiki_page_path(@wiki, params[:id], view: 'create')
- create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn gl-button btn-confirm', title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }
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/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index c5b39c7db08..550f079bf3b 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,4 +1,4 @@
-- illustration_path = 'illustrations/profile-page/groups.svg'
+- illustration_path = 'illustrations/empty-state/empty-groups-md.svg'
- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.')
- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.')
- primary_button_label = _('New group')
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/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg
deleted file mode 100644
index a75eee846c9..00000000000
--- a/app/views/shared/icons/_mr_widget_empty_state.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="256" height="146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><mask id="d" x="0" y="0" width="178.7" height="115.4" fill="#FFF"><use xlink:href="#a"/></mask><mask id="e" x="0" y="0" width="43.1" height="36.4" fill="#FFF"><use xlink:href="#b"/></mask><mask id="f" x="0" y="0" width="43.1" height="36.4" fill="#FFF"><use xlink:href="#c"/></mask><path d="M8.8 31.5H33a10 10 0 0 0 10-10V10A10 10 0 0 0 33 0H10A10 10 0 0 0 0 10v11.6c0 1.2.2 2.4.7 3.5H0v7.5c0 4 2.4 5 5.3 2.2l3.5-3.3z" id="b"/><path d="M8.8 31.5H33a10 10 0 0 0 10-10V10A10 10 0 0 0 33 0H10A10 10 0 0 0 0 10v11.6c0 1.2.2 2.4.7 3.5H0v7.5c0 4 2.4 5 5.3 2.2l3.5-3.3z" id="c"/><rect id="a" width="178.7" height="115.4" rx="10"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.9)" fill="var(--gray-10, #f9f9f9)"><rect x="19.3" width="77.1" height="14.2" rx="7.1"/><rect y="28.4" width="84.9" height="14.2" rx="7.1"/><rect x="133.7" y="42.5" width="122.1" height="14.2" rx="7.1"/><rect x="82.9" y="127" width="101.6" height="14.2" rx="7.1"/><rect x="42.4" y="99.3" width="101.6" height="14.2" rx="7.1"/><rect x="19.9" y="70.9" width="225" height="14.2" rx="7.1"/><path d="M98.4 14.2h-85 13.9a7.1 7.1 0 0 1 7 7 7 7 0 0 1-7 7.2H13.5h84.9-23.5a7.1 7.1 0 0 1-7-7.1 7 7 0 0 1 7-7.1h23.5zm162 42.5H185h23.5a7.1 7.1 0 0 1 7 7.1 7 7 0 0 1-7 7.1H185h75.3-23.5a7.1 7.1 0 0 1-7-7 7 7 0 0 1 7-7.2h23.5zM103.5 85.1H28.3h23.4a7.1 7.1 0 0 1 7.1 7 7 7 0 0 1-7 7.2H28.2h75.2H80a7.1 7.1 0 0 1-7.1-7.1 7 7 0 0 1 7-7.1h23.5zm48.2 28.4H76.5h13.8a7.1 7.1 0 0 1 7 7 7 7 0 0 1-7 7.1H76.5h75.2-33a7.1 7.1 0 0 1-7.2-7 7 7 0 0 1 7.1-7.1h33.1z"/></g><g transform="translate(38.6 12.2)"><use stroke="var(--gray-200, #EEE)" mask="url(#d)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#a"/><path fill="var(--gray-200, #EEE)" d="M2.6 18.7h174.2v2.6H2.6z"/><g fill="var(--gray-100, #EEE)"><g transform="translate(21.9 38.7)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(21.9 60)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect fill="#FC6D26" x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(21.9 81.2)"><g fill="var(--dark-icon-color-purple-2, #B5A7DD)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect fill="#FC6D26" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect fill="#FC6D26" opacity=".5" x="21.9" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/></g><g transform="translate(101 38)"><g fill="var(--dark-icon-color-purple-3, #6B4FBB)"><rect opacity=".5" x="25.1" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="34.1" y="7.1" width="9.6" height="2.6" rx="1.3"/><rect opacity=".5" x="30.9" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="14.2" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" width="6.4" height="2.6" rx="1.3"/><rect opacity=".5" x="25.1" y="35.5" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="28.4" width="9.6" height="2.6" rx="1.3"/><rect x="30.9" y="21.3" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="42.5" width="9.6" height="2.6" rx="1.3"/><rect opacity=".5" x="34.1" y="49.6" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="49.6" width="12.9" height="2.6" rx="1.3"/></g><g fill="var(--dark-icon-color-orange-1, #FDE5D8)"><rect y=".6" width="3.9" height="1.3" rx=".6"/><rect y="7.7" width="3.9" height="1.3" rx=".6"/><rect y="14.8" width="3.9" height="1.3" rx=".6"/><rect y="21.9" width="3.9" height="1.3" rx=".6"/><rect y="29" width="3.9" height="1.3" rx=".6"/><rect y="36.1" width="3.9" height="1.3" rx=".6"/><rect y="43.2" width="3.9" height="1.3" rx=".6"/><rect y="50.3" width="3.9" height="1.3" rx=".6"/><rect y="57.4" width="3.9" height="1.3" rx=".6"/></g><rect x="9.6" width="9.6" height="2.6" rx="1.3"/><rect x="46.3" width="9.6" height="2.6" rx="1.3"/><rect x="18.6" y="7.1" width="12.9" height="2.6" rx="1.3"/><rect x="9.6" y="7.1" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="21.3" width="9.6" height="2.6" rx="1.3"/><rect x="37.3" y="14.2" width="9.6" height="2.6" rx="1.3"/><rect x="9.6" y="35.5" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" y="21.3" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="30.9" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="39.9" y="28.4" width="6.4" height="2.6" rx="1.3"/><rect x="49.5" y="14.2" width="6.4" height="2.6" rx="1.3"/><rect x="25.1" y="56.7" width="9.6" height="2.6" rx="1.3"/><rect x="9.6" y="56.7" width="12.9" height="2.6" rx="1.3"/><rect x="21.9" y="42.5" width="6.4" height="2.6" rx="1.3"/><rect x="46.3" y="49.6" width="6.4" height="2.6" rx="1.3"/><rect x="9.6" y="49.6" width="6.4" height="2.6" rx="1.3"/></g></g></g><g transform="translate(196)"><use stroke="var(--dark-icon-color-orange-1, #FDE5D8)" mask="url(#e)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#b"/><g fill="var(--dark-icon-color-orange-2, #FDB692)"><rect x="9" y="9" width="18.6" height="1.9" rx="1"/><rect x="9" y="14.8" width="25.1" height="1.9" rx="1"/><rect x="9" y="20.6" width="18.6" height="1.9" rx="1"/></g></g><g transform="translate(189 41.3)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#fde5d8" cx="10.3" cy="9.7" rx="9.6" ry="9.7"/><path d="M0 9a8.4 8.4 0 0 0 8-4.3m1-4V0" stroke="#FC6D26" stroke-width="2"/><path d="M5 2a10.3 10.3 0 0 0 8.5 4.4c2.1 0 4-.6 5.7-1.7" stroke="#FC6D26" stroke-width="2"/><circle fill="#FC6D26" cx="6.8" cy="11.3" r="1"/><circle fill="#FC6D26" cx="13.8" cy="11.3" r="1"/></g><g transform="translate(47 96)"><ellipse stroke="var(--dark-icon-color-purple-3, #6B4FBB)" stroke-width="3" fill="#F4F1FA" cx="9.6" cy="10.3" rx="9.6" ry="9.7"/><path d="m12.9 4.5-1.7-2-1.6 2-1.6-2-1.6 2-1.6-2-1.6 2H1.5A9.6 9.6 0 0 1 9.6 0c3.5 0 6.5 1.8 8.2 4.5h-1.7l-1.6-2-1.6 2z" fill="var(--dark-icon-color-purple-3, #6B4FBB)"/><circle fill="var(--dark-icon-color-purple-3, #6B4FBB)" cx="6.1" cy="11.3" r="1"/><circle fill="var(--dark-icon-color-purple-3, #6B4FBB)" cx="13.2" cy="11.3" r="1"/></g><g transform="matrix(-1 0 0 1 56.6 54.8)" fill="var(--dark-icon-color-purple-2, #b5a8dd)"><use stroke="var(--dark-icon-color-purple-1, #E2DCF2)" mask="url(#f)" stroke-width="8" fill="var(--white, #fff)" xlink:href="#c"/><rect x="15.4" y="9" width="18.6" height="1.9" rx="1"/><rect x="21.9" y="14.8" width="12.2" height="1.9" rx="1"/><rect x="21.9" y="20.6" width="12.2" height="1.9" rx="1"/></g></g></svg>
diff --git a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
new file mode 100644
index 00000000000..de4439a8fde
--- /dev/null
+++ b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
@@ -0,0 +1,20 @@
+- if Gitlab.com?
+ = render Pajamas::AlertComponent.new(title: _('Slack notifications integration is deprecated'),
+ variant: :warning,
+ dismissible: false,
+ alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c|
+ = c.body do
+ - help_page_link = help_page_url('user/project/integrations/gitlab_slack_application')
+ - learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link }
+
+ = html_escape(s_('The Slack notifications integration is deprecated and will be removed in a future release. To continue to receive notifications from Slack, use the GitLab for Slack app instead. %{learn_more_link_start}Learn more%{link_end}.')) % { learn_more_link_start: learn_more_link, link_end: '</a>'.html_safe }
+- else
+ = render Pajamas::AlertComponent.new(title: _('Slack notifications will be deprecated'),
+ variant: :warning,
+ dismissible: false,
+ alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c|
+ = c.body do
+ - help_page_link = help_page_url('user/project/integrations/gitlab_slack_application')
+ - learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link }
+
+ = html_escape(s_('Slack notifications will be brought into the GitLab for Slack app so you can manage both integrations from one place. %{learn_more_link_start}Learn more%{link_end}.')) % { learn_more_link_start: learn_more_link, 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/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
index dda84e0fb9e..beeb328aedf 100644
--- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
+++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
@@ -3,16 +3,16 @@
.col-lg-3
%p
= s_('PrometheusService|Custom metrics require Prometheus installed on a cluster with environment scope "*" OR a manually configured Prometheus to be available.')
- = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer"
+ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9
- .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } }
- .card-header
+ = render Pajamas::CardComponent.new(header_options: { class: 'gl-display-flex gl-align-items-center' }, body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 custom-monitored-metrics js-panel-custom-monitored-metrics', data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } }) do |c|
+ - c.header do
%strong
= s_('PrometheusService|Custom metrics')
- = gl_badge_tag 0, nil, class: 'js-custom-monitored-count'
+ = gl_badge_tag 0, nil, class: 'gl-ml-2 js-custom-monitored-count'
= link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden'
- .card-body
+ - c.body do
.flash-container.hidden
.flash-warning
.flash-text
diff --git a/app/views/shared/integrations/prometheus/_metrics.html.haml b/app/views/shared/integrations/prometheus/_metrics.html.haml
index c74dbfd8b15..7cd4eeee5f8 100644
--- a/app/views/shared/integrations/prometheus/_metrics.html.haml
+++ b/app/views/shared/integrations/prometheus/_metrics.html.haml
@@ -8,12 +8,12 @@
= link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9
- .card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') } }
- .card-header
+ = render Pajamas::CardComponent.new(body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 js-panel-monitored-metrics', data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') }}) do |c|
+ - c.header do
%strong
= s_('PrometheusService|Common metrics')
- = gl_badge_tag 0, nil, class: 'js-monitored-count'
- .card-body
+ = gl_badge_tag 0, nil, class: 'js-monitored-count'
+ - c.body do
.loading-metrics.js-loading-metrics
%p.m-3
= gl_loading_icon(inline: true, css_class: 'metrics-load-spinner')
@@ -23,16 +23,16 @@
= s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics')
%ul.list-unstyled.metrics-list.hidden.js-metrics-list
- .card.hidden.js-panel-missing-env-vars
- .card-header
+ = render Pajamas::CardComponent.new(body_options: { class: 'hidden gl-p-0' }, card_options: { class: 'hidden js-panel-missing-env-vars' }) do |c|
+ - c.header do
= sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right')
= sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden')
= s_('PrometheusService|Missing environment variable')
= gl_badge_tag 0, nil, class: 'js-env-var-count'
- .card-body.hidden
+ - c.body do
.flash-container
.flash-notice
.flash-text
- = s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries." % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>" }).html_safe
- = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/dashboards/variables.md', anchor: 'query-variables')
+ = html_escape(s_("PrometheusService|To set up automatic monitoring, add the environment variable %{variable} to exporter's queries.")) % { variable: "<code>$CI_ENVIRONMENT_SLUG</code>".html_safe }
+ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md')
%ul.list-unstyled.metrics-list.js-missing-var-metrics-list
diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml
index 94b7fe14721..1a9bbac6f05 100644
--- a/app/views/shared/issuable/_feed_buttons.html.haml
+++ b/app/views/shared/issuable/_feed_buttons.html.haml
@@ -1,8 +1,10 @@
- show_calendar_button = local_assigns.fetch(:show_calendar_button, true)
-= render Pajamas::ButtonComponent.new(href: safe_params.merge(rss_url_options), icon: 'rss', button_options: { class: 'has-tooltip', 'aria-label': _('Subscribe to RSS feed'), data: { container: 'body', testid: 'rss-feed-link' } }) do
- = _('Subscribe to RSS feed')
-
- if show_calendar_button
- = render Pajamas::ButtonComponent.new(href: safe_params.merge(calendar_url_options), icon: 'calendar', button_options: { class: 'has-tooltip', 'aria-label': _('Subscribe to calendar'), data: { container: 'body' } }) do
- = _('Subscribe to calendar')
+ = link_to safe_params.merge(calendar_url_options), class: 'dropdown-item' do
+ .gl-dropdown-item-text-wrapper
+ = _("Subscribe to calendar")
+
+= link_to safe_params.merge(rss_url_options), class: 'dropdown-item' do
+ .gl-dropdown-item-text-wrapper
+ = _("Subscribe to RSS feed")
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 07cdbbece8c..b6bd691213c 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -6,14 +6,8 @@
= form_errors(issuable)
- if @conflict
- = render Pajamas::AlertComponent.new(variant: :danger,
- dismissible: false,
- alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
- Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
- Please check out
- = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project, issuable]), target: "_blank", rel: 'noopener noreferrer'
- and make sure your changes will not unintentionally remove theirs
+ - model_name = _(issuable.class.model_name.human.downcase)
+ = render 'shared/model_version_conflict', model_name: model_name, link_path: polymorphic_path([@project, issuable])
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
@@ -62,9 +56,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 js-reset-autosave', 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 js-reset-autosave', 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/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index af63839d7c1..3c4ee01d04f 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -26,7 +26,7 @@
- apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
%span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
= multi_label_name(selected, label_name)
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon")
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
- if show_create && project && can?(current_user, :admin_label, project)
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index eb3acd8e055..96167db80b4 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -10,9 +10,9 @@
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
= render_suggested_colors
- .dropdown-label-color-input
- .dropdown-label-color-preview.js-dropdown-label-color-preview
- %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') }
+ .dropdown-label-color-input.gl-display-flex
+ %input.dropdown-label-color-preview.js-dropdown-label-color-preview.gl-w-7.gl-h-7.gl-border-1.gl-border-solid.gl-border-gray-500.gl-rounded-top-right-none.gl-rounded-bottom-right-none{ class: "gl-border-r-0!", type: "color", placeholder: _('Select color') }
+ %input#new_label_color.default-dropdown-input.gl-rounded-top-left-none.gl-rounded-bottom-left-none{ type: "text", placeholder: _('Assign custom color like #FF0000') }
- if show_add_list
.dropdown-label-input{ class: add_list_class }
%label
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 72940b64801..76678c48a86 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -162,6 +162,15 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.gl-button.btn.btn-link{ type: 'button' }
= _('No')
+ - if ::Feature.enabled?(:mr_approved_filter, type: :ops)
+ #js-dropdown-approved.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.gl-button.btn.btn-link{ type: 'button' }
+ = _('No')
#js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
@@ -186,7 +195,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/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index f54354674e2..09162e6a349 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -9,14 +9,15 @@
- reviewers = local_assigns.fetch(:reviewers, nil)
- in_group_context_with_iterations = @project.group.present? && issuable_sidebar[:supports_iterations]
- is_merge_request = issuable_type === 'merge_request'
-- moved_sidebar_enabled = moved_mr_sidebar_enabled? && is_merge_request
+- moved_sidebar_enabled = moved_mr_sidebar_enabled?
+- is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled
-%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class} #{'right-sidebar-merge-requests' if moved_sidebar_enabled}", 'aria-live' => 'polite', 'aria-label': issuable_type }
- .issuable-sidebar{ class: "#{'is-merge-request' if moved_sidebar_enabled}" }
- .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if moved_sidebar_enabled}" }
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type }
+ .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" }
+ .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" }
%button.btn.gl-button.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ type: "reset", class: "gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- - if signed_in && !moved_sidebar_enabled
+ - if signed_in && !is_merge_request_with_flag
.js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
= form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
@@ -81,19 +82,19 @@
.js-sidebar-participants-widget-root
- .block.with-sub-blocks
- - if !moved_sidebar_enabled
+ - if !moved_sidebar_enabled
+ .block.with-sub-blocks
.js-sidebar-reference-widget-root
- - if issuable_type == 'merge_request' && !moved_sidebar_enabled
- .sub-block.js-sidebar-source-branch
- .sidebar-collapsed-icon.js-dont-change-state
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
- .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
- %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
- = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) }
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
-
- - if show_forwarding_email
+ - if is_merge_request && !moved_sidebar_enabled
+ .sub-block.js-sidebar-source-branch
+ .sidebar-collapsed-icon.js-dont-change-state
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
+ .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
+ %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
+ = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) }
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy')
+
+ - if show_forwarding_email && !moved_sidebar_enabled
.block
.js-sidebar-copy-email-root
- if issuable_sidebar.dig(:current_user, :can_move)
diff --git a/app/views/shared/issuable/_sidebar_user_dropdown.html.haml b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
index c058e7ebe3e..9bfdacc8cfd 100644
--- a/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
+++ b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
@@ -14,8 +14,6 @@
%li
.js-invite-members-trigger{ data: { trigger_element: 'anchor',
display_text: _('Invite Members'),
- event: 'click_invite_members',
- trigger_source: local_assigns.fetch(:trigger_source),
- label: data['track-label'] } }
+ trigger_source: local_assigns.fetch(:trigger_source) } }
- else
= dropdown_tag(data['dropdown-title'], options: options)
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/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 9603178f7de..b27fd8ab7d2 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -37,12 +37,15 @@
.issuable-form-select-holder
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]"
- .form-group.row
- = form.label :label_ids, _('Labels'), class: "col-12"
- = form.hidden_field :label_ids, multiple: true, value: ''
- .col-12
- .issuable-form-select-holder
- = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
+ - if Feature.enabled?(:visible_label_selection_on_metadata, project)
+ .js-issuable-form-label-selector{ data: issuable_label_selector_data(project, issuable) }
+ - else
+ .form-group.row
+ = form.label :label_ids, _('Labels'), class: "col-12"
+ = form.hidden_field :label_ids, multiple: true, value: ''
+ .col-12
+ .issuable-form-select-holder
+ = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
= render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
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 a94ef70b2d5..0bcdcb9e963 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -5,30 +5,10 @@
= _('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 gl-top-3")
- .dropdown-menu.dropdown-menu-selectable.dropdown-select
- .dropdown-title.gl-display-flex
- %span.gl-ml-auto
- = _("Select type")
- %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
- = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
- .dropdown-content{ data: { testid: 'issue-type-select-dropdown' } }
- %ul
- - if create_issue_type_allowed?(@project, :issue)
- %li.js-filter-issuable-type
- = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
- #{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_('Issue')}
- - if create_issue_type_allowed?(@project, :incident)
- %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
- = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
- #{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_('Incident')}
+ .issuable-form-select-holder.form-group.gl-mb-0.gl-display-block
+ #js-type-select{ data: issuable_type_selector_data(issuable) }
- - if issuable.incident?
+ - if issuable.incident_type_issue?
%p.form-text.text-muted
- incident_docs_url = help_page_path('operations/incident_management/incidents.md')
- incident_docs_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: incident_docs_url)
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index e1a9b30ef67..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-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..b6c0b73a83d 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)
@@ -19,4 +19,4 @@
%a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-left')
- .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
+ .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user, @issuable_sidebar) }
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 9ef4b9e084d..899b2ed832e 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -9,19 +9,17 @@
.form-group.row
.col-12
- = f.label :description
- = f.text_field :description, class: "gl-form-input form-control js-quick-submit", data: { qa_selector: 'label_description_field' }
+ = f.label :description, _("Description (optional)")
+ = f.text_area :description, class: "gl-form-input form-control js-quick-submit", rows: 4, data: { qa_selector: 'label_description_field' }
.form-group.row
.col-12
= f.label :color, _("Background color")
.input-group
.input-group-prepend
- .input-group-text.label-color-preview &nbsp;
+ %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 fc25c7e8f89..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
+.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' }
@@ -9,5 +9,5 @@
- if milestone.try(:description).present?
%div{ data: { qa_selector: "milestone_description_content" } }
- .description.md.gl-px-0.gl-pt-4.gl-border-1.gl-border-t-solid.gl-border-gray-100
+ .description.md.gl-px-0.gl-pt-4
= markdown_field(milestone, :description)
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index 50e3e8e195c..3e75775bf73 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -1,11 +1,14 @@
-.form-group.row
- .col-form-label.col-sm-2
+.gl-form-group
+ %div
= f.label :start_date, _('Start Date')
- .col-sm-4
- = f.gitlab_ui_datepicker :start_date, data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
- %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date')
- .col-form-label.col-sm-2
+ %div
+ .issuable-form-select-holder
+ = f.gitlab_ui_datepicker :start_date, data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
+ %a.gl-white-space-nowrap.gl-pl-4.js-clear-start-date{ href: "#" }= _('Clear start date')
+.gl-form-group
+ %div
= f.label :due_date, _('Due Date')
- .col-sm-4
- = f.gitlab_ui_datepicker :due_date, data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
- %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date')
+ %div
+ .issuable-form-select-holder
+ = f.gitlab_ui_datepicker :due_date, data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
+ %a.gl-white-space-nowrap.gl-pl-4.js-clear-due-date{ href: "#" }= _('Clear due date')
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 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/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index cc1965945ac..5477b9395ea 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -1,7 +1,7 @@
- affix_offset = local_assigns.fetch(:affix_offset, "50")
- project = local_assigns[:project]
-%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': _('Milestone') }
+%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix", "always-show-toggle" => true }, class: sidebar_gutter_collapsed_class(false), 'aria-live' => 'polite', 'aria-label': _('Milestone') }
.issuable-sidebar.milestone-sidebar
.block.milestone-progress.issuable-sidebar-header
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => s_('MilestoneSidebar|Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
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/nav/_explore_scope_header.html.haml b/app/views/shared/nav/_explore_scope_header.html.haml
new file mode 100644
index 00000000000..da22d6dbcf2
--- /dev/null
+++ b/app/views/shared/nav/_explore_scope_header.html.haml
@@ -0,0 +1,6 @@
+%li.context-header
+ = link_to explore_root_url, title: _('Explore'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
+ %span.avatar-container.icon-avatar.rect-avatar.s32
+ = sprite_icon('compass', size: 18)
+ %span.sidebar-context-title
+ = _('Explore')
diff --git a/app/views/shared/nav/_user_settings_scope_header.html.haml b/app/views/shared/nav/_user_settings_scope_header.html.haml
new file mode 100644
index 00000000000..c1601822736
--- /dev/null
+++ b/app/views/shared/nav/_user_settings_scope_header.html.haml
@@ -0,0 +1,4 @@
+%li.context-header
+ = link_to profile_path, title: _('User Settings'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
+ = render Pajamas::AvatarComponent.new(current_user, size: 32, alt: current_user.name, class: 'gl-mr-3 js-sidebar-user-avatar', avatar_options: { data: { testid: 'sidebar-user-avatar' } })
+ %span.sidebar-context-title= _('User Settings')
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/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index fb000b9aab1..d7d6e477ab1 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,7 +1,7 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
-.comment-toolbar.clearfix
- .toolbar-text
+.comment-toolbar.gl-mx-2.gl-mb-2.gl-px-4.gl-bg-gray-10.gl-rounded-bottom-left-base.gl-rounded-bottom-right-base.clearfix
+ .toolbar-text.gl-font-sm
- markdownLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/markdown') }
- quickActionsLinkStart = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/quick_actions') }
- if supports_quick_actions
@@ -9,7 +9,7 @@
- else
= html_escape(s_('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe }
- if supports_file_upload
- %span.uploading-container.gl-line-height-32
+ %span.uploading-container.gl-line-height-32.gl-font-sm
%span.uploading-progress-container.hide
= sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.attaching-file-message
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..79a33316b1a 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,10 +12,9 @@
- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
-- last_pipeline = project.last_pipeline if show_pipeline_status_icon
+- last_pipeline = last_pipeline_from_status_cache(project) 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/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml
index f6396168cb3..686cd1a081b 100644
--- a/app/views/shared/runners/_runner_details.html.haml
+++ b/app/views/shared/runners/_runner_details.html.haml
@@ -1,8 +1,5 @@
-- breadcrumb_title runner.short_sha
-- page_title "##{runner.id} (#{runner.short_sha})"
-
%h1.page-title.gl-font-size-h-display
- = s_('Runners|Runner #%{runner_id}' % { runner_id: runner.id })
+ = s_('Runners|Runner #%{runner_id}') % { runner_id: runner.id }
= render 'shared/runners/runner_type_badge', runner: runner
.table-holder
diff --git a/app/views/shared/topics/_topic.html.haml b/app/views/shared/topics/_topic.html.haml
index 83d5ecdb833..9b9630733fd 100644
--- a/app/views/shared/topics/_topic.html.haml
+++ b/app/views/shared/topics/_topic.html.haml
@@ -5,9 +5,8 @@
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' },
body_options: { class: 'gl-display-flex gl-align-items-center' }) do |c|
= c.body do
- .avatar-container.rect-avatar.s40.gl-flex-shrink-0
- = link_to detail_page_link do
- = topic_icon(topic, class: "avatar s40")
+ = link_to detail_page_link do
+ = render Pajamas::AvatarComponent.new(topic, size: 48, alt: '', class: 'gl-mr-3')
= link_to detail_page_link do
- if topic.title_or_name.length > max_topic_title_length
%h5.gl-str-truncated.has-tooltip{ title: topic.title_or_name }
diff --git a/app/views/shared/users/index.html.haml b/app/views/shared/users/index.html.haml
index dd6b14d6be2..ef5552943df 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-friends-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-friends-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 5c2233a4db2..6a066e0a838 100644
--- a/app/views/shared/wikis/_wiki_directory.html.haml
+++ b/app/views/shared/wikis/_wiki_directory.html.haml
@@ -1,6 +1,12 @@
+- wiki_path = wiki_page_path(@wiki, wiki_directory)
+
%li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } }
- = link_to wiki_page_path(@wiki, wiki_directory), data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do
- = wiki_directory.title
+ .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')
+ = 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|
= render partial: entry.to_partial_path, object: entry, locals: { context: context }
diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml
index c39739ac422..ee6c7f307a7 100644
--- a/app/views/shared/wikis/diff.html.haml
+++ b/app/views/shared/wikis/diff.html.haml
@@ -28,5 +28,5 @@
%pre.commit-description<
= preserve(markdown_field(commit, :description))
-= render 'projects/diffs/diffs', diffs: @diffs
+= render 'projects/diffs/diffs', diffs: @diffs, diff_page_context: "is-wiki"
= render 'shared/wikis/sidebar'
diff --git a/app/views/shared/wikis/empty.html.haml b/app/views/shared/wikis/empty.html.haml
index c52ead74b4c..d30a37aaa3e 100644
--- a/app/views/shared/wikis/empty.html.haml
+++ b/app/views/shared/wikis/empty.html.haml
@@ -2,4 +2,12 @@
- @right_sidebar = false
- add_page_specific_style 'page_bundles/wiki'
-= render 'shared/empty_states/wikis'
+- if @error.present?
+ = render Pajamas::AlertComponent.new(alert_options: { id: 'error_explanation', class: 'gl-mb-3'},
+ dismissible: false,
+ variant: :danger) do |c|
+ = c.body do
+ %ul.gl-pl-4
+ = @error
+
+= render 'shared/empty_states/wikis', hide_create: @error.present?
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 eb9465a409f..fe05a3de13a 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -4,8 +4,9 @@
- add_page_startup_graphql_call('snippet/project_permissions', { fullPath: @snippet.project_id })
- else
- add_page_startup_graphql_call('snippet/user_permissions')
-- @hide_top_links = true
-- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path
+- if @snippet.author != current_user
+ -# 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")
- content_for :prefetch_asset_tags do
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/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index b62440fcbde..c916b6c3d45 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -1,9 +1,9 @@
-.gl-text-gray-900.gl-mt-4
- = render 'middle_dot_divider' do
+.gl-text-gray-900
+ = render 'middle_dot_divider', stacking: true do
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
- = render 'middle_dot_divider' do
+ = render 'middle_dot_divider', stacking: true do
= s_('UserProfile|User ID: %{id}') % { id: @user.id }
= clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
- = render 'middle_dot_divider' do
+ = render 'middle_dot_divider', stacking: true do
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index b9290972656..1ebf02ffd39 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,141 +1,139 @@
- @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'
- add_page_specific_style 'page_bundles/profile'
- link_classes = "flex-grow-1 mx-1 "
+- if show_super_sidebar?
+ - @left_sidebar = true
+ - @force_desktop_expanded_sidebar = true
+ - nav "user_profile"
= content_for :meta_tags do
= 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')
-
- .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
- .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.blocked? || !@user.confirmed?
- .user-info
- %h1.cover-title
- = user_display_name(@user)
- = render "users/profile_basic_info"
- - else
- .user-info
- %h1.cover-title{ 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)")
-
- - 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
- = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
- = markdown_field(@user.status, :message)
- = render "users/profile_basic_info"
- .gl-text-gray-900.mb-1.mb-sm-2
- - unless @user.location.blank?
- = 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
- - user_local_time = local_time(@user.timezone)
- - unless user_local_time.nil?
- = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
- = sprite_icon('clock', css_class: 'fgray')
- %span
- = user_local_time
- - unless work_information(@user).blank?
- = 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
- - unless @user.skype.blank?
- = 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')
- - unless @user.linkedin.blank?
- = 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')
- - unless @user.twitter.blank?
- = 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')
- - unless @user.discord.blank?
- = 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')
- - unless @user.website_url.blank?
- = 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'
- .gl-text-gray-900
- = sprite_icon('users', css_class: 'gl-vertical-align-middle gl-text-gray-500')
- = render 'middle_dot_divider' do
- = link_to user_followers_path do
- - count = @user.followers.count
- = n_('1 follower', '%{count} followers', count) % { count: count }
- = render 'middle_dot_divider' do
- = link_to user_following_path, data: { qa_selector: 'following_link' } do
- = @user.followees.count
- = _('following')
- - if @user.bio.present?
- .gl-text-gray-900
- .profile-user-bio
- = @user.bio
-
- - unless profile_tabs.empty?
- - if Feature.enabled?(:profile_tabs_vue, current_user)
- #js-profile-tabs
- - else
- .scrolling-tabs-container
+ .cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1
+ %div{ class: container_class }
+ = 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.following_users_allowed?(@user)
+ - 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-mb-4!' if show_super_sidebar?)] }
+ .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= 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?
+ .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.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
+ = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
+
+ -# Ensure this stays indented one level less than the social links
+ -# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118314
+ - 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
+ %ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs.gl-border-b-0
- if profile_tab?(:overview)
%li.js-overview-tab
= link_to user_path, data: { target: 'div#js-overview', action: 'overview', toggle: 'tab' } do
@@ -169,11 +167,14 @@
%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) } do
+ = 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 c660243d336..50b301b2fc3 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -5,7 +5,7 @@
---
- :name: authorized_project_update:authorized_project_update_project_recalculate
:worker_name: AuthorizedProjectUpdate::ProjectRecalculateWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -14,7 +14,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_project_recalculate_per_user
:worker_name: AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -23,7 +23,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_from_replica
:worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -32,7 +32,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range
:worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -41,7 +41,7 @@
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -131,7 +131,16 @@
: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
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cluster_agent:clusters_agents_notify_git_push
+ :worker_name: Clusters::Agents::NotifyGitPushWorker
+ :feature_category: :deployment_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -156,6 +165,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: container_repository:container_registry_record_data_repair_detail
+ :worker_name: ContainerRegistry::RecordDataRepairDetailWorker
+ :feature_category: :container_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: container_repository:delete_container_repository
:worker_name: DeleteContainerRepositoryWorker
:feature_category: :container_registry
@@ -374,7 +392,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 +401,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 +536,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 +561,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
@@ -561,6 +588,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:packages_cleanup_delete_orphaned_dependencies
+ :worker_name: Packages::Cleanup::DeleteOrphanedDependenciesWorker
+ :feature_category: :package_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:packages_cleanup_package_registry
:worker_name: Packages::CleanupPackageRegistryWorker
:feature_category: :package_registry
@@ -579,6 +615,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
@@ -617,7 +662,7 @@
:tags: []
- :name: cronjob:personal_access_tokens_expired_notification
:worker_name: PersonalAccessTokens::ExpiredNotificationWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -626,7 +671,7 @@
:tags: []
- :name: cronjob:personal_access_tokens_expiring
:worker_name: PersonalAccessTokens::ExpiringWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -680,7 +725,7 @@
:tags: []
- :name: cronjob:remove_expired_group_links
:worker_name: RemoveExpiredGroupLinksWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -689,7 +734,7 @@
:tags: []
- :name: cronjob:remove_expired_members
:worker_name: RemoveExpiredMembersWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :cpu
@@ -698,7 +743,7 @@
:tags: []
- :name: cronjob:remove_unaccepted_member_invites
:worker_name: RemoveUnacceptedMemberInvitesWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -833,7 +878,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
@@ -894,15 +939,6 @@
:weight: 3
:idempotent: true
:tags: []
-- :name: deployment:deployments_drop_older_deployments
- :worker_name: Deployments::DropOlderDeploymentsWorker
- :feature_category: :continuous_delivery
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 3
- :idempotent: false
- :tags: []
- :name: deployment:deployments_hooks
:worker_name: Deployments::HooksWorker
:feature_category: :continuous_delivery
@@ -932,7 +968,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 +977,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 +986,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 +995,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 +1004,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 +1013,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 +1022,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 +1031,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 +1040,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 +1049,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 +1058,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 +1067,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 +1076,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 +1085,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 +1094,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 +1103,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
@@ -1137,6 +1173,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_import_collaborator
+ :worker_name: Gitlab::GithubImport::ImportCollaboratorWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_import_diff_note
:worker_name: Gitlab::GithubImport::ImportDiffNoteWorker
:feature_category: :importers
@@ -1227,6 +1272,24 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_pull_requests_import_merged_by
+ :worker_name: Gitlab::GithubImport::PullRequests::ImportMergedByWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: false
+ :tags: []
+- :name: github_importer:github_import_pull_requests_import_review
+ :worker_name: Gitlab::GithubImport::PullRequests::ImportReviewWorker
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_pull_requests_import_review_request
:worker_name: Gitlab::GithubImport::PullRequests::ImportReviewRequestWorker
:feature_category: :importers
@@ -1272,6 +1335,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: github_importer:github_import_stage_import_collaborators
+ :worker_name: Gitlab::GithubImport::Stage::ImportCollaboratorsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: false
+ :tags: []
- :name: github_importer:github_import_stage_import_issue_events
:worker_name: Gitlab::GithubImport::Stage::ImportIssueEventsWorker
:feature_category: :importers
@@ -1722,6 +1794,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
@@ -1884,15 +1965,6 @@
:weight: 4
:idempotent: true
:tags: []
-- :name: pipeline_default:ci_create_cross_project_pipeline
- :worker_name: Ci::CreateCrossProjectPipelineWorker
- :feature_category: :continuous_integration
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :cpu
- :weight: 3
- :idempotent: false
- :tags: []
- :name: pipeline_default:ci_create_downstream_pipeline
:worker_name: Ci::CreateDownstreamPipelineWorker
:feature_category: :continuous_integration
@@ -2165,7 +2237,7 @@
:tags: []
- :name: unassign_issuables:members_destroyer_unassign_issuables
:worker_name: MembersDestroyer::UnassignIssuablesWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :user_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2219,7 +2291,7 @@
:tags: []
- :name: authorized_projects
:worker_name: AuthorizedProjectsWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :high
:resource_boundary: :unknown
@@ -2271,21 +2343,39 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: bulk_imports_finish_batched_relation_export
+ :worker_name: BulkImports::FinishBatchedRelationExportWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bulk_imports_pipeline
:worker_name: BulkImports::PipelineWorker
:feature_category: :importers
:has_external_dependencies: true
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: false
:tags: []
+- :name: bulk_imports_relation_batch_export
+ :worker_name: BulkImports::RelationBatchExportWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: bulk_imports_relation_export
:worker_name: BulkImports::RelationExportWorker
:feature_category: :importers
:has_external_dependencies: false
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: true
:tags: []
@@ -2417,7 +2507,7 @@
:tags: []
- :name: delete_user
:worker_name: DeleteUserWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :user_management
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2611,7 +2701,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: group_export
:worker_name: GroupExportWorker
@@ -2642,7 +2732,7 @@
:tags: []
- :name: groups_update_two_factor_requirement_for_members
:worker_name: Groups::UpdateTwoFactorRequirementForMembersWorker
- :feature_category: :authentication_and_authorization
+ :feature_category: :system_access
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2694,6 +2784,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: integrations_slack_event
+ :worker_name: Integrations::SlackEventWorker
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: invalid_gpg_signature_update
:worker_name: InvalidGpgSignatureUpdateWorker
:feature_category: :source_code_management
@@ -2865,6 +2964,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_set_reviewer_reviewed
+ :worker_name: MergeRequests::SetReviewerReviewedWorker
+ :feature_category: :code_review_workflow
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_update_head_pipeline
:worker_name: MergeRequests::UpdateHeadPipelineWorker
:feature_category: :code_review_workflow
@@ -2901,9 +3009,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
@@ -3027,15 +3144,6 @@
:weight: 1
:idempotent: true
:tags: []
-- :name: phabricator_import_import_tasks
- :worker_name: Gitlab::PhabricatorImport::ImportTasksWorker
- :feature_category: :importers
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: post_receive
:worker_name: PostReceive
:feature_category: :source_code_management
@@ -3070,7 +3178,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: false
+ :idempotent: true
:tags: []
- :name: project_export
:worker_name: ProjectExportWorker
@@ -3108,6 +3216,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_forks_sync
+ :worker_name: Projects::Forks::SyncWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies: false
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_git_garbage_collect
:worker_name: Projects::GitGarbageCollectWorker
:feature_category: :gitaly
@@ -3117,6 +3234,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: projects_import_export_create_relation_exports
+ :worker_name: Projects::ImportExport::CreateRelationExportsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_import_export_parallel_project_export
:worker_name: Projects::ImportExport::ParallelProjectExportWorker
:feature_category: :importers
@@ -3135,6 +3261,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: projects_import_export_wait_relation_exports
+ :worker_name: Projects::ImportExport::WaitRelationExportsWorker
+ :feature_category: :importers
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_inactive_projects_deletion_notification
:worker_name: Projects::InactiveProjectsDeletionNotificationWorker
:feature_category: :compliance_management
@@ -3155,7 +3290,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
@@ -3302,7 +3437,7 @@
:feature_category: :importers
:has_external_dependencies: true
:urgency: :low
- :resource_boundary: :unknown
+ :resource_boundary: :memory
:weight: 1
:idempotent: false
:tags: []
@@ -3315,24 +3450,6 @@
:weight: 1
:idempotent: true
:tags: []
-- :name: self_monitoring_project_create
- :worker_name: SelfMonitoringProjectCreateWorker
- :feature_category: :metrics
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 2
- :idempotent: false
- :tags: []
-- :name: self_monitoring_project_delete
- :worker_name: SelfMonitoringProjectDeleteWorker
- :feature_category: :metrics
- :has_external_dependencies: false
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 2
- :idempotent: false
- :tags: []
- :name: service_desk_email_receiver
:worker_name: ServiceDeskEmailReceiverWorker
:feature_category: :service_desk
@@ -3360,6 +3477,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: ssh_keys_update_last_used_at
+ :worker_name: SshKeys::UpdateLastUsedAtWorker
+ :feature_category: :source_code_management
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: system_hook_push
:worker_name: SystemHookPushWorker
:feature_category: :source_code_management
@@ -3389,7 +3515,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
@@ -3459,6 +3585,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/authorized_project_update/project_recalculate_per_user_worker.rb b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
index 352c82e5021..96647cc671c 100644
--- a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
+++ b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb
@@ -4,7 +4,7 @@ module AuthorizedProjectUpdate
class ProjectRecalculatePerUserWorker < ProjectRecalculateWorker
data_consistency :always
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb
index 1b5faee0b6f..29ea43ad641 100644
--- a/app/workers/authorized_project_update/project_recalculate_worker.rb
+++ b/app/workers/authorized_project_update/project_recalculate_worker.rb
@@ -7,9 +7,7 @@ module AuthorizedProjectUpdate
data_consistency :always
include Gitlab::ExclusiveLeaseHelpers
- prepend WaitableWorker
-
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
index daebb23baae..cdc0a097c92 100644
--- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
@@ -5,7 +5,7 @@ module AuthorizedProjectUpdate
include ApplicationWorker
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
data_consistency :always
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index 8452f2a7821..ae243a94d3d 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -16,7 +16,7 @@ module AuthorizedProjectUpdate
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
queue_namespace :authorized_project_update
diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
index 7ca59a72adf..d6b41ba949c 100644
--- a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb
@@ -2,7 +2,7 @@
module AuthorizedProjectUpdate
class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
queue_namespace :authorized_project_update
deduplicate :until_executing, including_scheduled: true
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 4312ba41367..dde46a4e61b 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -6,9 +6,8 @@ class AuthorizedProjectsWorker
data_consistency :always
sidekiq_options retry: 3
- prepend WaitableWorker
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :high
weight 2
idempotent!
diff --git a/app/workers/bulk_imports/finish_batched_relation_export_worker.rb b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
new file mode 100644
index 00000000000..aa7bbffa732
--- /dev/null
+++ b/app/workers/bulk_imports/finish_batched_relation_export_worker.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class FinishBatchedRelationExportWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+
+ REENQUEUE_DELAY = 5.seconds
+ TIMEOUT = 6.hours
+
+ def perform(export_id)
+ @export = Export.find_by_id(export_id)
+
+ return unless export
+ return if export.finished? || export.failed?
+ return re_enqueue if export_in_progress?
+ return fail_export! if export_timeout?
+
+ finish_export!
+ end
+
+ private
+
+ attr_reader :export
+
+ def fail_export!
+ expire_cache!
+
+ export.batches.map(&:fail_op!)
+ export.fail_op!
+ end
+
+ def re_enqueue
+ self.class.perform_in(REENQUEUE_DELAY.ago, export.id)
+ end
+
+ def export_timeout?
+ export.updated_at < TIMEOUT.ago
+ end
+
+ def export_in_progress?
+ export.batches.any?(&:started?)
+ end
+
+ def finish_export!
+ expire_cache!
+
+ export.finish!
+ end
+
+ def expire_cache!
+ export.batches.each do |batch|
+ key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+
+ Gitlab::Cache::Import::Caching.expire(key, 0)
+ end
+ end
+ end
+end
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_batch_export_worker.rb b/app/workers/bulk_imports/relation_batch_export_worker.rb
new file mode 100644
index 00000000000..4ce36929e15
--- /dev/null
+++ b/app/workers/bulk_imports/relation_batch_export_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class RelationBatchExportWorker
+ include ApplicationWorker
+
+ idempotent!
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ feature_category :importers
+ sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+
+ def perform(user_id, batch_id)
+ RelationBatchExportService.new(user_id, batch_id).execute
+ end
+ end
+end
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index dcac841b3b2..b6693f0b07d 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -11,12 +11,20 @@ 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)
+ def perform(user_id, portable_id, portable_class, relation, batched = false)
user = User.find(user_id)
portable = portable(portable_id, portable_class)
+ config = BulkImports::FileTransfer.config_for(portable)
- RelationExportService.new(user, portable, relation, jid).execute
+ if Feature.enabled?(:bulk_imports_batched_import_export) &&
+ Gitlab::Utils.to_boolean(batched) &&
+ config.batchable_relation?(relation)
+ BatchedRelationExportService.new(user, portable, relation, jid).execute
+ else
+ RelationExportService.new(user, portable, relation, jid).execute
+ end
end
private
diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb
index fe23d10c2ac..879192e67c4 100644
--- a/app/workers/ci/archive_traces_cron_worker.rb
+++ b/app/workers/ci/archive_traces_cron_worker.rb
@@ -11,20 +11,12 @@ module Ci
feature_category :continuous_integration
deduplicate :until_executed, including_scheduled: true
- # rubocop: disable CodeReuse/ActiveRecord
def perform
# Archive stale live traces which still resides in redis or database
# This could happen when Ci::ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL
# More details in https://gitlab.com/gitlab-org/gitlab-foss/issues/36791
- if Feature.enabled?(:deduplicate_archive_traces_cron_worker)
- Ci::ArchiveTraceService.new.batch_execute(worker_name: self.class.name)
- else
- Ci::Build.with_stale_live_trace.find_each(batch_size: 100) do |build|
- Ci::ArchiveTraceService.new.execute(build, worker_name: self.class.name)
- end
- end
+ Ci::ArchiveTraceService.new.batch_execute(worker_name: self.class.name)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/workers/ci/create_cross_project_pipeline_worker.rb b/app/workers/ci/create_cross_project_pipeline_worker.rb
deleted file mode 100644
index 4881ee12e5c..00000000000
--- a/app/workers/ci/create_cross_project_pipeline_worker.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class CreateCrossProjectPipelineWorker # rubocop:disable Scalability/IdempotentWorker
- include ::ApplicationWorker
- include ::PipelineQueue
-
- sidekiq_options retry: 3
- worker_resource_boundary :cpu
-
- def perform(bridge_id)
- ::Ci::Bridge.find_by_id(bridge_id).try do |bridge|
- ::Ci::CreateDownstreamPipelineService
- .new(bridge.project, bridge.user)
- .execute(bridge)
- end
- end
- end
-end
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/clusters/agents/notify_git_push_worker.rb b/app/workers/clusters/agents/notify_git_push_worker.rb
new file mode 100644
index 00000000000..d2994bb9144
--- /dev/null
+++ b/app/workers/clusters/agents/notify_git_push_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class NotifyGitPushWorker
+ include ApplicationWorker
+ include ClusterAgentQueue
+
+ deduplicate :until_executed, including_scheduled: true
+ idempotent!
+
+ urgency :low
+ data_consistency :delayed
+
+ def perform(project_id)
+ return unless project = ::Project.find_by_id(project_id)
+ return unless Feature.enabled?(:notify_kas_on_git_push, project)
+
+ Gitlab::Kas::Client.new.send_git_push_event(project: project)
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index e2e31b0a5bd..ce77592daac 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -11,6 +11,7 @@ module ApplicationWorker
include WorkerAttributes
include WorkerContext
include Gitlab::SidekiqVersioning::Worker
+ include Gitlab::Loggable
LOGGING_EXTRA_KEY = 'extra'
SAFE_PUSH_BULK_LIMIT = 1000
@@ -28,7 +29,7 @@ module ApplicationWorker
'jid' => jid
)
- payload.stringify_keys.merge(context)
+ build_structured_payload(**payload).merge(context)
end
def log_extra_metadata_on_done(key, value)
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 c5c7da23892..408354d5caa 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -13,12 +13,27 @@ module Gitlab
sidekiq_options retry: 3
include GithubImport::Queue
include ReschedulingMethods
- include Gitlab::NotifyUponDeath
feature_category :importers
worker_has_external_dependencies!
+
+ sidekiq_retries_exhausted do |msg|
+ args = msg['args']
+ correlation_id = msg['correlation_id']
+ jid = msg['jid']
+
+ new.perform_failure(args[0], args[1], correlation_id)
+
+ # If a job is being exhausted we still want to notify the
+ # Gitlab::Import::AdvanceStageWorker to prevent the entire import from getting stuck
+ if args.length == 3 && (key = args.last) && key.is_a?(String)
+ JobWaiter.notify(key, jid)
+ end
+ end
end
+ NotRetriableError = Class.new(StandardError)
+
# project - An instance of `Project` to import the data into.
# client - An instance of `Gitlab::GithubImport::Client`
# hash - A Hash containing the details of the object to import.
@@ -47,13 +62,27 @@ module Gitlab
# Representation is created but the developer forgot to add a
# `:github_identifiers` field.
track_and_raise_exception(project, e, fail_import: true)
- rescue ActiveRecord::RecordInvalid => e
+ rescue ActiveRecord::RecordInvalid, NotRetriableError => e
# We do not raise exception to prevent job retry
- track_exception(project, e)
+ failure = track_exception(project, e)
+ add_identifiers_to_failure(failure, object.github_identifiers)
rescue StandardError => e
track_and_raise_exception(project, e)
end
+ # hash - A Hash containing the details of the object to import.
+ def perform_failure(project_id, hash, correlation_id)
+ project = Project.find_by_id(project_id)
+ return unless project
+
+ failure = project.import_failures.failures_by_correlation_id(correlation_id).first
+ return unless failure
+
+ object = representation_class.from_json_hash(hash)
+
+ add_identifiers_to_failure(failure, object.github_identifiers)
+ end
+
def increment_object_counter?(_object)
true
end
@@ -103,6 +132,12 @@ module Gitlab
raise(exception)
end
+
+ def add_identifiers_to_failure(failure, external_identifiers)
+ external_identifiers[:object_type] = object_type
+
+ failure.update_column(:external_identifiers, external_identifiers)
+ end
end
end
end
diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
index 64fa705329e..772388ffc9e 100644
--- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
@@ -5,6 +5,10 @@ module Gitlab
# Module that provides methods shared by the various workers used for
# importing GitHub projects.
module ReschedulingMethods
+ include JobDelayCalculator
+
+ ENQUEUED_JOB_COUNT = 'github-importer/enqueued_job_count/%{project}/%{collection}'
+
# project_id - The ID of the GitLab project to import the note into.
# hash - A Hash containing the details of the GitHub object to import.
# notify_key - The Redis key to notify upon completion, if any.
@@ -18,10 +22,7 @@ module Gitlab
if try_import(project, client, hash)
notify_waiter(notify_key)
else
- # In the event of hitting the rate limit we want to reschedule the job
- # so its retried after our rate limit has been reset.
- self.class
- .perform_in(client.rate_limit_resets_in, project.id, hash, notify_key)
+ reschedule_job(project, client, hash, notify_key)
end
end
@@ -35,6 +36,20 @@ module Gitlab
def notify_waiter(key = nil)
JobWaiter.notify(key, jid) if key
end
+
+ def reschedule_job(project, client, hash, notify_key)
+ # In the event of hitting the rate limit we want to reschedule the job
+ # so its retried after our rate limit has been reset with additional delay
+ # to spread the load.
+ enqueued_job_count_key = format(ENQUEUED_JOB_COUNT, project: project.id, collection: object_type)
+ enqueued_job_counter =
+ Gitlab::Cache::Import::Caching.increment(enqueued_job_count_key, timeout: client.rate_limit_resets_in)
+
+ job_delay = client.rate_limit_resets_in + calculate_job_delay(enqueued_job_counter)
+
+ self.class
+ .perform_in(job_delay, project.id, hash, notify_key)
+ end
end
end
end
diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb
deleted file mode 100644
index 1796e2441f2..00000000000
--- a/app/workers/concerns/self_monitoring_project_worker.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module SelfMonitoringProjectWorker
- extend ActiveSupport::Concern
-
- included do
- # This worker falls under Self-monitoring with Monitor::APM group. However,
- # self-monitoring is not classified as a feature category but rather as
- # Other Functionality. Metrics seems to be the closest feature_category for
- # this worker.
- feature_category :metrics
- weight 2
- end
-
- LEASE_TIMEOUT = 15.minutes.to_i
- EXCLUSIVE_LEASE_KEY = 'self_monitoring_service_creation_deletion'
-
- class_methods do
- # @param job_id [String]
- # Job ID that is used to construct the cache keys.
- # @return [Hash]
- # Returns true if the job is enqueued or in progress and false otherwise.
- def in_progress?(job_id)
- Gitlab::SidekiqStatus.job_status(Array.wrap(job_id)).first
- end
- end
-
- private
-
- def lease_key
- EXCLUSIVE_LEASE_KEY
- end
-
- def lease_timeout
- self.class::LEASE_TIMEOUT
- end
-end
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
deleted file mode 100644
index 1fe950b7570..00000000000
--- a/app/workers/concerns/waitable_worker.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module WaitableWorker
- extend ActiveSupport::Concern
-
- def perform(*args)
- notify_key = args.pop if Gitlab::JobWaiter.key?(args.last)
-
- super(*args)
- ensure
- Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
- end
-end
diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
index f40855a7455..53e3ac3a1b0 100644
--- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -68,7 +68,7 @@ module ContainerExpirationPolicies
container_repository_id: repo.id
)
- repo.cleanup_ongoing!
+ repo.start_expiration_policy!
end
end
end
@@ -95,10 +95,9 @@ module ContainerExpirationPolicies
def cleanup_scheduled_count
strong_memoize(:cleanup_scheduled_count) do
limit = max_running_jobs + 1
- ContainerExpirationPolicy.with_container_repositories
- .runnable_schedules
- .limit(limit)
- .count
+ ContainerRepository.requiring_cleanup
+ .limit(limit)
+ .count
end
end
diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb
index a838b97b35d..448a16ad309 100644
--- a/app/workers/container_registry/cleanup_worker.rb
+++ b/app/workers/container_registry/cleanup_worker.rb
@@ -12,14 +12,17 @@ module ContainerRegistry
feature_category :container_registry
STALE_DELETE_THRESHOLD = 30.minutes.freeze
+ STALE_REPAIR_DETAIL_THRESHOLD = 2.hours.freeze
BATCH_SIZE = 200
def perform
log_counts
reset_stale_deletes
+ delete_stale_ongoing_repair_details
enqueue_delete_container_repository_jobs if ContainerRepository.delete_scheduled.exists?
+ enqueue_record_repair_detail_jobs if should_enqueue_record_detail_jobs?
end
private
@@ -33,10 +36,31 @@ module ContainerRegistry
end
end
+ def delete_stale_ongoing_repair_details
+ # Deleting stale ongoing repair details would put the project back to the analysis pool
+ ContainerRegistry::DataRepairDetail
+ .ongoing_since(STALE_REPAIR_DETAIL_THRESHOLD.ago)
+ .each_batch(of: BATCH_SIZE) do |batch| # rubocop:disable Style/SymbolProc
+ batch.delete_all
+ end
+ end
+
def enqueue_delete_container_repository_jobs
ContainerRegistry::DeleteContainerRepositoryWorker.perform_with_capacity
end
+ def enqueue_record_repair_detail_jobs
+ ContainerRegistry::RecordDataRepairDetailWorker.perform_with_capacity
+ end
+
+ def should_enqueue_record_detail_jobs?
+ return false unless Gitlab.com?
+ return false unless Feature.enabled?(:registry_data_repair_worker)
+ return false unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
+
+ Project.pending_data_repair_analysis.exists?
+ end
+
def log_counts
::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries do
log_extra_metadata_on_done(
diff --git a/app/workers/container_registry/record_data_repair_detail_worker.rb b/app/workers/container_registry/record_data_repair_detail_worker.rb
new file mode 100644
index 00000000000..f400568a3ef
--- /dev/null
+++ b/app/workers/container_registry/record_data_repair_detail_worker.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+ class RecordDataRepairDetailWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+
+ data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+ queue_namespace :container_repository
+ feature_category :container_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ MAX_CAPACITY = 2
+ LEASE_TIMEOUT = 1.hour.to_i
+
+ def perform_work
+ return unless Gitlab.com?
+ return unless next_project
+ return if next_project.container_registry_data_repair_detail
+
+ missing_count = 0
+
+ try_obtain_lease do
+ detail = create_data_repair_detail
+
+ GitlabApiClient.each_sub_repositories_with_tag_page(path: next_project.full_path,
+ page_size: 50) do |repositories|
+ next if repositories.empty?
+
+ paths = repositories.map { |repo| ContainerRegistry::Path.new(repo["path"]) }
+ paths, invalid_paths = paths.partition(&:valid?)
+ unless invalid_paths.empty?
+ log_extra_metadata_on_done(
+ :invalid_paths_parsed_in_container_repository_repair,
+ invalid_paths.join(' ,')
+ )
+ end
+
+ found_repositories = next_project.container_repositories.where(name: paths.map(&:repository_name)) # rubocop:disable CodeReuse/ActiveRecord
+
+ missing_count += repositories.count - found_repositories.count
+ end
+ detail.update!(missing_count: missing_count, status: :completed)
+ end
+ rescue StandardError => exception
+ next_project.reset.container_registry_data_repair_detail&.update(status: :failed)
+ Gitlab::ErrorTracking.log_exception(exception, class: self.class.name)
+ end
+
+ def remaining_work_count
+ return 0 unless Gitlab.com?
+ return 0 unless Feature.enabled?(:registry_data_repair_worker)
+ return 0 unless ContainerRegistry::GitlabApiClient.supports_gitlab_api?
+
+ Project.pending_data_repair_analysis.limit(max_running_jobs + 1).count
+ end
+
+ def max_running_jobs
+ MAX_CAPACITY
+ end
+
+ private
+
+ def next_project
+ Project.pending_data_repair_analysis.first
+ end
+ strong_memoize_attr :next_project
+
+ def create_data_repair_detail
+ ContainerRegistry::DataRepairDetail.create!(project: next_project, status: :ongoing)
+ end
+
+ # Used by ExclusiveLeaseGuard
+ def lease_key
+ "container_registry_data_repair_detail_worker:#{next_project.id}"
+ end
+
+ # Used by ExclusiveLeaseGuard
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+ 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/delete_user_worker.rb b/app/workers/delete_user_worker.rb
index 0af084caf86..6a375a0cdd4 100644
--- a/app/workers/delete_user_worker.rb
+++ b/app/workers/delete_user_worker.rb
@@ -7,12 +7,17 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
- feature_category :authentication_and_authorization
+ feature_category :user_management
loggable_arguments 2
def perform(current_user_id, delete_user_id, options = {})
- delete_user = User.find(delete_user_id)
- current_user = User.find(current_user_id)
+ delete_user = User.find_by_id(delete_user_id)
+ return unless delete_user.present?
+
+ return if delete_user.banned? && ::Feature.enabled?(:delay_delete_own_user)
+
+ current_user = User.find_by_id(current_user_id)
+ return unless current_user.present?
Users::DestroyService.new(current_user).execute(delete_user, options.symbolize_keys)
rescue Gitlab::Access::AccessDeniedError => e
diff --git a/app/workers/deployments/drop_older_deployments_worker.rb b/app/workers/deployments/drop_older_deployments_worker.rb
deleted file mode 100644
index c464febd119..00000000000
--- a/app/workers/deployments/drop_older_deployments_worker.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Deployments
- class DropOlderDeploymentsWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- queue_namespace :deployment
- feature_category :continuous_delivery
-
- def perform(deployment_id)
- Deployments::OlderDeploymentsDropService.new(deployment_id).execute
- end
- end
-end
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/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index a9f645bd634..45f4bf486d7 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -20,6 +20,7 @@ module Gitlab
# The known importer stages and their corresponding Sidekiq workers.
STAGES = {
+ collaborators: Stage::ImportCollaboratorsWorker,
pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker,
pull_request_review_requests: Stage::ImportPullRequestsReviewRequestsWorker,
pull_request_reviews: Stage::ImportPullRequestsReviewsWorker,
diff --git a/app/workers/gitlab/github_import/import_collaborator_worker.rb b/app/workers/gitlab/github_import/import_collaborator_worker.rb
new file mode 100644
index 00000000000..35cb3fa6830
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_collaborator_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportCollaboratorWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ def representation_class
+ Representation::Collaborator
+ end
+
+ def importer_class
+ Importer::CollaboratorImporter
+ end
+
+ def object_type
+ :collaborator
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
index ab0cb81249b..94472fdf6db 100644
--- a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# TODO: remove in 16.1 milestone
+# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
module Gitlab
module GithubImport
class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker
@@ -12,7 +14,7 @@ module Gitlab
end
def importer_class
- Importer::PullRequestMergedByImporter
+ Importer::PullRequests::MergedByImporter
end
def object_type
diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
index 8d5c7b95b10..6b7d19010ec 100644
--- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# TODO: remove in 16.1 milestone
+# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
module Gitlab
module GithubImport
class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker
@@ -12,7 +14,7 @@ module Gitlab
end
def importer_class
- Importer::PullRequestReviewImporter
+ Importer::PullRequests::ReviewImporter
end
def object_type
diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb
index 79938a157d7..dbc1feca2e0 100644
--- a/app/workers/gitlab/github_import/import_pull_request_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb
@@ -16,6 +16,10 @@ module Gitlab
def object_type
:pull_request
end
+
+ def parallel_import_batch
+ { size: 200, delay: 1.minute }
+ end
end
end
end
diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb
index bf901f2f7b8..0d3831789bf 100644
--- a/app/workers/gitlab/github_import/import_release_attachments_worker.rb
+++ b/app/workers/gitlab/github_import/import_release_attachments_worker.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-# TODO: remove in 16.X milestone
-# https://gitlab.com/gitlab-org/gitlab/-/issues/377059
+# TODO: remove in 16.1 milestone
+# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
module Gitlab
module GithubImport
class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker
diff --git a/app/workers/gitlab/github_import/pull_requests/import_merged_by_worker.rb b/app/workers/gitlab/github_import/pull_requests/import_merged_by_worker.rb
new file mode 100644
index 00000000000..2c9b2cdffac
--- /dev/null
+++ b/app/workers/gitlab/github_import/pull_requests/import_merged_by_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module PullRequests
+ class ImportMergedByWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ worker_resource_boundary :cpu
+
+ def representation_class
+ Gitlab::GithubImport::Representation::PullRequest
+ end
+
+ def importer_class
+ Importer::PullRequests::MergedByImporter
+ end
+
+ def object_type
+ :pull_request_merged_by
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/pull_requests/import_review_worker.rb b/app/workers/gitlab/github_import/pull_requests/import_review_worker.rb
new file mode 100644
index 00000000000..97521d629f9
--- /dev/null
+++ b/app/workers/gitlab/github_import/pull_requests/import_review_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module PullRequests
+ class ImportReviewWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ worker_resource_boundary :cpu
+
+ def representation_class
+ Gitlab::GithubImport::Representation::PullRequestReview
+ end
+
+ def importer_class
+ Importer::PullRequests::ReviewImporter
+ end
+
+ def object_type
+ :pull_request_review
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index 5197c1e1e88..e716eda5c99 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -12,10 +12,6 @@ module Gitlab
include GithubImport::Queue
include StageMethods
- # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
- sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
- sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 200_000).to_i
-
# project - An instance of Project.
def import(_, project)
@project = project
diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb
index e4a413b4081..4045852e3f0 100644
--- a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb
@@ -15,7 +15,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- return skip_to_next_stage(project) if feature_disabled?(project)
+ return skip_to_next_stage(project) if import_settings(project).disabled?(:attachments_import)
waiters = importers.each_with_object({}) do |importer, hash|
waiter = start_importer(project, importer, client)
@@ -53,10 +53,6 @@ module Gitlab
:protected_branches
)
end
-
- def feature_disabled?(project)
- import_settings(project).disabled?(:attachments_import)
- end
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb
new file mode 100644
index 00000000000..8f72cc051b3
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportCollaboratorsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ return skip_to_next_stage(project) if import_settings(project).disabled?(:collaborators_import) ||
+ !has_push_access?(client, project.import_source)
+
+ info(project.id, message: 'starting importer', importer: 'Importer::CollaboratorsImporter')
+
+ waiter = Importer::CollaboratorsImporter.new(project, client).execute
+ project.import_state.refresh_jid_expiration
+
+ move_to_next_stage(project, { waiter.key => waiter.jobs_remaining })
+ rescue StandardError => e
+ Gitlab::Import::ImportFailureService.track(
+ project_id: project.id,
+ error_source: self.class.name,
+ exception: e,
+ fail_import: abort_on_failure,
+ metrics: true
+ )
+
+ raise(e)
+ end
+
+ private
+
+ def has_push_access?(client, repo)
+ client.repository(repo).dig(:permissions, :push)
+ end
+
+ def skip_to_next_stage(project)
+ Gitlab::GithubImport::Logger.warn(
+ log_attributes(
+ project.id,
+ message: 'no push access rights to fetch collaborators',
+ importer: 'Importer::CollaboratorsImporter'
+ )
+ )
+ move_to_next_stage(project, {})
+ end
+
+ def move_to_next_stage(project, waiters = {})
+ AdvanceStageWorker.perform_async(
+ project.id, waiters, :pull_requests_merged_by
+ )
+ end
+
+ def abort_on_failure
+ true
+ end
+ end
+ end
+ end
+end
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/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb
index 9b123b5776a..329bf8f84b1 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb
@@ -15,7 +15,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- waiter = Importer::PullRequestsMergedByImporter
+ waiter = Importer::PullRequests::AllMergedByImporter
.new(project, client)
.execute
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb
index e10f1170618..33dee47bd03 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb
@@ -15,7 +15,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- waiter = Importer::PullRequestsReviewsImporter
+ waiter = Importer::PullRequests::ReviewsImporter
.new(project, client)
.execute
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
index 71d0247bae0..e7eee0915d5 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -25,7 +25,7 @@ module Gitlab
AdvanceStageWorker.perform_async(
project.id,
{ waiter.key => waiter.jobs_remaining },
- :pull_requests_merged_by
+ :collaborators
)
rescue StandardError => e
Gitlab::Import::ImportFailureService.track(
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
index 8c1a2cd2677..d998771b328 100644
--- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -12,10 +12,6 @@ module Gitlab
include GithubImport::Queue
include StageMethods
- # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
- sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MEMORY_GROWTH_KB', 50).to_i
- sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
-
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
@@ -72,7 +68,7 @@ module Gitlab
return unless last_github_issue
- Issue.track_project_iid!(project, last_github_issue[:number])
+ Issue.track_namespace_iid!(project.project_namespace, last_github_issue[:number])
end
end
end
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/gitlab/phabricator_import/base_worker.rb b/app/workers/gitlab/phabricator_import/base_worker.rb
deleted file mode 100644
index 2dc4855f854..00000000000
--- a/app/workers/gitlab/phabricator_import/base_worker.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-# All workers within a Phabricator import should inherit from this worker and
-# implement the `#import` method. The jobs should then be scheduled using the
-# `.schedule` class method instead of `.perform_async`
-#
-# Doing this makes sure that only one job of that type is running at the same time
-# for a certain project. This will avoid deadlocks. When a job is already running
-# we'll wait for it for 10 times 5 seconds to restart. If the running job hasn't
-# finished, by then, we'll retry in 30 seconds.
-#
-# It also makes sure that we keep the import state of the project up to date:
-# - It keeps track of the jobs so we know how many jobs are running for the
-# project
-# - It refreshes the import jid, so it doesn't get cleaned up by the
-# `Gitlab::Import::StuckProjectImportJobsWorker`
-# - It marks the import as failed if a job failed to many times
-# - It marks the import as finished when all remaining jobs are done
-module Gitlab
- module PhabricatorImport
- class BaseWorker # rubocop:disable Scalability/IdempotentWorker
- include WorkerAttributes
- include Gitlab::ExclusiveLeaseHelpers
-
- feature_category :importers
-
- class << self
- def schedule(project_id, *args)
- perform_async(project_id, *args)
- add_job(project_id)
- end
-
- def add_job(project_id)
- worker_state(project_id).add_job
- end
-
- def remove_job(project_id)
- worker_state(project_id).remove_job
- end
-
- def worker_state(project_id)
- Gitlab::PhabricatorImport::WorkerState.new(project_id)
- end
- end
-
- def perform(project_id, *args)
- in_lock("#{self.class.name.underscore}/#{project_id}/#{args}", ttl: 2.hours, sleep_sec: 5.seconds) do
- project = Project.find_by_id(project_id)
- next unless project
-
- # Bail if the import job already failed
- next unless project.import_state&.in_progress?
-
- project.import_state.refresh_jid_expiration
-
- import(project, *args)
-
- # If this is the last running job, finish the import
- project.after_import if self.class.worker_state(project_id).running_count < 2
-
- self.class.remove_job(project_id)
- end
- rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
- # Reschedule a job if there was already a running one
- # Running them at the same time could cause a deadlock updating the same
- # resource
- self.class.perform_in(30.seconds, project_id, *args)
- end
-
- private
-
- def import(project, *args)
- importer_class.new(project, *args).execute
- end
-
- def importer_class
- raise NotImplementedError, "Implement `#{__method__}` on #{self.class}"
- end
- end
- end
-end
diff --git a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb
deleted file mode 100644
index f650681fc2f..00000000000
--- a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-module Gitlab
- module PhabricatorImport
- class ImportTasksWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
- include ProjectImportOptions # This marks the project as failed after too many tries
-
- def importer_class
- Gitlab::PhabricatorImport::Issues::Importer
- end
- end
- end
-end
diff --git a/app/workers/gitlab_service_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb
index b02e7318585..53a4361fb48 100644
--- a/app/workers/gitlab_service_ping_worker.rb
+++ b/app/workers/gitlab_service_ping_worker.rb
@@ -52,3 +52,5 @@ class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker
nil
end
end
+
+GitlabServicePingWorker.prepend_mod
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index 92195d3fe16..a116944feb9 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GroupDestroyWorker # rubocop:disable Scalability/IdempotentWorker
+class GroupDestroyWorker
include ApplicationWorker
data_consistency :always
@@ -10,6 +10,9 @@ class GroupDestroyWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :subgroups
+ idempotent!
+ deduplicate :until_executed, ttl: 2.hours
+
def perform(group_id, user_id)
begin
group = Group.find(group_id)
diff --git a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
index ac1d3589516..ca68a82ec66 100644
--- a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
+++ b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb
@@ -9,7 +9,7 @@ module Groups
idempotent!
- feature_category :authentication_and_authorization
+ feature_category :system_access
def perform(group_id)
group = Group.find_by_id(group_id)
diff --git a/app/workers/integrations/slack_event_worker.rb b/app/workers/integrations/slack_event_worker.rb
new file mode 100644
index 00000000000..e5cdae1beea
--- /dev/null
+++ b/app/workers/integrations/slack_event_worker.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Integrations
+ class SlackEventWorker
+ include ApplicationWorker
+
+ EVENTS = {
+ 'app_home_opened' => SlackEvents::AppHomeOpenedService
+ }.freeze
+
+ feature_category :integrations
+ data_consistency :delayed
+ urgency :low
+ deduplicate :until_executed
+ idempotent!
+ worker_has_external_dependencies!
+
+ def self.event?(slack_event)
+ EVENTS.key?(slack_event)
+ end
+
+ def perform(args)
+ args = args.with_indifferent_access
+
+ log_extra_metadata_on_done(:slack_event, args[:slack_event])
+ log_extra_metadata_on_done(:slack_user_id, args.dig(:params, :event, :user))
+ log_extra_metadata_on_done(:slack_workspace_id, args.dig(:params, :team_id))
+
+ unless self.class.event?(args[:slack_event])
+ Sidekiq.logger.error(
+ message: 'Unknown slack_event',
+ slack_event: args[:slack_event]
+ )
+
+ return
+ end
+
+ # Ensure idempotency by taking out an exclusive lease keyed to `params.event_id`.
+ # The `event_id` is "a unique identifier for this specific event, globally unique
+ # across all workspaces" and guaranteed to be present as part of the Slack event JSON schema.
+ # See https://api.slack.com/types/event.
+ lease = Gitlab::ExclusiveLease.new("slack_event:#{args[:params][:event_id]}", timeout: 1.hour.to_i)
+ return unless lease.try_obtain
+
+ service_class = EVENTS[args[:slack_event]]
+ response = service_class.new(args[:params]).execute
+
+ lease.cancel if response.error?
+ rescue StandardError => e
+ lease.cancel
+ raise e
+ end
+ end
+end
diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb
index 1c5fab8c4c0..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)
@@ -25,7 +25,13 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
def export_service(type, user, project, params)
issuable_classes = issuable_classes_for(type.to_sym)
issuables = issuable_classes[:finder].new(user, parse_params(params, project.id, type)).execute
- issuable_classes[:service].new(issuables, project)
+
+ if type.to_sym == :issue # issues do not support field selection for export
+ issuable_classes[:service].new(issuables, project, user)
+ else
+ fields = params.with_indifferent_access.delete(:selected_fields) || []
+ issuable_classes[:service].new(issuables, project, fields)
+ end
end
def issuable_classes_for(type)
@@ -34,6 +40,8 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
{ finder: IssuesFinder, service: Issues::ExportCsvService }
when :merge_request
{ finder: MergeRequestsFinder, service: MergeRequests::ExportCsvService }
+ when :work_item
+ { finder: WorkItems::WorkItemsFinder, service: WorkItems::ExportCsvService }
else
raise ArgumentError, type_error_message(type)
end
@@ -47,7 +55,13 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
end
def type_error_message(type)
- "Type parameter must be :issue or :merge_request, it was #{type}"
+ types_sentence = allowed_types.to_sentence(last_word_connector: ' or ')
+
+ "Type parameter must be #{types_sentence}, it was #{type}"
+ end
+
+ def allowed_types
+ %w[:issue :merge_request :work_item]
end
end
diff --git a/app/workers/issues/placement_worker.rb b/app/workers/issues/placement_worker.rb
index ec29a754128..0a4f2612912 100644
--- a/app/workers/issues/placement_worker.rb
+++ b/app/workers/issues/placement_worker.rb
@@ -40,7 +40,7 @@ module Issues
leftover = to_place.pop if to_place.count > QUERY_LIMIT
Issue.move_nulls_to_end(to_place)
- Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position))
+ Issues::BaseService.new(container: nil).rebalance_if_needed(to_place.max_by(&:relative_position))
Issues::PlacementWorker.perform_async(nil, leftover.project_id) if leftover.present?
rescue RelativePositioning::NoSpaceLeft => e
Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_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..40f225ab756 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,34 @@ 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 = {
+ branches: branches_to_sync(project),
+ merge_requests: merge_requests_to_sync(project),
+ update_sequence_id: update_sequence_id
+ }
+
+ 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/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb
index 915551d6e30..2e6ce0005fc 100644
--- a/app/workers/members_destroyer/unassign_issuables_worker.rb
+++ b/app/workers/members_destroyer/unassign_issuables_worker.rb
@@ -11,7 +11,7 @@ module MembersDestroyer
ENTITY_TYPES = %w(Group Project).freeze
queue_namespace :unassign_issuables
- feature_category :authentication_and_authorization
+ feature_category :user_management
idempotent!
diff --git a/app/workers/merge_requests/set_reviewer_reviewed_worker.rb b/app/workers/merge_requests/set_reviewer_reviewed_worker.rb
new file mode 100644
index 00000000000..2f15bf3b879
--- /dev/null
+++ b/app/workers/merge_requests/set_reviewer_reviewed_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class SetReviewerReviewedWorker
+ include Gitlab::EventStore::Subscriber
+
+ data_consistency :always
+ feature_category :code_review_workflow
+ urgency :low
+ idempotent!
+
+ def handle_event(event)
+ current_user_id = event.data[:current_user_id]
+ merge_request_id = event.data[:merge_request_id]
+ current_user = User.find_by_id(current_user_id)
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ if !current_user
+ logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id))
+ elsif !merge_request
+ logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id))
+ else
+ project = merge_request.source_project
+
+ ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user)
+ .execute(merge_request)
+ end
+ end
+ end
+end
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..54169165f64 100644
--- a/app/workers/namespaces/process_sync_events_worker.rb
+++ b/app/workers/namespaces/process_sync_events_worker.rb
@@ -9,11 +9,11 @@ module Namespaces
data_consistency :always
- feature_category :pods
+ feature_category :cell
urgency :high
idempotent!
- deduplicate :until_executed, if_deduplicated: :reschedule_once
+ deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
index a32a414c0ba..74239c5d968 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -18,7 +18,6 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
def perform(merge_request_id, user_id)
return unless objects_found?(merge_request_id, user_id)
- return if issuable.prepared?
MergeRequests::AfterCreateService
.new(project: issuable.target_project, current_user: user)
diff --git a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb
new file mode 100644
index 00000000000..0b3d3c98742
--- /dev/null
+++ b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Packages
+ module Cleanup
+ class DeleteOrphanedDependenciesWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ data_consistency :sticky
+ feature_category :package_registry
+ urgency :low
+ idempotent!
+
+ # This cron worker is executed at an interval of 10 minutes and should not run for
+ # more than 2 minutes nor process more than 10 batches.
+ MAX_RUN_TIME = 2.minutes
+ MAX_BATCHES = 10
+ BATCH_SIZE = 100
+ LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY = 'last_processed_packages_dependency_id'
+ REDIS_EXPIRATION_TIME = 2.hours.to_i
+
+ def perform
+ return unless enabled?
+
+ start_time
+
+ dependency_id = last_processed_dependency_id
+ batches_count = 0
+ deleted_rows_count = 0
+
+ ::Packages::Dependency.id_in(dependency_id..).each_batch(of: BATCH_SIZE) do |batch|
+ batches_count += 1
+ deleted_rows_count += batch.orphaned.delete_all
+
+ if batches_count == MAX_BATCHES || over_time?
+ save_last_processed_dependency_id(batch.maximum(:id))
+ break
+ end
+ end
+
+ log_extra_metadata(deleted_rows_count)
+ reset_last_processed_dependency_id if batches_count < MAX_BATCHES && !over_time?
+ end
+
+ private
+
+ def enabled?
+ Feature.enabled?(:packages_delete_orphaned_dependencies_worker)
+ end
+
+ def start_time
+ @start_time ||= ::Gitlab::Metrics::System.monotonic_time
+ end
+
+ def over_time?
+ (::Gitlab::Metrics::System.monotonic_time - start_time) > MAX_RUN_TIME
+ end
+
+ def save_last_processed_dependency_id(dependency_id)
+ with_redis do |redis|
+ redis.set(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY, dependency_id, ex: REDIS_EXPIRATION_TIME)
+ end
+ end
+
+ def last_processed_dependency_id
+ with_redis do |redis|
+ redis.get(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY).to_i
+ end
+ end
+
+ def reset_last_processed_dependency_id
+ with_redis do |redis|
+ redis.del(LAST_PROCESSED_PACKAGES_DEPENDENCY_REDIS_KEY)
+ end
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::SharedState.with(&block) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def log_extra_metadata(deleted_rows_count)
+ log_extra_metadata_on_done(:last_processed_packages_dependency_id, last_processed_dependency_id)
+ log_extra_metadata_on_done(:deleted_rows_count, deleted_rows_count)
+ end
+ end
+ end
+end
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/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb
index 1eff3ea02dd..f0c753c3a9b 100644
--- a/app/workers/packages/debian/generate_distribution_worker.rb
+++ b/app/workers/packages/debian/generate_distribution_worker.rb
@@ -20,7 +20,7 @@ module Packages
loggable_arguments 0
def perform(container_type, distribution_id)
- @container_type = container_type
+ @container_type = container_type.to_sym
@distribution_id = distribution_id
return unless distribution
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/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb
index b119957fa2c..f86bd604cdf 100644
--- a/app/workers/personal_access_tokens/expired_notification_worker.rb
+++ b/app/workers/personal_access_tokens/expired_notification_worker.rb
@@ -8,7 +8,7 @@ module PersonalAccessTokens
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
MAX_TOKENS = 100
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
index f4afa9f8994..de0bda82573 100644
--- a/app/workers/personal_access_tokens/expiring_worker.rb
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -8,7 +8,7 @@ module PersonalAccessTokens
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
MAX_TOKENS = 100
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index b4712aaeafb..015b89630a1 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -14,7 +14,7 @@ class PipelineProcessWorker
loggable_arguments 1
idempotent!
- deduplicate :until_executing
+ deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute
def perform(pipeline_id)
Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index f95176da252..676a834d79d 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -140,8 +140,6 @@ class PostReceive
end
def emit_snowplow_event(project, user)
- return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace)
-
metric_path = 'counts.source_code_pushes'
Gitlab::Tracking.event(
'PostReceive',
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index 45d0ebd2b65..181eebe56e8 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker
+class ProjectDestroyWorker
include ApplicationWorker
data_consistency :always
@@ -10,6 +10,9 @@ class ProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :source_code_management
+ idempotent!
+ deduplicate :until_executed, ttl: 2.hours
+
def perform(project_id, user_id, params)
project = Project.find(project_id)
user = User.find(user_id)
diff --git a/app/workers/projects/forks/sync_worker.rb b/app/workers/projects/forks/sync_worker.rb
new file mode 100644
index 00000000000..2fa6785bc91
--- /dev/null
+++ b/app/workers/projects/forks/sync_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Projects
+ module Forks
+ class SyncWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+ idempotent!
+ urgency :high
+ feature_category :source_code_management
+
+ def perform(project_id, user_id, ref)
+ project = Project.find_by_id(project_id)
+ user = User.find_by_id(user_id)
+ return unless project && user
+
+ ::Projects::Forks::SyncService.new(project, user, ref).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/projects/import_export/create_relation_exports_worker.rb b/app/workers/projects/import_export/create_relation_exports_worker.rb
new file mode 100644
index 00000000000..9ca69a5500a
--- /dev/null
+++ b/app/workers/projects/import_export/create_relation_exports_worker.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class CreateRelationExportsWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ idempotent!
+ data_consistency :always
+ deduplicate :until_executed
+ feature_category :importers
+ worker_resource_boundary :cpu
+ sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+
+ # This delay is an arbitrary number to finish the export quicker in case all relations
+ # are exported before the first execution of the WaitRelationExportsWorker worker.
+ INITIAL_DELAY = 10.seconds
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(user_id, project_id, after_export_strategy = {})
+ project = Project.find_by_id(project_id)
+ return unless project
+
+ project_export_job = project.export_jobs.find_or_create_by!(jid: jid)
+ return if project_export_job.started?
+
+ relation_exports = RelationExport.relation_names_list.map do |relation_name|
+ project_export_job.relation_exports.find_or_create_by!(relation: relation_name)
+ end
+
+ relation_exports.each do |relation_export|
+ RelationExportWorker.with_status.perform_async(relation_export.id)
+ end
+
+ WaitRelationExportsWorker.perform_in(
+ INITIAL_DELAY,
+ project_export_job.id,
+ user_id,
+ after_export_strategy
+ )
+
+ project_export_job.start!
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/workers/projects/import_export/relation_export_worker.rb b/app/workers/projects/import_export/relation_export_worker.rb
index 13ca33c4457..7747d4f4099 100644
--- a/app/workers/projects/import_export/relation_export_worker.rb
+++ b/app/workers/projects/import_export/relation_export_worker.rb
@@ -10,13 +10,34 @@ module Projects
data_consistency :always
deduplicate :until_executed
feature_category :importers
- sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+ sidekiq_options dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
urgency :low
worker_resource_boundary :memory
+ sidekiq_retries_exhausted do |job, exception|
+ relation_export = Projects::ImportExport::RelationExport.find(job['args'].first)
+ project_export_job = relation_export.project_export_job
+ project = project_export_job.project
+
+ relation_export.mark_as_failed(job['error_message'])
+
+ log_payload = {
+ message: 'Project relation export failed',
+ export_error: job['error_message'],
+ relation: relation_export.relation,
+ project_export_job_id: project_export_job.id,
+ project_name: project.name,
+ project_id: project.id
+ }
+ Gitlab::ExceptionLogFormatter.format!(exception, log_payload)
+ Gitlab::Export::Logger.error(log_payload)
+ end
+
def perform(project_relation_export_id)
relation_export = Projects::ImportExport::RelationExport.find(project_relation_export_id)
+ relation_export.retry! if relation_export.started?
+
if relation_export.queued?
Projects::ImportExport::RelationExportService.new(relation_export, jid).execute
end
diff --git a/app/workers/projects/import_export/wait_relation_exports_worker.rb b/app/workers/projects/import_export/wait_relation_exports_worker.rb
new file mode 100644
index 00000000000..4250073edce
--- /dev/null
+++ b/app/workers/projects/import_export/wait_relation_exports_worker.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Projects
+ module ImportExport
+ class WaitRelationExportsWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ idempotent!
+ data_consistency :always
+ deduplicate :until_executed
+ feature_category :importers
+ loggable_arguments 1, 2
+ worker_resource_boundary :cpu
+ sidekiq_options dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
+
+ INTERVAL = 1.minute
+
+ def perform(project_export_job_id, user_id, after_export_strategy = {})
+ @export_job = ProjectExportJob.find(project_export_job_id)
+
+ return unless @export_job.started?
+
+ @export_job.update_attribute(:jid, jid)
+ @relation_exports = @export_job.relation_exports
+
+ if queued_relation_exports.any? || started_relation_exports.any?
+ fail_started_jobs_no_longer_running
+
+ self.class.perform_in(INTERVAL, project_export_job_id, user_id, after_export_strategy)
+ return
+ end
+
+ if all_relation_export_finished?
+ ParallelProjectExportWorker.perform_async(project_export_job_id, user_id, after_export_strategy)
+ return
+ end
+
+ fail_and_notify_user(user_id)
+ end
+
+ private
+
+ def relation_exports_with_status(status)
+ @relation_exports.select { |relation_export| relation_export.status == status }
+ end
+
+ def queued_relation_exports
+ relation_exports_with_status(RelationExport::STATUS[:queued])
+ end
+
+ def started_relation_exports
+ @started_relation_exports ||= relation_exports_with_status(RelationExport::STATUS[:started])
+ end
+
+ def all_relation_export_finished?
+ @relation_exports.all? { |relation_export| relation_export.status == RelationExport::STATUS[:finished] }
+ end
+
+ def fail_started_jobs_no_longer_running
+ started_relation_exports.each do |relation_export|
+ next if Gitlab::SidekiqStatus.running?(relation_export.jid)
+ next if relation_export.reset.finished?
+
+ relation_export.mark_as_failed("Exausted number of retries to export: #{relation_export.relation}")
+ end
+ end
+
+ def fail_and_notify_user(user_id)
+ @export_job.fail_op!
+
+ @user = User.find_by_id(user_id)
+ return unless @user
+
+ failed_relation_exports = relation_exports_with_status(RelationExport::STATUS[:failed])
+ errors = failed_relation_exports.map(&:export_error)
+
+ NotificationService.new.project_not_exported(@export_job.project, @user, errors)
+ 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..93b6b6bfabd 100644
--- a/app/workers/projects/process_sync_events_worker.rb
+++ b/app/workers/projects/process_sync_events_worker.rb
@@ -9,11 +9,11 @@ module Projects
data_consistency :always
- feature_category :pods
+ feature_category :cell
urgency :high
idempotent!
- deduplicate :until_executed, if_deduplicated: :reschedule_once
+ deduplicate :until_executed, if_deduplicated: :reschedule_once, ttl: 1.minute
def perform
results = ::Ci::ProcessSyncEventsService.new(
diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb
index e69450692d9..2e523ccc992 100644
--- a/app/workers/projects/record_target_platforms_worker.rb
+++ b/app/workers/projects/record_target_platforms_worker.rb
@@ -7,7 +7,6 @@ module Projects
LEASE_TIMEOUT = 1.hour.to_i
APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze
- ANDROID_PLATFORM_LANGUAGES = %w(java kotlin).freeze
feature_category :projects
data_consistency :always
@@ -32,28 +31,13 @@ module Projects
attr_reader :target_platforms, :project
def detector_service
- if uses_apple_platform_languages?
- AppleTargetPlatformDetectorService
- elsif uses_android_platform_languages? && detect_android_projects_enabled?
- AndroidTargetPlatformDetectorService
- end
- end
-
- def detect_android_projects_enabled?
- Feature.enabled?(:detect_android_projects, project)
- end
-
- def uses_apple_platform_languages?
- target_languages.with_programming_language(*APPLE_PLATFORM_LANGUAGES).present?
- end
+ return if target_languages.blank?
- def uses_android_platform_languages?
- target_languages.with_programming_language(*ANDROID_PLATFORM_LANGUAGES).present?
+ AppleTargetPlatformDetectorService
end
def target_languages
- languages = APPLE_PLATFORM_LANGUAGES + ANDROID_PLATFORM_LANGUAGES
- @target_languages ||= project.repository_languages.with_programming_language(*languages)
+ @target_languages ||= project.repository_languages.with_programming_language(*APPLE_PLATFORM_LANGUAGES)
end
def log_target_platforms_metadata
diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb
index c8dfb2ade0a..927c21d9c53 100644
--- a/app/workers/prune_old_events_worker.rb
+++ b/app/workers/prune_old_events_worker.rb
@@ -15,9 +15,13 @@ class PruneOldEventsWorker # rubocop:disable Scalability/IdempotentWorker
DELETE_LIMIT = 10_000
def perform
- # Contribution calendar shows maximum 12 months of events, we retain 3 years for data integrity.
- cutoff_date = (3.years + 1.day).ago
+ if Feature.enabled?(:ops_prune_old_events, type: :ops)
+ # Contribution calendar shows maximum 12 months of events, we retain 3 years for data integrity.
+ cutoff_date = (3.years + 1.day).ago
- Event.unscoped.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT)
+ Event.unscoped.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT)
+ else
+ Gitlab::AppLogger.info(":ops_prune_old_events is disabled, skipping.")
+ end
end
end
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index 37298c53a5c..f1da5f37945 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -7,7 +7,7 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :authentication_and_authorization
+ feature_category :system_access
def perform
ProjectGroupLink.expired.find_each do |link|
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index c9eb715a522..b5031f4cda6 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -7,7 +7,7 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker
include CronjobQueue
- feature_category :authentication_and_authorization
+ feature_category :system_access
worker_resource_boundary :cpu
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/remove_unaccepted_member_invites_worker.rb b/app/workers/remove_unaccepted_member_invites_worker.rb
index 7fe45b26094..96f60b5fa12 100644
--- a/app/workers/remove_unaccepted_member_invites_worker.rb
+++ b/app/workers/remove_unaccepted_member_invites_worker.rb
@@ -7,7 +7,7 @@ class RemoveUnacceptedMemberInvitesWorker # rubocop:disable Scalability/Idempote
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :authentication_and_authorization
+ feature_category :system_access
urgency :low
idempotent!
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index f9e12c5135a..c16071e8e31 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -12,10 +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
-
- # 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
- sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
+ worker_resource_boundary :memory
def perform(project_id)
@project = Project.find_by_id(project_id)
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/self_monitoring_project_create_worker.rb b/app/workers/self_monitoring_project_create_worker.rb
deleted file mode 100644
index 97d858eddd9..00000000000
--- a/app/workers/self_monitoring_project_create_worker.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-class SelfMonitoringProjectCreateWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
- include ExclusiveLeaseGuard
- include SelfMonitoringProjectWorker
-
- def perform
- try_obtain_lease do
- Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService.new.execute
- end
- end
-end
diff --git a/app/workers/self_monitoring_project_delete_worker.rb b/app/workers/self_monitoring_project_delete_worker.rb
deleted file mode 100644
index 74dc4cb6581..00000000000
--- a/app/workers/self_monitoring_project_delete_worker.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-class SelfMonitoringProjectDeleteWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
- include ExclusiveLeaseGuard
- include SelfMonitoringProjectWorker
-
- def perform
- try_obtain_lease do
- Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService.new.execute
- end
- 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/ssh_keys/update_last_used_at_worker.rb b/app/workers/ssh_keys/update_last_used_at_worker.rb
new file mode 100644
index 00000000000..80c2132b8d8
--- /dev/null
+++ b/app/workers/ssh_keys/update_last_used_at_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module SshKeys
+ class UpdateLastUsedAtWorker
+ include ApplicationWorker
+
+ idempotent!
+ deduplicate :until_executed
+ data_consistency :sticky
+
+ feature_category :source_code_management
+
+ def perform(key_id)
+ key = Key.find_by_id(key_id)
+
+ return unless key
+
+ Keys::LastUsedService.new(key).execute
+ end
+ end
+end
diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb
index e0d8958fc80..97da76346b6 100644
--- a/app/workers/stage_update_worker.rb
+++ b/app/workers/stage_update_worker.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# This will be scheduled to be removed after removing the FF ci_remove_ensure_stage_service
class StageUpdateWorker
include ApplicationWorker
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